[
  {
    "path": ".coveragerc",
    "content": "[run]\nsource = bookmarks\nomit = bookmarks/tests/*\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/python\n{\n\t\"name\": \"Python 3\",\n\t\"image\": \"mcr.microsoft.com/devcontainers/python:3.13\",\n\t\"features\": {\n\t\t\"ghcr.io/devcontainers/features/node:1\": {}\n\t},\n\n\t// Features to add to the dev container. More info: https://containers.dev/features.\n\t// \"features\": {},\n\n\t// Use 'forwardPorts' to make a list of ports inside the container available locally.\n\t\"forwardPorts\": [8000],\n\n\t// Use 'postCreateCommand' to run commands after the container is created.\n\t\"postCreateCommand\": \"pip install uv && uv sync --group dev && npm install && mkdir -p data && uv run manage.py migrate\",\n\n\t// Configure tool-specific properties.\n\t\"customizations\": {\n\t\t\"vscode\": {\n\t\t\t\"extensions\": [\n\t\t\t\t\"ms-python.python\"\n\t\t\t]\n\t\t}\n\t},\n\n\t\"remoteUser\": \"vscode\"\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": "# Ignore everything\n*\n\n# Include files required for build or at runtime\n!/bookmarks\n\n!/bootstrap.sh\n!/LICENSE.txt\n!/manage.py\n!/package.json\n!/package-lock.json\n!/postcss.config.js\n!/pyproject.toml\n!/rollup.config.mjs\n!/supervisord-tasks.conf\n!/supervisord-all.conf\n!/uv.lock\n!/uwsgi.ini\n!/version.txt\n\n # Remove dev settings\n/bookmarks/settings/dev.py\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto\n*.sh text eol=lf"
  },
  {
    "path": ".github/workflows/build-test.yaml",
    "content": "name: build-test\n\non: workflow_dispatch\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ vars.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ github.token }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build latest\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/default.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            ghcr.io/sissbruecker/linkding:test\n          target: linkding\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max\n\n      - name: Build latest-alpine\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/alpine.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            ghcr.io/sissbruecker/linkding:test-alpine\n          target: linkding\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max\n\n      - name: Build latest-plus\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/default.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            ghcr.io/sissbruecker/linkding:test-plus\n          target: linkding-plus\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max\n\n      - name: Build latest-plus-alpine\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/alpine.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            ghcr.io/sissbruecker/linkding:test-plus-alpine\n          target: linkding-plus\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max\n"
  },
  {
    "path": ".github/workflows/build.yaml",
    "content": "name: build\n\non: workflow_dispatch\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Read version from file\n        id: get_version\n        run: echo \"VERSION=$(cat version.txt)\" >> $GITHUB_ENV\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ vars.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ github.token }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build latest\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/default.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            sissbruecker/linkding:latest\n            sissbruecker/linkding:${{ env.VERSION }}\n            ghcr.io/sissbruecker/linkding:latest\n            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}\n          target: linkding\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max\n\n      - name: Build latest-alpine\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/alpine.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            sissbruecker/linkding:latest-alpine\n            sissbruecker/linkding:${{ env.VERSION }}-alpine\n            ghcr.io/sissbruecker/linkding:latest-alpine\n            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine\n          target: linkding\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max\n\n      - name: Build latest-plus\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/default.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            sissbruecker/linkding:latest-plus\n            sissbruecker/linkding:${{ env.VERSION }}-plus\n            ghcr.io/sissbruecker/linkding:latest-plus\n            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus\n          target: linkding-plus\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max\n\n      - name: Build latest-plus-alpine\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./docker/alpine.Dockerfile\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          tags: |\n            sissbruecker/linkding:latest-plus-alpine\n            sissbruecker/linkding:${{ env.VERSION }}-plus-alpine\n            ghcr.io/sissbruecker/linkding:latest-plus-alpine\n            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine\n          target: linkding-plus\n          push: true\n          cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine\n          cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max"
  },
  {
    "path": ".github/workflows/main.yaml",
    "content": "name: linkding CI\n\non:\n  pull_request:\n  push:\n    branches:\n      - master\n\njobs:\n  unit_tests:\n    name: Unit Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.13\"\n      - name: Install uv\n        uses: astral-sh/setup-uv@v6\n      - name: Set up Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n      - name: Install Node dependencies\n        run: npm ci\n      - name: Setup Python environment\n        run: |\n          uv sync\n          mkdir data\n      - name: Run tests\n        run: uv run pytest -n auto\n  e2e_tests:\n    name: E2E Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.13\"\n      - name: Install uv\n        uses: astral-sh/setup-uv@v6\n      - name: Set up Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n      - name: Install Node dependencies\n        run: npm ci\n      - name: Setup Python environment\n        run: |\n          uv sync\n          uv run playwright install chromium\n          mkdir data\n      - name: Run build\n        run: |\n          npm run build\n          uv run manage.py collectstatic\n      - name: Run tests\n        run: uv run pytest bookmarks/tests_e2e -n auto -o \"python_files=e2e_test_*.py\"\n      - name: Upload screenshots\n        if: failure()\n        uses: actions/upload-artifact@v4\n        with:\n          name: e2e-screenshots\n          path: test-results/screenshots\n"
  },
  {
    "path": ".gitignore",
    "content": "# Created by .ignore support plugin (hsz.mobi)\n### JetBrains template\n# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm\n# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839\n\n# File-based project format\n*.iws\n\n# IntelliJ\n.idea\n*.iml\nout/\n\n### Python template\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n.pytest_cache/\ntest-results/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# pyenv\n.python-version\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n\n### Node template\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\n# next.js build output\n.next\n\n### Custom\n# Rollup compilation output\n/bookmarks/static/bundle.js*\n# CSS compilation output\n/bookmarks/static/theme-*.css*\n# Collected static files for deployment\n/static\n# Build output, etc.\n/tmp\n# Database file\n/data\n# ublock + chromium\n/uBOLite.chromium.mv3\n/chromium-profile\n# direnv\n/.direnv\n\n# Test setups\n/scripts/unsecure-test-setups/authelia-oidc/authelia/db.sqlite3\n/scripts/unsecure-test-setups/authelia-oidc/traefik/certs\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## v1.45.0 (06/01/2026)\n\n### What's Changed\r\n* API token management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1248\r\n* Add option to disable login form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1269\r\n* Turn scheme-less URLs into HTTPS instead of HTTP links by @Maaxxs in https://github.com/sissbruecker/linkding/pull/1225\r\n* Disable bulk execute button when no bookmarks selected by @emanuelebeffa in https://github.com/sissbruecker/linkding/pull/1241\r\n* Add option to run supervisor as main process by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1270\r\n* Allow setting date_added and date_modified for new bookmarks through REST API by @jmason in https://github.com/sissbruecker/linkding/pull/1063\r\n* Download PDF instead of creating HTML snapshot if URL points at PDF by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1271\r\n* Allow sandboxed scripts when viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1252\r\n* Allow viewing video assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1259\r\n* Remove absolute URIs from settings page by @packrat386 in https://github.com/sissbruecker/linkding/pull/1261\r\n* Move tag management forms into dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1253\r\n* Move bulk edit checkboxes into bookmark list container by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1257\r\n* Remove registration switch by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1268\r\n* Add linkdinger to community projects by @lmmendes in https://github.com/sissbruecker/linkding/pull/1266\r\n\r\n### New Contributors\r\n* @packrat386 made their first contribution in https://github.com/sissbruecker/linkding/pull/1261\r\n* @lmmendes made their first contribution in https://github.com/sissbruecker/linkding/pull/1266\r\n* @Maaxxs made their first contribution in https://github.com/sissbruecker/linkding/pull/1225\r\n* @emanuelebeffa made their first contribution in https://github.com/sissbruecker/linkding/pull/1241\r\n* @jmason made their first contribution in https://github.com/sissbruecker/linkding/pull/1063\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.2...v1.45.0\n\n---\n\n## v1.44.2 (13/12/2025)\n\n### What's Changed\r\n\r\n> [!WARNING] \r\n> *This resolves a [security vulnerability](https://github.com/sissbruecker/linkding/security/advisories/GHSA-3pf9-5cjv-2w7q) in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*\r\n\r\n* Use sandbox CSP for viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1245\r\n* Fix devcontainer by @m3eno in https://github.com/sissbruecker/linkding/pull/1208\r\n* Fix tag cloud highlighting first char when tags are not grouped by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1209\r\n* Bump supervisor to 4.3.0 to fix warning by @simonhammes in https://github.com/sissbruecker/linkding/pull/1216\r\n* Added Javascript client and library for Linkding REST API by @vbsampath in https://github.com/sissbruecker/linkding/pull/1195\r\n* Add Komrade project to community resources by @dev-inside in https://github.com/sissbruecker/linkding/pull/1236\r\n\r\n### New Contributors\r\n* @m3eno made their first contribution in https://github.com/sissbruecker/linkding/pull/1208\r\n* @vbsampath made their first contribution in https://github.com/sissbruecker/linkding/pull/1195\r\n* @dev-inside made their first contribution in https://github.com/sissbruecker/linkding/pull/1236\r\n* @simonhammes made their first contribution in https://github.com/sissbruecker/linkding/pull/1216\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.1...v1.44.2\n\n---\n\n## v1.44.1 (11/10/2025)\n\n### What's Changed\r\n* Fix normalized URL not being generated in bookmark import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1202\r\n* Fix missing tags causing errors in import with Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1203\r\n* Check for dupes by exact URL if normalized URL is missing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1204\r\n* Attempt to fix botched normalized URL migration from 1.43.0 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1205\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.0...v1.44.1\n\n---\n\n## v1.44.0 (05/10/2025)\n\n### What's Changed\r\n* Add new search engine that supports logical expressions (and, or, not) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1198\r\n* Fix pagination links to use relative URLs by @dunlor in https://github.com/sissbruecker/linkding/pull/1186\r\n* Fix queued tasks link when context path is used by @dunlor in https://github.com/sissbruecker/linkding/pull/1187\r\n* Fix bundle preview pagination resetting to first page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1194\r\n\r\n### New Contributors\r\n* @dunlor made their first contribution in https://github.com/sissbruecker/linkding/pull/1186\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.43.0...v1.44.0\n\n---\n\n## v1.43.0 (28/09/2025)\n\n### What's Changed\r\n* Add basic tag management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1175\r\n* Normalize URLs when checking for duplicates by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1169\r\n* Add option to mark bookmarks as shared by default by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1170\r\n* Use modal dialog for confirming actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1168\r\n* Fix error when filtering bookmark assets in the admin UI by @proog in https://github.com/sissbruecker/linkding/pull/1162\r\n* Document API bundle filter by @proog in https://github.com/sissbruecker/linkding/pull/1161\r\n* Add alfred-linkding-bookmarks to community.md by @FireFingers21 in https://github.com/sissbruecker/linkding/pull/1160\r\n* Switch to uv by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1172\r\n* Replace Svelte components with Lit elements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1174\r\n* Bump versions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1173\r\n* Bump astro from 5.12.8 to 5.13.2 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1166\r\n* Bump vite from 6.3.5 to 6.3.6 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1184\r\n\r\n### New Contributors\r\n* @FireFingers21 made their first contribution in https://github.com/sissbruecker/linkding/pull/1160\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.42.0...v1.43.0\n\n---\n\n## v1.42.0 (16/08/2025)\n\n### What's Changed\r\n* Bulk create HTML snapshots by @Tql-ws1 in https://github.com/sissbruecker/linkding/pull/1132\r\n* Create bundle from current search query by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1154\r\n* Add alternative bookmarklet that uses browser metadata by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1159\r\n* Add date and time to HTML export filename by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1101\r\n* Automatically compress uploads with gzip by @hkclark in https://github.com/sissbruecker/linkding/pull/1087\r\n* Show bookmark bundles in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1110\r\n* Allow filtering feeds by bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1152\r\n* Submit bookmark form with Ctrl/Cmd + Enter by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1158\r\n* Improve bookmark form accessibility by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1116\r\n* Fix custom CSS not being used in reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1102\r\n* Use filename when downloading asset through UI by @proog in https://github.com/sissbruecker/linkding/pull/1146\r\n* Update order when deleting bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1114\r\n* Wrap long titles in bookmark details modal by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1150\r\n* Ignore tags with just whitespace by @pvl in https://github.com/sissbruecker/linkding/pull/1125\r\n* Ignore tags that exceed length limit during import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1153\r\n* Add CloudBreak on Managed Hosting by @benjaminoakes in https://github.com/sissbruecker/linkding/pull/1079\r\n* Add Pocket migration to to community page by @hkclark in https://github.com/sissbruecker/linkding/pull/1112\r\n* Add linkding-media-archiver to community.md by @proog in https://github.com/sissbruecker/linkding/pull/1144\r\n* Bump astro from 5.7.13 to 5.12.8 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1147\r\n\r\n### New Contributors\r\n* @hkclark made their first contribution in https://github.com/sissbruecker/linkding/pull/1087\r\n* @benjaminoakes made their first contribution in https://github.com/sissbruecker/linkding/pull/1079\r\n* @proog made their first contribution in https://github.com/sissbruecker/linkding/pull/1146\r\n* @pvl made their first contribution in https://github.com/sissbruecker/linkding/pull/1125\r\n* @Tql-ws1 made their first contribution in https://github.com/sissbruecker/linkding/pull/1132\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.41.0...v1.42.0\n\n---\n\n## v1.41.0 (19/06/2025)\n\n### What's Changed\r\n* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097\r\n* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100\r\n* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080\r\n* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089\r\n* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098\r\n* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088\r\n* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084\r\n* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086\r\n* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090\r\n* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096\r\n\r\n### New Contributors\r\n* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0\n\n---\n\n## v1.40.0 (17/05/2025)\n\n### What's Changed\r\n* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999\r\n* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021\r\n* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024\r\n* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045\r\n* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051\r\n* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058\r\n* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060\r\n* Handle lowercase \"true\" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020\r\n* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014\r\n* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015\r\n* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019\r\n* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033\r\n* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009\r\n* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052\r\n* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055\r\n* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025\r\n* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007\r\n* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030\r\n* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028\r\n* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034\r\n* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035\r\n* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036\r\n* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037\r\n* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059\r\n\r\n### New Contributors\r\n* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019\r\n* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020\r\n* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999\r\n* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025\r\n* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024\r\n* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052\r\n* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051\r\n* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058\r\n* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0\n\n---\n\n## v1.39.1 (06/03/2025)\n\n> [!WARNING]\r\n> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.\n\n---\n\n## v1.39.0 (06/03/2025)\n\n### What's Changed\r\n* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996\r\n* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003\r\n* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995\r\n* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001\r\n* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002\r\n\r\n### New Contributors\r\n* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001\r\n* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0\n\n---\n\n## v1.38.1 (22/02/2025)\n\n### What's Changed\r\n* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989\r\n* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990\r\n* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992\r\n* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993\r\n* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994\r\n\r\n### New Contributors\r\n* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1\n\n---\n\n## v1.38.0 (09/02/2025)\n\n### What's Changed\r\n* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965\r\n* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971\r\n* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974\r\n* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975\r\n* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977\r\n* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984\r\n* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968\r\n\r\n### New Contributors\r\n* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0\n\n---\n\n## v1.37.0 (26/01/2025)\n\n### What's Changed\r\n* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887\r\n* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959\r\n* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944\r\n* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945\r\n* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880\r\n* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892\r\n* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949\r\n* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914\r\n* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897\r\n* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884\r\n* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953\r\n* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947\r\n* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928\r\n* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929\r\n* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962\r\n\r\n### New Contributors\r\n* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880\r\n* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887\r\n* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892\r\n* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949\r\n* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0\n\n---\n\n## v1.36.0 (02/10/2024)\n\n### What's Changed\r\n* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866\r\n* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860\r\n* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849\r\n* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850\r\n* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852\r\n* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853\r\n* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854\r\n* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858\r\n* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863\r\n* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865\r\n* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855\r\n* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851\r\n* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856\r\n\r\n### New Contributors\r\n* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850\r\n* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0\n\n---\n\n## v1.35.0 (23/09/2024)\n\n### What's Changed\r\n* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835\r\n* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842\r\n* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843\r\n* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846\r\n* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847\r\n* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833\r\n* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836\r\n* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844\r\n* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837\r\n* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839\r\n* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840\r\n* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841\r\n\r\n### New Contributors\r\n* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836\r\n* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839\r\n* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0\n\n---\n\n## v1.34.0 (16/09/2024)\n\n### What's Changed\r\n* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825\r\n* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829\r\n* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826\r\n\r\n### New Contributors\r\n* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0\n\n---\n\n## v1.33.0 (14/09/2024)\n\n### What's Changed\r\n* Theme improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/822\r\n* Speed up navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/824\r\n* Rename \"SingeFileError\" to \"SingleFileError\" by @curiousleo in https://github.com/sissbruecker/linkding/pull/823\r\n* Bump svelte from 4.2.12 to 4.2.19 by @dependabot in https://github.com/sissbruecker/linkding/pull/806\r\n\r\n### New Contributors\r\n* @curiousleo made their first contribution in https://github.com/sissbruecker/linkding/pull/823\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.32.0...v1.33.0\n\n---\n\n## v1.32.0 (10/09/2024)\n\n### What's Changed\r\n* Allow configuring landing page for unauthenticated users by @sissbruecker in https://github.com/sissbruecker/linkding/pull/808\r\n* Allow configuring guest user profile by @sissbruecker in https://github.com/sissbruecker/linkding/pull/809\r\n* Return bookmark tags in RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/810\r\n* Additional filter parameters for RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/811\r\n* Allow pre-filling notes in new bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/812\r\n* Fix inconsistent tag order in bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/819\r\n* Fix auto-tagging when URL includes port by @sissbruecker in https://github.com/sissbruecker/linkding/pull/820\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.1...v1.32.0\n\n---\n\n## v1.31.1 (30/08/2024)\n\n### What's Changed\r\n* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763\r\n* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772\r\n* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800\r\n* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788\r\n* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765\r\n* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701\r\n* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804\r\n* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805\r\n* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795\r\n* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775\r\n* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769\r\n* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762\r\n\r\n### New Contributors\r\n* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772\r\n* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800\r\n* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788\r\n* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765\r\n* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1\n\n---\n\n## v1.31.0 (16/06/2024)\n\n### What's Changed\r\n* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721\r\n* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736\r\n* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724\r\n* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725\r\n* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734\r\n* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735\r\n* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737\r\n* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733\r\n* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740\r\n\r\n### New Contributors\r\n* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0\n\n---\n\n## v1.30.0 (20/04/2024)\n\n### What's Changed\r\n* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703\r\n* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713\r\n* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706\r\n* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699\r\n* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702\r\n* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708\r\n* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704\r\n\r\n### New Contributors\r\n* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0\n\n---\n\n## v1.29.0 (14/04/2024)\n\n### What's Changed\r\n* Remove ads and cookie banners from HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/695\r\n* Add button for creating missing HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/696\r\n* Refresh file list when there are queued snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/697\r\n* Bump idna from 3.6 to 3.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/694\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.28.0...v1.29.0\n\n---\n\n## v1.28.0 (09/04/2024)\n\n### What's Changed\r\n* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684\r\n* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686\r\n* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687\r\n* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688\r\n* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691\r\n* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689\r\n\r\n### New Contributors\r\n* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684\r\n* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0\n\n---\n\n## v1.27.1 (07/04/2024)\n\n### What's Changed\r\n* Fix HTML snapshot errors related to single-file-cli by @sissbruecker in https://github.com/sissbruecker/linkding/pull/683\r\n* Replace django-background-tasks with huey by @sissbruecker in https://github.com/sissbruecker/linkding/pull/657\r\n* Add Authelia OIDC example to docs by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/675\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.0...v1.27.1\n\n---\n\n## v1.27.0 (01/04/2024)\n\n### What's Changed\r\n* Archive snapshots of websites locally by @sissbruecker in https://github.com/sissbruecker/linkding/pull/672\r\n* Add Railway hosting option by @tianheg in https://github.com/sissbruecker/linkding/pull/661\r\n* Add how to for increasing the font size by @sissbruecker in https://github.com/sissbruecker/linkding/pull/667\r\n\r\n### New Contributors\r\n* @tianheg made their first contribution in https://github.com/sissbruecker/linkding/pull/661\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.26.0...v1.27.0\n\n---\n\n## v1.26.0 (30/03/2024)\n\n### What's Changed\r\n* Add option for showing bookmark description as separate block by @sissbruecker in https://github.com/sissbruecker/linkding/pull/663\r\n* Add bookmark details view by @sissbruecker in https://github.com/sissbruecker/linkding/pull/665\r\n* Make bookmark list actions configurable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/666\r\n* Bump black from 24.1.1 to 24.3.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/662\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.25.0...v1.26.0\n\n---\n\n## v1.25.0 (18/03/2024)\n\n### What's Changed\r\n* Improve PWA capabilities by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/630\r\n* build improvements by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/649\r\n* Add support for oidc by @Nighmared in https://github.com/sissbruecker/linkding/pull/389\r\n* Add option for custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/652\r\n* Update backup location to safe directory by @bphenriques in https://github.com/sissbruecker/linkding/pull/653\r\n* Include web archive link in /api/bookmarks/ by @sissbruecker in https://github.com/sissbruecker/linkding/pull/655\r\n* Add RSS feeds for shared bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/656\r\n* Bump django from 5.0.2 to 5.0.3 by @dependabot in https://github.com/sissbruecker/linkding/pull/658\r\n\r\n### New Contributors\r\n* @hugo-vrijswijk made their first contribution in https://github.com/sissbruecker/linkding/pull/630\r\n* @Nighmared made their first contribution in https://github.com/sissbruecker/linkding/pull/389\r\n* @bphenriques made their first contribution in https://github.com/sissbruecker/linkding/pull/653\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.2...v1.25.0\n\n---\n\n## v1.24.2 (16/03/2024)\n\n### What's Changed\r\n* Fix logout button by @sissbruecker in https://github.com/sissbruecker/linkding/pull/648\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.1...v1.24.2\n\n---\n\n## v1.24.1 (16/03/2024)\n\n### What's Changed\r\n* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/618\r\n* Persist secret key in data folder by @sissbruecker in https://github.com/sissbruecker/linkding/pull/620\r\n* Group ideographic characters in tag cloud by @jonathan-s in https://github.com/sissbruecker/linkding/pull/613\r\n* Bump django from 5.0.1 to 5.0.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/625\r\n* Add k8s setup to community section by @jzck in https://github.com/sissbruecker/linkding/pull/633\r\n* Added a new Linkding client to community section by @JGeek00 in https://github.com/sissbruecker/linkding/pull/638\r\n\r\n### New Contributors\r\n* @jzck made their first contribution in https://github.com/sissbruecker/linkding/pull/633\r\n* @JGeek00 made their first contribution in https://github.com/sissbruecker/linkding/pull/638\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.0...v1.24.1\n\n---\n\n## v1.24.0 (27/01/2024)\n\n### What's Changed\r\n* Support Open Graph description by @jonathan-s in https://github.com/sissbruecker/linkding/pull/602\r\n* Add tooltip to truncated bookmark titles by @jonathan-s in https://github.com/sissbruecker/linkding/pull/607\r\n* Improve bulk tag performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/612\r\n* Increase tag limit in tag autocomplete by @hypebeast in https://github.com/sissbruecker/linkding/pull/581\r\n* Add CapRover as managed hosting option by @adamshand in https://github.com/sissbruecker/linkding/pull/585\r\n* Bump playwright dependencies by @jonathan-s in https://github.com/sissbruecker/linkding/pull/601\r\n* Adjust archive.org donation link in general.html by @JnsDornbusch in https://github.com/sissbruecker/linkding/pull/603\r\n\r\n### New Contributors\r\n* @hypebeast made their first contribution in https://github.com/sissbruecker/linkding/pull/581\r\n* @adamshand made their first contribution in https://github.com/sissbruecker/linkding/pull/585\r\n* @jonathan-s made their first contribution in https://github.com/sissbruecker/linkding/pull/601\r\n* @JnsDornbusch made their first contribution in https://github.com/sissbruecker/linkding/pull/603\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.1...v1.24.0\n\n---\n\n## v1.23.1 (08/12/2023)\n\n### What's Changed\r\n* Properly encode search query param by @sissbruecker in https://github.com/sissbruecker/linkding/pull/587\r\n\r\n> [!WARNING] \r\n> *This resolves a security vulnerability in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.0...v1.23.1\n\n---\n\n## v1.23.0 (24/11/2023)\n\n### What's Changed\r\n* Add Alpine based Docker image (experimental) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/570\r\n* Add backup CLI command by @sissbruecker in https://github.com/sissbruecker/linkding/pull/571\r\n* Update browser extension links by @OPerepadia in https://github.com/sissbruecker/linkding/pull/574\r\n* Include archived bookmarks in export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/579\r\n\r\n### New Contributors\r\n* @OPerepadia made their first contribution in https://github.com/sissbruecker/linkding/pull/574\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.3...v1.23.0\n\n---\n\n## v1.22.3 (04/11/2023)\n\n### What's Changed\r\n* Fix RSS feed not handling None values  by @vitormarcal in https://github.com/sissbruecker/linkding/pull/569\r\n* Bump django from 4.1.10 to 4.1.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/567\r\n\r\n### New Contributors\r\n* @vitormarcal made their first contribution in https://github.com/sissbruecker/linkding/pull/569\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.2...v1.22.3\n\n---\n\n## v1.22.2 (27/10/2023)\n\n### What's Changed\r\n* Fix search options not opening on iOS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/549\r\n* Bump urllib3 from 1.26.11 to 1.26.17 by @dependabot in https://github.com/sissbruecker/linkding/pull/542\r\n* Add iOS shortcut to community section by @andrewdolphin in https://github.com/sissbruecker/linkding/pull/550\r\n* Disable editing of search preferences in user admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/555\r\n* Add feed2linkding to community section by @Strubbl in https://github.com/sissbruecker/linkding/pull/544\r\n* Sanitize RSS feed to remove control characters by @sissbruecker in https://github.com/sissbruecker/linkding/pull/565\r\n* Bump urllib3 from 1.26.17 to 1.26.18 by @dependabot in https://github.com/sissbruecker/linkding/pull/560\r\n\r\n### New Contributors\r\n* @andrewdolphin made their first contribution in https://github.com/sissbruecker/linkding/pull/550\r\n* @Strubbl made their first contribution in https://github.com/sissbruecker/linkding/pull/544\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.1...v1.22.2\n\n---\n\n## v1.22.1 (06/10/2023)\n\n### What's Changed\r\n* Fix memory leak with SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/548\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.0...v1.22.1\n\n---\n\n## v1.22.0 (01/10/2023)\n\n### What's Changed\r\n* Fix case-insensitive search for unicode characters in SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/520\r\n* Add sort option to bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/522\r\n* Add button to show tags on smaller screens by @sissbruecker in https://github.com/sissbruecker/linkding/pull/529\r\n* Make code blocks in notes scrollable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/530\r\n* Add filter for shared state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/531\r\n* Add support for exporting/importing bookmark notes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/532\r\n* Add filter for unread state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/535\r\n* Allow saving search preferences by @sissbruecker in https://github.com/sissbruecker/linkding/pull/540\r\n* Add user profile endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/541\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.22.0\n\n---\n\n## v1.21.1 (26/09/2023)\n\n### What's Changed\r\n* Fix bulk edit to respect searched tags by @sissbruecker in https://github.com/sissbruecker/linkding/pull/537\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.21.1\n\n---\n\n## v1.21.0 (25/08/2023)\n\n### What's Changed\r\n* Make search autocomplete respect link target setting by @sissbruecker in https://github.com/sissbruecker/linkding/pull/513\r\n* Various CSS improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/514\r\n* Display shared state in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/515\r\n* Allow bulk editing unread and shared state of bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/517\r\n* Bump uwsgi from 2.0.20 to 2.0.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/516\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.1...v1.21.0\n\n---\n\n## v1.20.1 (23/08/2023)\n\n### What's Changed\r\n* Update cached styles and scripts after version change by @sissbruecker in https://github.com/sissbruecker/linkding/pull/510\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.0...v1.20.1\n\n---\n\n## v1.20.0 (22/08/2023)\n\n### What's Changed\r\n* Add option to share bookmarks publicly by @sissbruecker in https://github.com/sissbruecker/linkding/pull/503\r\n* Various improvements to favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/504\r\n* Add support for PRIVATE flag in import and export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/505\r\n* Avoid page reload when triggering actions in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/506\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.1...v1.20.0\n\n---\n\n## v1.19.1 (29/07/2023)\n\n### What's Changed\r\n* Add Postman Collection to Community section of README by @gingerbeardman in https://github.com/sissbruecker/linkding/pull/476\r\n* Added Dev Container support by @acbgbca in https://github.com/sissbruecker/linkding/pull/474\r\n* Added Apple web-app meta tag #358 by @acbgbca in https://github.com/sissbruecker/linkding/pull/359\r\n* Bump requests from 2.28.1 to 2.31.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/478\r\n* Allow passing title and description to new bookmark form by @acbgbca in https://github.com/sissbruecker/linkding/pull/479\r\n* Enable WAL to avoid locked database lock errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/480\r\n* Fix website loader content encoding detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/482\r\n* Bump certifi from 2022.12.7 to 2023.7.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/497\r\n* Bump django from 4.1.9 to 4.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/494\r\n\r\n### New Contributors\r\n* @gingerbeardman made their first contribution in https://github.com/sissbruecker/linkding/pull/476\r\n* @acbgbca made their first contribution in https://github.com/sissbruecker/linkding/pull/474\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.0...v1.19.1\n\n---\n\n## v1.19.0 (20/05/2023)\n\n### What's Changed\r\n* Add notes to bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/472\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.18.0...v1.19.0\n\n---\n\n## v1.18.0 (18/05/2023)\n\n### What's Changed\r\n* Make search case-insensitive on Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/432\r\n* Allow searching for tags without hash character by @sissbruecker in https://github.com/sissbruecker/linkding/pull/449\r\n* Prevent zoom-in after focusing an input on small viewports on iOS devices by @puresick in https://github.com/sissbruecker/linkding/pull/440\r\n* Add database options by @plockaby in https://github.com/sissbruecker/linkding/pull/406\r\n* Allow to log real client ip in logs when using a reverse proxy by @fmenabe in https://github.com/sissbruecker/linkding/pull/398\r\n* Add option to display URL below title by @bah0 in https://github.com/sissbruecker/linkding/pull/365\r\n* Add LinkThing iOS app to community section by @amoscardino in https://github.com/sissbruecker/linkding/pull/446\r\n* Bump django from 4.1.7 to 4.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/466\r\n* Bump sqlparse from 0.4.2 to 0.4.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/455\r\n\r\n### New Contributors\r\n* @amoscardino made their first contribution in https://github.com/sissbruecker/linkding/pull/446\r\n* @puresick made their first contribution in https://github.com/sissbruecker/linkding/pull/440\r\n* @plockaby made their first contribution in https://github.com/sissbruecker/linkding/pull/406\r\n* @fmenabe made their first contribution in https://github.com/sissbruecker/linkding/pull/398\r\n* @bah0 made their first contribution in https://github.com/sissbruecker/linkding/pull/365\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.2...v1.18.0\n\n---\n\n## v1.17.2 (18/02/2023)\n\n### What's Changed\r\n* Escape texts in exported HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/429\r\n* Bump django from 4.1.2 to 4.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/427\r\n* Make health check in Dockerfile honor context path setting by @mrex in https://github.com/sissbruecker/linkding/pull/407\r\n* Disable autocapitalization for tag input form by @joshdick in https://github.com/sissbruecker/linkding/pull/395\r\n\r\n### New Contributors\r\n* @mrex made their first contribution in https://github.com/sissbruecker/linkding/pull/407\r\n* @joshdick made their first contribution in https://github.com/sissbruecker/linkding/pull/395\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.1...v1.17.2\n\n---\n\n## v1.17.1 (22/01/2023)\n\n### What's Changed\r\n* Fix favicon being cleared by web archive snapshot task by @sissbruecker in https://github.com/sissbruecker/linkding/pull/405\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.0...v1.17.1\n\n---\n\n## v1.17.0 (21/01/2023)\n\n### What's Changed\r\n* Add Health Check endpoint  by @mckennajones in https://github.com/sissbruecker/linkding/pull/392\r\n* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401\r\n* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402\r\n* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0\n\n---\n\n## v1.16.1 (20/01/2023)\n\n### What's Changed\r\n* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400\r\n* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391\r\n* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374\r\n* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1\n\n---\n\n## v1.16.0 (12/01/2023)\n\n### What's Changed\r\n* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388\r\n* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368\r\n* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354\r\n* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360\r\n* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383\r\n* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384\r\n* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371\r\n* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379\r\n* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387\r\n\r\n### New Contributors\r\n* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371\r\n* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384\r\n* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368\r\n* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388\r\n* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0\n\n---\n\n## v1.15.1 (05/10/2022)\n\n### What's Changed\r\n* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350\r\n* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1\n\n---\n\n## v1.15.0 (11/09/2022)\n\n### What's Changed\r\n* Bump Django and other dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/331\r\n* Add option to create initial superuser by @sissbruecker in https://github.com/sissbruecker/linkding/pull/323\r\n* Improved Android HTTP Shortcuts doc by @kzshantonu in https://github.com/sissbruecker/linkding/pull/330\r\n* Minify bookmark list HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/332\r\n* Bump python version to 3.10 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/333\r\n* Fix error when deleting all bookmarks in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/336\r\n* Improve bookmark query performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/334\r\n* Prevent rate limit errors in wayback machine API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/339\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.14.0...v1.15.0\n\n---\n\n## v1.14.0 (14/08/2022)\n\n### What's Changed\r\n* Add support for context path by @s2marine in https://github.com/sissbruecker/linkding/pull/313\r\n* Add support for authentication proxies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/321\r\n* Add bookmark list keyboard navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/320\r\n* Skip updating website metadata on edit unless URL has changed by @sissbruecker in https://github.com/sissbruecker/linkding/pull/318\r\n* Add simple docs of the new `shared` API parameter by @bachya in https://github.com/sissbruecker/linkding/pull/312\r\n* Add project linka to community section in README by @cmsax in https://github.com/sissbruecker/linkding/pull/319\r\n* Order tags in test_should_create_new_bookmark by @RoGryza in https://github.com/sissbruecker/linkding/pull/310\r\n* Bump django from 3.2.14 to 3.2.15 by @dependabot in https://github.com/sissbruecker/linkding/pull/316\r\n\r\n### New Contributors\r\n* @s2marine made their first contribution in https://github.com/sissbruecker/linkding/pull/313\r\n* @RoGryza made their first contribution in https://github.com/sissbruecker/linkding/pull/310\r\n* @cmsax made their first contribution in https://github.com/sissbruecker/linkding/pull/319\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.13.0...v1.14.0\n\n---\n\n## v1.13.0 (04/08/2022)\n\n### What's Changed\r\n* Add bookmark sharing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/311\r\n* Display selected tags in tag cloud by @sissbruecker and @jhauris in https://github.com/sissbruecker/linkding/pull/307\r\n* Update unread flag when saving duplicate URL by @sissbruecker in https://github.com/sissbruecker/linkding/pull/306\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.12.0...v1.13.0\n\n---\n\n## v1.12.0 (23/07/2022)\n\n### What's Changed\r\n* Add read it later functionality by @sissbruecker in https://github.com/sissbruecker/linkding/pull/304\r\n* Add RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/305\r\n* Add bookmarklet to community by @ukcuddlyguy in https://github.com/sissbruecker/linkding/pull/293\r\n* Shorten and simplify example bookmarklet in documentation by @FunctionDJ in https://github.com/sissbruecker/linkding/pull/297\r\n* Fix typo by @kianmeng in https://github.com/sissbruecker/linkding/pull/295\r\n* Bump django from 3.2.13 to 3.2.14 by @dependabot in https://github.com/sissbruecker/linkding/pull/294\r\n* Bump svelte from 3.46.4 to 3.49.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/299\r\n* Bump terser from 5.5.1 to 5.14.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/302\r\n\r\n### New Contributors\r\n* @ukcuddlyguy made their first contribution in https://github.com/sissbruecker/linkding/pull/293\r\n* @FunctionDJ made their first contribution in https://github.com/sissbruecker/linkding/pull/297\r\n* @kianmeng made their first contribution in https://github.com/sissbruecker/linkding/pull/295\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.1...v1.12.0\n\n---\n\n## v1.11.1 (03/07/2022)\n\n### What's Changed\r\n* Fix duplicate tags on import by @wahlm in https://github.com/sissbruecker/linkding/pull/289\r\n* Add apple-touch-icon by @daveonkels in https://github.com/sissbruecker/linkding/pull/282\r\n* Bump waybackpy to 3.0.6 by @dustinblackman in https://github.com/sissbruecker/linkding/pull/281\r\n\r\n### New Contributors\r\n* @wahlm made their first contribution in https://github.com/sissbruecker/linkding/pull/289\r\n* @daveonkels made their first contribution in https://github.com/sissbruecker/linkding/pull/282\r\n* @dustinblackman made their first contribution in https://github.com/sissbruecker/linkding/pull/281\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.0...v1.11.1\n\n---\n\n## v1.11.0 (26/05/2022)\n\n### What's Changed\r\n* Add background tasks to admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/264\r\n* Improve about section by @sissbruecker in https://github.com/sissbruecker/linkding/pull/265\r\n* Allow creating archived bookmark through REST API by @kencx in https://github.com/sissbruecker/linkding/pull/268\r\n* Add PATCH support to bookmarks endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/269\r\n* Add community reference to linkding-cli by @bachya in https://github.com/sissbruecker/linkding/pull/270\r\n\r\n### New Contributors\r\n* @kencx made their first contribution in https://github.com/sissbruecker/linkding/pull/268\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.1...v1.11.0\n\n---\n\n## v1.10.1 (21/05/2022)\n\n### What's Changed\r\n* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263\r\n\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1\n\n---\n\n## v1.10.0 (21/05/2022)\n\n### What's Changed\r\n* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253\r\n* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259\r\n* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261\r\n* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260\r\n* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226\r\n\r\n### New Contributors\r\n* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253\r\n* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259\r\n* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0\n\n---\n\n## v1.9.0 (14/05/2022)\n\n### What's Changed\r\n* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248\r\n* Add whitespace after auto-completed tag by @sissbruecker in https://github.com/sissbruecker/linkding/pull/249\r\n* Bump django from 3.2.12 to 3.2.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/244\r\n* Add community helm chart reference to readme by @pascaliske in https://github.com/sissbruecker/linkding/pull/242\r\n* Feature: Shortcut key for new bookmark by @rithask in https://github.com/sissbruecker/linkding/pull/241\r\n* Clarify archive.org feature by @clach04 in https://github.com/sissbruecker/linkding/pull/229\r\n* Make Internet Archive integration opt-in by @sissbruecker in https://github.com/sissbruecker/linkding/pull/250\r\n\r\n### New Contributors\r\n* @pascaliske made their first contribution in https://github.com/sissbruecker/linkding/pull/242\r\n* @rithask made their first contribution in https://github.com/sissbruecker/linkding/pull/241\r\n* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229\r\n\r\n**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0\n\n---\n\n## v1.8.8 (27/03/2022)\n\n- [**bug**] Prevent bookmark actions through get requests\r\n- [**bug**] Prevent external redirects\n\n---\n\n## v1.8.7 (26/03/2022)\n\n- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)\r\n- [**enhancement**]  Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)\r\n- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)\n\n---\n\n## v1.8.6 (25/03/2022)\n\n- [bug] fix bookmark access restrictions\r\n- [bug] prevent external redirects\r\n- [chore] bump dependencies\n\n---\n\n## v1.8.5 (12/12/2021)\n\n- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)\r\n- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)\r\n- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)\n\n---\n\n## v1.8.4 (16/10/2021)\n\n- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)\n\n---\n\n## v1.8.3 (03/10/2021)\n\n- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)\n\n---\n\n## v1.8.2 (02/10/2021)\n\n- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)\n\n---\n\n## v1.8.1 (01/10/2021)\n\n- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)\r\n  - allows to press `s` to focus the search input\n\n---\n\n## v1.8.0 (04/09/2021)\n\n- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)\r\n  - Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)\r\n  - This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option\n\n---\n\n## v1.7.2 (26/08/2021)\n\n- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)\n\n---\n\n## v1.7.1 (25/08/2021)\n\n- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)\r\n- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)\r\n- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)\n\n---\n\n## v1.7.0 (17/08/2021)\n\n- Upgrade to Django 3\r\n- Bump other dependencies\n\n---\n\n## v1.6.5 (15/08/2021)\n\n- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)\n\n---\n\n## v1.6.4 (13/05/2021)\n\n- Update dependencies for security fixes\n\n---\n\n## v1.6.3 (06/04/2021)\n\n- [**bug**] relative names use the wrong \"today\" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)\n\n---\n\n## v1.6.2 (04/04/2021)\n\n- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)\n- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)\n- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)\n- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)\n\n---\n\n## v1.6.1 (31/03/2021)\n\n- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)\n\n---\n\n## v1.6.0 (28/03/2021)\n\n- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)\n\n---\n\n## v1.5.0 (28/03/2021)\n\n- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)\n\n---\n\n## v1.4.1 (20/03/2021)\n\n- Security patches\r\n- Documentation improvements\n\n---\n\n## v1.4.0 (24/02/2021)\n\n- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)\n\n---\n\n## v1.3.3 (18/02/2021)\n\n- [**closed**] Missing \"description\" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)\n\n---\n\n## v1.3.2 (18/02/2021)\n\n- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)\n- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)\n\n---\n\n## v1.3.1 (15/02/2021)\n\n[enhancement] Enhance delete links with inline confirmation\n\n---\n\n## v1.3.0 (14/02/2021)\n\n- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)\n- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)\n- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)\n- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)\n- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)\n- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)\n- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)\n\n---\n\n## v1.2.1 (12/01/2021)\n\n- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)\n- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)\n\n---\n\n## v1.2.0 (09/01/2021)\n\n- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)\n- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)\n\n---\n\n## v1.1.1 (01/01/2021)\n\n- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)\n\n---\n\n## v1.1.0 (31/12/2020)\n\n- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)\r\n- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)\n\n---\n\n## v1.0.0 (31/12/2020)\n\n- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)\n- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)\n- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)\n- [**enhancement**] API for app development [#24](https://github.com/sissbruecker/linkding/issues/24)\n- [**enhancement**] Enhancement: detect duplicates at entry time [#23](https://github.com/sissbruecker/linkding/issues/23)\n- [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18)\n- [**enhancement**] Enhancement: better administration page [#4](https://github.com/sissbruecker/linkding/issues/4)\n- [**enhancement**] Bug: Navigation bar active link stays on add bookmark [#3](https://github.com/sissbruecker/linkding/issues/3)\n- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)"
  },
  {
    "path": "LICENSE.txt",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2019 Sascha Ißbrücker\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "Makefile",
    "content": ".PHONY: serve\n\ninit:\n\tuv sync\n\t[ -d data ] || mkdir data data/assets data/favicons data/previews\n\tuv run manage.py migrate\n\tnpm install\n\nserve:\n\tuv run manage.py runserver\n\ntasks:\n\tuv run manage.py run_huey\n\ntest:\n\tuv run pytest -n auto\n\nlint:\n\tuv run ruff check bookmarks\n\nformat:\n\tuv run ruff format bookmarks\n\tuv run djlint bookmarks/templates --reformat --quiet --warn\n\tnpx prettier bookmarks/frontend --write\n\tnpx prettier bookmarks/styles --write\n\nprepare-e2e:\n\tuv run playwright install chromium\n\trm -rf static\n\tnpm run build\n\tuv run manage.py collectstatic --no-input\n\ne2e:\n\tmake prepare-e2e\n\tuv run pytest bookmarks/tests_e2e -n auto -o \"python_files=e2e_test_*.py\"\n\nfrontend:\n\tnpm run dev\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n    <br>\n    <a href=\"https://github.com/sissbruecker/linkding\">\n        <img src=\"assets/header.svg\" height=\"50\">\n    </a>\n    <br>\n</div>\n\n##  Introduction\n\nlinkding is a bookmark manager that you can host yourself.\nIt's designed be to be minimal, fast, and easy to set up using Docker.\n\nThe name comes from:\n- *link* which is often used as a synonym for URLs and bookmarks in common language\n- *Ding* which is German for thing\n- ...so basically something for managing your links\n\n**Feature Overview:**\n- Clean UI optimized for readability\n- Organize bookmarks with tags\n- Bulk editing, Markdown notes, read it later functionality\n- Share bookmarks with other users or guests\n- Automatically provides titles, descriptions and icons of bookmarked websites\n- Automatically archive websites, either as local HTML file or on Internet Archive\n- Import and export bookmarks in Netscape HTML format\n- Installable as a Progressive Web App (PWA)\n- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet\n- SSO support via OIDC or authentication proxies\n- REST API for developing 3rd party apps\n- Admin panel for user self-service and raw data access\n\n\n**Demo:** https://demo.linkding.link/\n\n**Screenshot:**\n\n![Screenshot](/docs/public/linkding-screenshot.png?raw=true \"Screenshot\")\n\n## Getting Started\n\nThe following links help you to get started with linkding:\n- [Install linkding on your own server](https://linkding.link/installation) or [check managed hosting options](https://linkding.link/managed-hosting)\n- [Install the browser extension](https://linkding.link/browser-extension)\n- [Check out community projects](https://linkding.link/community), which include mobile apps, browser extensions, libraries and more\n\n## Documentation\n\nThe full documentation is now available at [linkding.link](https://linkding.link/).\n\nIf you want to contribute to the documentation, you can find the source files in the `docs` folder.\n\nIf you want to contribute a community project, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md).\n\n## Contributing\n\nSmall improvements, bugfixes and documentation improvements are always welcome. If you want to contribute a larger feature, consider opening an issue first to discuss it. I may choose to ignore PRs for features that don't align with the project's goals or that I don't want to maintain.\n\n## Development\n\nThe application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.\n\n### Prerequisites\n- Python 3.13\n- [uv](https://docs.astral.sh/uv/getting-started/installation/)\n- Node.js\n\n### Setup\n\nInitialize the development environment with:\n```\nmake init\n```\nThis sets up a virtual environment using uv, installs NPM dependencies and runs migrations to create the initial database.\n\nCreate a user for the frontend:\n```\nuv run manage.py createsuperuser --username=joe --email=joe@example.com\n```\n\nRun the frontend build for bundling frontend components with:\n```\nmake frontend\n```\n\nThen start the Django development server with:\n```\nmake serve\n```\nThe frontend is now available under http://localhost:8000\n\n### Tests\n\nRun all tests with pytest:\n```\nmake test\n```\n\n\n### Linting\n\nRun linting with ruff:\n```\nmake lint\n```\n\n### Formatting\n\nFormat Python code with ruff, Django templates with djlint, and JavaScript code with prettier:\n```\nmake format\n```\n\n### DevContainers\n\nThis repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)\n\nOnce checked out, only the following commands are required to get started:\n\nCreate a user for the frontend:\n```\nuv run manage.py createsuperuser --username=joe --email=joe@example.com\n```\nStart the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:\n```\nmake frontend\n```\nStart the Django development server with:\n```\nmake serve\n```\nThe frontend is now available under http://localhost:8000\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 1.10.x   | :white_check_mark: |\n\n## Reporting a Vulnerability\n\nTo report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com\n\nI'll try to get back to you as soon as possible.\n"
  },
  {
    "path": "bookmarks/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/admin.py",
    "content": "import os\n\nfrom django import forms\nfrom django.contrib import admin, messages\nfrom django.contrib.admin import AdminSite\nfrom django.contrib.auth.admin import UserAdmin\nfrom django.contrib.auth.models import User\nfrom django.core.paginator import Paginator\nfrom django.db.models import Count, QuerySet\nfrom django.shortcuts import render\nfrom django.urls import path\nfrom django.utils.translation import gettext, ngettext\nfrom huey.contrib.djhuey import HUEY as huey\n\nfrom bookmarks.models import (\n    ApiToken,\n    Bookmark,\n    BookmarkAsset,\n    BookmarkBundle,\n    FeedToken,\n    Tag,\n    Toast,\n    UserProfile,\n)\nfrom bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark\n\n\n# Custom paginator to paginate through Huey tasks\nclass TaskPaginator(Paginator):\n    def __init__(self):\n        super().__init__(self, 100)\n        self.task_count = huey.storage.queue_size()\n\n    @property\n    def count(self):\n        return self.task_count\n\n    def page(self, number):\n        limit = self.per_page\n        offset = (number - 1) * self.per_page\n        return self._get_page(\n            self.enqueued_items(limit, offset),\n            number,\n            self,\n        )\n\n    # Copied from Huey's SqliteStorage with some modifications to allow pagination\n    def enqueued_items(self, limit, offset):\n        def to_bytes(b):\n            return bytes(b) if not isinstance(b, bytes) else b\n\n        sql = \"select data from task where queue=? order by priority desc, id limit ? offset ?\"\n        params = (huey.storage.name, limit, offset)\n\n        serialized_tasks = [\n            to_bytes(i) for (i,) in huey.storage.sql(sql, params, results=True)\n        ]\n        return [huey.deserialize_task(task) for task in serialized_tasks]\n\n\n# Custom view to display Huey tasks in the admin\ndef background_task_view(request):\n    page_number = int(request.GET.get(\"p\", 1))\n    paginator = TaskPaginator()\n    page = paginator.get_page(page_number)\n    page_range = paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=2)\n    context = {\n        **linkding_admin_site.each_context(request),\n        \"title\": \"Background tasks\",\n        \"page\": page,\n        \"page_range\": page_range,\n        \"tasks\": page.object_list,\n    }\n    return render(request, \"admin/background_tasks.html\", context)\n\n\nclass LinkdingAdminSite(AdminSite):\n    site_header = \"linkding administration\"\n    site_title = \"linkding Admin\"\n\n    def get_urls(self):\n        urls = super().get_urls()\n        custom_urls = [\n            path(\"tasks/\", background_task_view, name=\"background_tasks\"),\n        ]\n        return custom_urls + urls\n\n    def get_app_list(self, request, app_label=None):\n        app_list = super().get_app_list(request, app_label)\n        context_path = os.getenv(\"LD_CONTEXT_PATH\", \"\")\n        app_list += [\n            {\n                \"name\": \"Huey\",\n                \"app_label\": \"huey_app\",\n                \"models\": [\n                    {\n                        \"name\": \"Queued tasks\",\n                        \"object_name\": \"background_tasks\",\n                        \"admin_url\": f\"/{context_path}admin/tasks/\",\n                        \"view_only\": True,\n                    }\n                ],\n            }\n        ]\n        return app_list\n\n\nclass AdminBookmark(admin.ModelAdmin):\n    list_display = (\"resolved_title\", \"url\", \"is_archived\", \"owner\", \"date_added\")\n    search_fields = (\n        \"title\",\n        \"description\",\n        \"website_title\",\n        \"website_description\",\n        \"url\",\n        \"tags__name\",\n    )\n    list_filter = (\n        \"owner__username\",\n        \"is_archived\",\n        \"unread\",\n        \"tags\",\n    )\n    ordering = (\"-date_added\",)\n    actions = [\n        \"delete_selected_bookmarks\",\n        \"archive_selected_bookmarks\",\n        \"unarchive_selected_bookmarks\",\n        \"mark_as_read\",\n        \"mark_as_unread\",\n    ]\n\n    def get_actions(self, request):\n        actions = super().get_actions(request)\n        # Remove default delete action, which gets replaced by delete_selected_bookmarks below\n        # The default action shows a confirmation page which can fail in production when selecting all bookmarks and the\n        # number of objects to delete exceeds the value in DATA_UPLOAD_MAX_NUMBER_FIELDS (1000 by default)\n        del actions[\"delete_selected\"]\n        return actions\n\n    def delete_selected_bookmarks(self, request, queryset: QuerySet):\n        bookmarks_count = queryset.count()\n        for bookmark in queryset:\n            bookmark.delete()\n        self.message_user(\n            request,\n            ngettext(\n                \"%d bookmark was successfully deleted.\",\n                \"%d bookmarks were successfully deleted.\",\n                bookmarks_count,\n            )\n            % bookmarks_count,\n            messages.SUCCESS,\n        )\n\n    def archive_selected_bookmarks(self, request, queryset: QuerySet):\n        for bookmark in queryset:\n            archive_bookmark(bookmark)\n        bookmarks_count = queryset.count()\n        self.message_user(\n            request,\n            ngettext(\n                \"%d bookmark was successfully archived.\",\n                \"%d bookmarks were successfully archived.\",\n                bookmarks_count,\n            )\n            % bookmarks_count,\n            messages.SUCCESS,\n        )\n\n    def unarchive_selected_bookmarks(self, request, queryset: QuerySet):\n        for bookmark in queryset:\n            unarchive_bookmark(bookmark)\n        bookmarks_count = queryset.count()\n        self.message_user(\n            request,\n            ngettext(\n                \"%d bookmark was successfully unarchived.\",\n                \"%d bookmarks were successfully unarchived.\",\n                bookmarks_count,\n            )\n            % bookmarks_count,\n            messages.SUCCESS,\n        )\n\n    def mark_as_read(self, request, queryset: QuerySet):\n        bookmarks_count = queryset.count()\n        queryset.update(unread=False)\n        self.message_user(\n            request,\n            ngettext(\n                \"%d bookmark marked as read.\",\n                \"%d bookmarks marked as read.\",\n                bookmarks_count,\n            )\n            % bookmarks_count,\n            messages.SUCCESS,\n        )\n\n    def mark_as_unread(self, request, queryset: QuerySet):\n        bookmarks_count = queryset.count()\n        queryset.update(unread=True)\n        self.message_user(\n            request,\n            ngettext(\n                \"%d bookmark marked as unread.\",\n                \"%d bookmarks marked as unread.\",\n                bookmarks_count,\n            )\n            % bookmarks_count,\n            messages.SUCCESS,\n        )\n\n\nclass AdminBookmarkAsset(admin.ModelAdmin):\n    @admin.display(description=\"Display Name\")\n    def custom_display_name(self, obj):\n        return str(obj)\n\n    list_display = (\"custom_display_name\", \"date_created\", \"status\")\n    search_fields = (\n        \"display_name\",\n        \"file\",\n    )\n    list_filter = (\"status\",)\n\n\nclass AdminTag(admin.ModelAdmin):\n    list_display = (\"name\", \"bookmarks_count\", \"owner\", \"date_added\")\n    search_fields = (\"name\", \"owner__username\")\n    list_filter = (\"owner__username\",)\n    ordering = (\"-date_added\",)\n    actions = [\"delete_unused_tags\"]\n\n    def get_queryset(self, request):\n        queryset = super().get_queryset(request)\n        queryset = queryset.annotate(bookmarks_count=Count(\"bookmark\"))\n        return queryset\n\n    def bookmarks_count(self, obj):\n        return obj.bookmarks_count\n\n    bookmarks_count.admin_order_field = \"bookmarks_count\"\n\n    def delete_unused_tags(self, request, queryset: QuerySet):\n        unused_tags = queryset.filter(bookmark__isnull=True)\n        unused_tags_count = unused_tags.count()\n        for tag in unused_tags:\n            tag.delete()\n\n        if unused_tags_count > 0:\n            self.message_user(\n                request,\n                ngettext(\n                    \"%d unused tag was successfully deleted.\",\n                    \"%d unused tags were successfully deleted.\",\n                    unused_tags_count,\n                )\n                % unused_tags_count,\n                messages.SUCCESS,\n            )\n        else:\n            self.message_user(\n                request,\n                gettext(\n                    \"There were no unused tags in the selection\",\n                ),\n                messages.SUCCESS,\n            )\n\n\nclass AdminBookmarkBundle(admin.ModelAdmin):\n    list_display = (\n        \"name\",\n        \"owner\",\n        \"order\",\n        \"search\",\n        \"any_tags\",\n        \"all_tags\",\n        \"excluded_tags\",\n        \"filter_shared\",\n        \"filter_unread\",\n        \"date_created\",\n    )\n    search_fields = [\"name\", \"search\", \"any_tags\", \"all_tags\", \"excluded_tags\"]\n    list_filter = (\"owner__username\",)\n\n\nclass AdminUserProfileInline(admin.StackedInline):\n    model = UserProfile\n    can_delete = False\n    verbose_name_plural = \"Profile\"\n    fk_name = \"user\"\n    readonly_fields = (\"search_preferences\",)\n\n\nclass AdminCustomUser(UserAdmin):\n    inlines = (AdminUserProfileInline,)\n\n    def get_inline_instances(self, request, obj=None):\n        if not obj:\n            return list()\n        return super().get_inline_instances(request, obj)\n\n\nclass AdminToast(admin.ModelAdmin):\n    list_display = (\"key\", \"message\", \"owner\", \"acknowledged\")\n    search_fields = (\"key\", \"message\")\n    list_filter = (\"owner__username\",)\n\n\nclass AdminFeedToken(admin.ModelAdmin):\n    list_display = (\"key\", \"user\")\n    search_fields = [\"key\"]\n    list_filter = (\"user__username\",)\n\n\nclass ApiTokenAdminForm(forms.ModelForm):\n    class Meta:\n        model = ApiToken\n        fields = (\"name\", \"user\")\n\n\nclass AdminApiToken(admin.ModelAdmin):\n    form = ApiTokenAdminForm\n    list_display = (\"name\", \"user\", \"created\")\n    search_fields = [\"name\", \"user__username\"]\n    list_filter = (\"user__username\",)\n    ordering = (\"-created\",)\n\n\nlinkding_admin_site = LinkdingAdminSite()\nlinkding_admin_site.register(Bookmark, AdminBookmark)\nlinkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)\nlinkding_admin_site.register(Tag, AdminTag)\nlinkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)\nlinkding_admin_site.register(User, AdminCustomUser)\nlinkding_admin_site.register(ApiToken, AdminApiToken)\nlinkding_admin_site.register(Toast, AdminToast)\nlinkding_admin_site.register(FeedToken, AdminFeedToken)\n"
  },
  {
    "path": "bookmarks/api/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/api/auth.py",
    "content": "from django.utils.translation import gettext_lazy as _\nfrom rest_framework import exceptions\nfrom rest_framework.authentication import TokenAuthentication, get_authorization_header\n\nfrom bookmarks.models import ApiToken\n\n\nclass LinkdingTokenAuthentication(TokenAuthentication):\n    \"\"\"\n    Extends DRF TokenAuthentication to add support for multiple keywords and\n    multiple tokens per user.\n    \"\"\"\n\n    model = ApiToken\n    keywords = [keyword.lower().encode() for keyword in [\"Token\", \"Bearer\"]]\n\n    def authenticate(self, request):\n        auth = get_authorization_header(request).split()\n\n        if not auth or auth[0].lower() not in self.keywords:\n            return None\n\n        if len(auth) == 1:\n            msg = _(\"Invalid token header. No credentials provided.\")\n            raise exceptions.AuthenticationFailed(msg)\n        elif len(auth) > 2:\n            msg = _(\"Invalid token header. Token string should not contain spaces.\")\n            raise exceptions.AuthenticationFailed(msg)\n\n        try:\n            token = auth[1].decode()\n        except UnicodeError:\n            msg = _(\n                \"Invalid token header. Token string should not contain invalid characters.\"\n            )\n            raise exceptions.AuthenticationFailed(msg) from None\n\n        return self.authenticate_credentials(token)\n"
  },
  {
    "path": "bookmarks/api/routes.py",
    "content": "import gzip\nimport logging\nimport os\n\nfrom django.conf import settings\nfrom django.http import Http404, StreamingHttpResponse\nfrom rest_framework import mixins, status, viewsets\nfrom rest_framework.decorators import action\nfrom rest_framework.permissions import AllowAny\nfrom rest_framework.response import Response\nfrom rest_framework.routers import DefaultRouter, SimpleRouter\n\nfrom bookmarks import queries\nfrom bookmarks.api.serializers import (\n    BookmarkAssetSerializer,\n    BookmarkBundleSerializer,\n    BookmarkSerializer,\n    TagSerializer,\n    UserProfileSerializer,\n)\nfrom bookmarks.models import (\n    Bookmark,\n    BookmarkAsset,\n    BookmarkBundle,\n    BookmarkSearch,\n    Tag,\n    User,\n)\nfrom bookmarks.services import assets, auto_tagging, bookmarks, bundles, website_loader\nfrom bookmarks.type_defs import HttpRequest\nfrom bookmarks.views import access\n\nlogger = logging.getLogger(__name__)\n\n\nclass BookmarkViewSet(\n    viewsets.GenericViewSet,\n    mixins.ListModelMixin,\n    mixins.RetrieveModelMixin,\n    mixins.CreateModelMixin,\n    mixins.UpdateModelMixin,\n    mixins.DestroyModelMixin,\n):\n    request: HttpRequest\n    serializer_class = BookmarkSerializer\n\n    def get_permissions(self):\n        # Allow unauthenticated access to shared bookmarks.\n        # The shared action should still filter bookmarks so that\n        # unauthenticated users only see bookmarks from users that have public\n        # sharing explicitly enabled\n        if self.action == \"shared\":\n            return [AllowAny()]\n\n        # Otherwise use default permissions which should require authentication\n        return super().get_permissions()\n\n    def get_queryset(self):\n        # Provide filtered queryset for list actions\n        user = self.request.user\n        search = BookmarkSearch.from_request(self.request, self.request.GET)\n        if self.action == \"list\":\n            return queries.query_bookmarks(user, user.profile, search)\n        elif self.action == \"archived\":\n            return queries.query_archived_bookmarks(user, user.profile, search)\n        elif self.action == \"shared\":\n            user = User.objects.filter(username=search.user).first()\n            public_only = not self.request.user.is_authenticated\n            return queries.query_shared_bookmarks(\n                user, self.request.user_profile, search, public_only\n            )\n\n        # For single entity actions return user owned bookmarks\n        return Bookmark.objects.all().filter(owner=user)\n\n    def get_serializer_context(self):\n        disable_scraping = \"disable_scraping\" in self.request.GET\n        disable_html_snapshot = \"disable_html_snapshot\" in self.request.GET\n        return {\n            \"request\": self.request,\n            \"user\": self.request.user,\n            \"disable_scraping\": disable_scraping,\n            \"disable_html_snapshot\": disable_html_snapshot,\n        }\n\n    @action(methods=[\"get\"], detail=False)\n    def archived(self, request: HttpRequest):\n        return self.list(request)\n\n    @action(methods=[\"get\"], detail=False)\n    def shared(self, request: HttpRequest):\n        return self.list(request)\n\n    @action(methods=[\"post\"], detail=True)\n    def archive(self, request: HttpRequest, pk):\n        bookmark = self.get_object()\n        bookmarks.archive_bookmark(bookmark)\n        return Response(status=status.HTTP_204_NO_CONTENT)\n\n    @action(methods=[\"post\"], detail=True)\n    def unarchive(self, request: HttpRequest, pk):\n        bookmark = self.get_object()\n        bookmarks.unarchive_bookmark(bookmark)\n        return Response(status=status.HTTP_204_NO_CONTENT)\n\n    @action(methods=[\"get\"], detail=False)\n    def check(self, request: HttpRequest):\n        url = request.GET.get(\"url\")\n        ignore_cache = request.GET.get(\"ignore_cache\", False) in [\"true\"]\n        bookmark = Bookmark.query_existing(request.user, url).first()\n        existing_bookmark_data = (\n            self.get_serializer(bookmark).data if bookmark else None\n        )\n\n        metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)\n\n        # Return tags that would be automatically applied to the bookmark\n        profile = request.user.profile\n        auto_tags = []\n        if profile.auto_tagging_rules:\n            try:\n                auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)\n            except Exception as e:\n                logger.error(\n                    f\"Failed to auto-tag bookmark. url={url}\",\n                    exc_info=e,\n                )\n\n        return Response(\n            {\n                \"bookmark\": existing_bookmark_data,\n                \"metadata\": metadata.to_dict(),\n                \"auto_tags\": auto_tags,\n            },\n            status=status.HTTP_200_OK,\n        )\n\n    @action(methods=[\"post\"], detail=False)\n    def singlefile(self, request: HttpRequest):\n        if settings.LD_DISABLE_ASSET_UPLOAD:\n            return Response(\n                {\"error\": \"Asset upload is disabled.\"},\n                status=status.HTTP_403_FORBIDDEN,\n            )\n        url = request.POST.get(\"url\")\n        file = request.FILES.get(\"file\")\n\n        if not url or not file:\n            return Response(\n                {\"error\": \"Both 'url' and 'file' parameters are required.\"},\n                status=status.HTTP_400_BAD_REQUEST,\n            )\n\n        bookmark = Bookmark.query_existing(request.user, url).first()\n\n        if not bookmark:\n            bookmark = Bookmark(url=url)\n            bookmark = bookmarks.create_bookmark(\n                bookmark, \"\", request.user, disable_html_snapshot=True\n            )\n            bookmarks.enhance_with_website_metadata(bookmark)\n\n        assets.upload_snapshot(bookmark, file.read())\n\n        return Response(\n            {\"message\": \"Snapshot uploaded successfully.\"},\n            status=status.HTTP_201_CREATED,\n        )\n\n\nclass BookmarkAssetViewSet(\n    viewsets.GenericViewSet,\n    mixins.ListModelMixin,\n    mixins.RetrieveModelMixin,\n    mixins.DestroyModelMixin,\n):\n    request: HttpRequest\n    serializer_class = BookmarkAssetSerializer\n\n    def get_queryset(self):\n        user = self.request.user\n        # limit access to assets to the owner of the bookmark for now\n        bookmark = access.bookmark_write(self.request, self.kwargs[\"bookmark_id\"])\n        return BookmarkAsset.objects.filter(\n            bookmark_id=bookmark.id, bookmark__owner=user\n        )\n\n    def get_serializer_context(self):\n        return {\"user\": self.request.user}\n\n    @action(detail=True, methods=[\"get\"], url_path=\"download\")\n    def download(self, request: HttpRequest, bookmark_id, pk):\n        asset = self.get_object()\n        try:\n            file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)\n            content_type = asset.content_type\n            file_stream = (\n                gzip.GzipFile(file_path, mode=\"rb\")\n                if asset.gzip\n                else open(file_path, \"rb\")  # noqa: SIM115\n            )\n            response = StreamingHttpResponse(file_stream, content_type=content_type)\n            response[\"Content-Disposition\"] = (\n                f'attachment; filename=\"{asset.download_name}\"'\n            )\n            return response\n        except FileNotFoundError:\n            raise Http404(\"Asset file does not exist\") from None\n        except Exception as e:\n            logger.error(\n                f\"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}\",\n                exc_info=e,\n            )\n            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)\n\n    @action(methods=[\"post\"], detail=False)\n    def upload(self, request: HttpRequest, bookmark_id):\n        if settings.LD_DISABLE_ASSET_UPLOAD:\n            return Response(\n                {\"error\": \"Asset upload is disabled.\"},\n                status=status.HTTP_403_FORBIDDEN,\n            )\n        bookmark = access.bookmark_write(request, bookmark_id)\n\n        upload_file = request.FILES.get(\"file\")\n        if not upload_file:\n            return Response(\n                {\"error\": \"No file provided.\"}, status=status.HTTP_400_BAD_REQUEST\n            )\n\n        try:\n            asset = assets.upload_asset(bookmark, upload_file)\n            serializer = self.get_serializer(asset)\n            return Response(serializer.data, status=status.HTTP_201_CREATED)\n        except Exception as e:\n            logger.error(\n                f\"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}\",\n                exc_info=e,\n            )\n            return Response(\n                {\"error\": \"Failed to upload asset.\"},\n                status=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            )\n\n    def perform_destroy(self, instance):\n        assets.remove_asset(instance)\n\n\nclass TagViewSet(\n    viewsets.GenericViewSet,\n    mixins.ListModelMixin,\n    mixins.RetrieveModelMixin,\n    mixins.CreateModelMixin,\n):\n    request: HttpRequest\n    serializer_class = TagSerializer\n\n    def get_queryset(self):\n        user = self.request.user\n        return Tag.objects.all().filter(owner=user)\n\n    def get_serializer_context(self):\n        return {\"user\": self.request.user}\n\n\nclass UserViewSet(viewsets.GenericViewSet):\n    @action(methods=[\"get\"], detail=False)\n    def profile(self, request: HttpRequest):\n        return Response(UserProfileSerializer(request.user.profile).data)\n\n\nclass BookmarkBundleViewSet(\n    viewsets.GenericViewSet,\n    mixins.ListModelMixin,\n    mixins.RetrieveModelMixin,\n    mixins.CreateModelMixin,\n    mixins.UpdateModelMixin,\n    mixins.DestroyModelMixin,\n):\n    request: HttpRequest\n    serializer_class = BookmarkBundleSerializer\n\n    def get_queryset(self):\n        user = self.request.user\n        return BookmarkBundle.objects.filter(owner=user).order_by(\"order\")\n\n    def get_serializer_context(self):\n        return {\"user\": self.request.user}\n\n    def perform_destroy(self, instance):\n        bundles.delete_bundle(instance)\n\n\n# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/\n# Instead create separate routers for each view set and manually register them in urls.py\n# The default router is only used to allow reversing a URL for the API root\ndefault_router = DefaultRouter()\n\nbookmark_router = SimpleRouter()\nbookmark_router.register(\"\", BookmarkViewSet, basename=\"bookmark\")\n\ntag_router = SimpleRouter()\ntag_router.register(\"\", TagViewSet, basename=\"tag\")\n\nuser_router = SimpleRouter()\nuser_router.register(\"\", UserViewSet, basename=\"user\")\n\nbundle_router = SimpleRouter()\nbundle_router.register(\"\", BookmarkBundleViewSet, basename=\"bundle\")\n\nbookmark_asset_router = SimpleRouter()\nbookmark_asset_router.register(\"\", BookmarkAssetViewSet, basename=\"bookmark_asset\")\n"
  },
  {
    "path": "bookmarks/api/serializers.py",
    "content": "from django.db.models import prefetch_related_objects\nfrom django.templatetags.static import static\nfrom rest_framework import serializers\nfrom rest_framework.serializers import ListSerializer\n\nfrom bookmarks.models import (\n    Bookmark,\n    BookmarkAsset,\n    BookmarkBundle,\n    Tag,\n    UserProfile,\n    build_tag_string,\n)\nfrom bookmarks.services import bookmarks, bundles\nfrom bookmarks.services.tags import get_or_create_tag\nfrom bookmarks.services.wayback import generate_fallback_webarchive_url\nfrom bookmarks.utils import app_version\n\n\nclass TagListField(serializers.ListField):\n    child = serializers.CharField()\n\n\nclass BookmarkListSerializer(ListSerializer):\n    def to_representation(self, data):\n        # Prefetch nested relations to avoid n+1 queries\n        prefetch_related_objects(data, \"tags\")\n\n        return super().to_representation(data)\n\n\nclass EmtpyField(serializers.ReadOnlyField):\n    def to_representation(self, value):\n        return None\n\n\nclass BookmarkBundleSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = BookmarkBundle\n        fields = [\n            \"id\",\n            \"name\",\n            \"search\",\n            \"any_tags\",\n            \"all_tags\",\n            \"excluded_tags\",\n            \"filter_unread\",\n            \"filter_shared\",\n            \"order\",\n            \"date_created\",\n            \"date_modified\",\n        ]\n        read_only_fields = [\n            \"id\",\n            \"date_created\",\n            \"date_modified\",\n        ]\n\n    def create(self, validated_data):\n        bundle = BookmarkBundle(**validated_data)\n        bundle.order = validated_data.get(\"order\", None)\n        return bundles.create_bundle(bundle, self.context[\"user\"])\n\n\nclass BookmarkSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = Bookmark\n        fields = [\n            \"id\",\n            \"url\",\n            \"title\",\n            \"description\",\n            \"notes\",\n            \"web_archive_snapshot_url\",\n            \"favicon_url\",\n            \"preview_image_url\",\n            \"is_archived\",\n            \"unread\",\n            \"shared\",\n            \"tag_names\",\n            \"date_added\",\n            \"date_modified\",\n            \"website_title\",\n            \"website_description\",\n        ]\n        read_only_fields = [\n            \"web_archive_snapshot_url\",\n            \"favicon_url\",\n            \"preview_image_url\",\n            \"tag_names\",\n            \"website_title\",\n            \"website_description\",\n        ]\n        list_serializer_class = BookmarkListSerializer\n\n    # Custom tag_names field to allow passing a list of tag names to create/update\n    tag_names = TagListField(required=False)\n    # Custom fields to generate URLs for favicon, preview image, and web archive snapshot\n    favicon_url = serializers.SerializerMethodField()\n    preview_image_url = serializers.SerializerMethodField()\n    web_archive_snapshot_url = serializers.SerializerMethodField()\n    # Add dummy website title and description fields for backwards compatibility but keep them empty\n    website_title = EmtpyField()\n    website_description = EmtpyField()\n    # these are optional\n    date_added = serializers.DateTimeField(required=False)\n    date_modified = serializers.DateTimeField(required=False)\n\n    def get_favicon_url(self, obj: Bookmark):\n        if not obj.favicon_file:\n            return None\n        request = self.context.get(\"request\")\n        favicon_file_path = static(obj.favicon_file)\n        favicon_url = request.build_absolute_uri(favicon_file_path)\n        return favicon_url\n\n    def get_preview_image_url(self, obj: Bookmark):\n        if not obj.preview_image_file:\n            return None\n        request = self.context.get(\"request\")\n        preview_image_file_path = static(obj.preview_image_file)\n        preview_image_url = request.build_absolute_uri(preview_image_file_path)\n        return preview_image_url\n\n    def get_web_archive_snapshot_url(self, obj: Bookmark):\n        if obj.web_archive_snapshot_url:\n            return obj.web_archive_snapshot_url\n\n        return generate_fallback_webarchive_url(obj.url, obj.date_added)\n\n    def create(self, validated_data):\n        tag_names = validated_data.pop(\"tag_names\", [])\n        tag_string = build_tag_string(tag_names)\n        bookmark = Bookmark(**validated_data)\n\n        disable_scraping = self.context.get(\"disable_scraping\", False)\n        disable_html_snapshot = self.context.get(\"disable_html_snapshot\", False)\n\n        saved_bookmark = bookmarks.create_bookmark(\n            bookmark,\n            tag_string,\n            self.context[\"user\"],\n            disable_html_snapshot=disable_html_snapshot,\n        )\n        # Unless scraping is explicitly disabled, enhance bookmark with website\n        # metadata to preserve backwards compatibility with clients that expect\n        # title and description to be populated automatically when left empty\n        if not disable_scraping:\n            bookmarks.enhance_with_website_metadata(saved_bookmark)\n        return saved_bookmark\n\n    def update(self, instance: Bookmark, validated_data):\n        tag_names = validated_data.pop(\"tag_names\", instance.tag_names)\n        tag_string = build_tag_string(tag_names)\n\n        for field_name, field in self.fields.items():\n            if not field.read_only and field_name in validated_data:\n                setattr(instance, field_name, validated_data[field_name])\n\n        return bookmarks.update_bookmark(instance, tag_string, self.context[\"user\"])\n\n    def validate(self, attrs):\n        # When creating a bookmark, the service logic prevents duplicate URLs by\n        # updating the existing bookmark instead. When editing a bookmark,\n        # there is no assumption that it would update a different bookmark if\n        # the URL is a duplicate, so raise a validation error in that case.\n        if self.instance and \"url\" in attrs:\n            is_duplicate = (\n                Bookmark.objects.filter(owner=self.instance.owner, url=attrs[\"url\"])\n                .exclude(pk=self.instance.pk)\n                .exists()\n            )\n            if is_duplicate:\n                raise serializers.ValidationError(\n                    {\"url\": \"A bookmark with this URL already exists.\"}\n                )\n\n        return attrs\n\n\nclass BookmarkAssetSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = BookmarkAsset\n        fields = [\n            \"id\",\n            \"bookmark\",\n            \"date_created\",\n            \"file_size\",\n            \"asset_type\",\n            \"content_type\",\n            \"display_name\",\n            \"status\",\n        ]\n\n\nclass TagSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = Tag\n        fields = [\"id\", \"name\", \"date_added\"]\n        read_only_fields = [\"date_added\"]\n\n    def create(self, validated_data):\n        return get_or_create_tag(validated_data[\"name\"], self.context[\"user\"])\n\n\nclass UserProfileSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = UserProfile\n        fields = [\n            \"theme\",\n            \"bookmark_date_display\",\n            \"bookmark_link_target\",\n            \"web_archive_integration\",\n            \"tag_search\",\n            \"enable_sharing\",\n            \"enable_public_sharing\",\n            \"enable_favicons\",\n            \"display_url\",\n            \"permanent_notes\",\n            \"search_preferences\",\n            \"version\",\n        ]\n\n    version = serializers.ReadOnlyField(default=app_version)\n"
  },
  {
    "path": "bookmarks/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass BookmarksConfig(AppConfig):\n    name = \"bookmarks\"\n\n    def ready(self):\n        # Register signal handlers\n        # noinspection PyUnusedImports\n        import bookmarks.signals  # noqa: F401\n"
  },
  {
    "path": "bookmarks/context_processors.py",
    "content": "from bookmarks import utils\nfrom bookmarks.models import Toast\n\n\ndef toasts(request):\n    user = request.user\n    toast_messages = (\n        Toast.objects.filter(owner=user, acknowledged=False)\n        if user.is_authenticated\n        else []\n    )\n    has_toasts = len(toast_messages) > 0\n\n    return {\n        \"has_toasts\": has_toasts,\n        \"toast_messages\": toast_messages,\n    }\n\n\ndef app_version(request):\n    return {\"app_version\": utils.app_version}\n"
  },
  {
    "path": "bookmarks/feeds.py",
    "content": "import unicodedata\nfrom dataclasses import dataclass\n\nfrom django.contrib.syndication.views import Feed\nfrom django.db.models import QuerySet, prefetch_related_objects\nfrom django.http import HttpRequest\nfrom django.urls import reverse\n\nfrom bookmarks import queries\nfrom bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile\nfrom bookmarks.views import access\n\n\n@dataclass\nclass FeedContext:\n    request: HttpRequest\n    feed_token: FeedToken | None\n    query_set: QuerySet[Bookmark]\n\n\ndef sanitize(text: str):\n    if not text:\n        return \"\"\n    # remove control characters\n    valid_chars = [\"\\n\", \"\\r\", \"\\t\"]\n    return \"\".join(\n        ch for ch in text if ch in valid_chars or unicodedata.category(ch)[0] != \"C\"\n    )\n\n\nclass BaseBookmarksFeed(Feed):\n    def get_object(self, request, feed_key: str | None):\n        feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None\n        bundle = None\n        bundle_id = request.GET.get(\"bundle\")\n        if bundle_id:\n            bundle = access.bundle_read(request, bundle_id)\n\n        search = BookmarkSearch(\n            q=request.GET.get(\"q\", \"\"),\n            unread=request.GET.get(\"unread\", \"\"),\n            shared=request.GET.get(\"shared\", \"\"),\n            bundle=bundle,\n        )\n        query_set = self.get_query_set(feed_token, search)\n        return FeedContext(request, feed_token, query_set)\n\n    def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):\n        raise NotImplementedError\n\n    def items(self, context: FeedContext):\n        limit = context.request.GET.get(\"limit\", 100)\n        data = context.query_set[: int(limit)] if limit else list(context.query_set)\n        prefetch_related_objects(data, \"tags\")\n        return data\n\n    def item_title(self, item: Bookmark):\n        return sanitize(item.resolved_title)\n\n    def item_description(self, item: Bookmark):\n        return sanitize(item.resolved_description)\n\n    def item_link(self, item: Bookmark):\n        return item.url\n\n    def item_pubdate(self, item: Bookmark):\n        return item.date_added\n\n    def item_categories(self, item: Bookmark):\n        return item.tag_names\n\n\nclass AllBookmarksFeed(BaseBookmarksFeed):\n    title = \"All bookmarks\"\n    description = \"All bookmarks\"\n\n    def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):\n        return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)\n\n    def link(self, context: FeedContext):\n        return reverse(\"linkding:feeds.all\", args=[context.feed_token.key])\n\n\nclass UnreadBookmarksFeed(BaseBookmarksFeed):\n    title = \"Unread bookmarks\"\n    description = \"All unread bookmarks\"\n\n    def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):\n        return queries.query_bookmarks(\n            feed_token.user, feed_token.user.profile, search\n        ).filter(unread=True)\n\n    def link(self, context: FeedContext):\n        return reverse(\"linkding:feeds.unread\", args=[context.feed_token.key])\n\n\nclass SharedBookmarksFeed(BaseBookmarksFeed):\n    title = \"Shared bookmarks\"\n    description = \"All shared bookmarks\"\n\n    def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):\n        return queries.query_shared_bookmarks(\n            None, feed_token.user.profile, search, False\n        )\n\n    def link(self, context: FeedContext):\n        return reverse(\"linkding:feeds.shared\", args=[context.feed_token.key])\n\n\nclass PublicSharedBookmarksFeed(BaseBookmarksFeed):\n    title = \"Public shared bookmarks\"\n    description = \"All public shared bookmarks\"\n\n    def get_object(self, request):\n        return super().get_object(request, None)\n\n    def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):\n        return queries.query_shared_bookmarks(None, UserProfile(), search, True)\n\n    def link(self, context: FeedContext):\n        return reverse(\"linkding:feeds.public_shared\")\n"
  },
  {
    "path": "bookmarks/forms.py",
    "content": "from django import forms\nfrom django.contrib.auth.models import User\nfrom django.db import models\nfrom django.utils import timezone\n\nfrom bookmarks.models import (\n    Bookmark,\n    BookmarkBundle,\n    BookmarkSearch,\n    GlobalSettings,\n    Tag,\n    UserProfile,\n    build_tag_string,\n    parse_tag_string,\n    sanitize_tag_name,\n)\nfrom bookmarks.services.bookmarks import create_bookmark, update_bookmark\nfrom bookmarks.type_defs import HttpRequest\nfrom bookmarks.validators import BookmarkURLValidator\nfrom bookmarks.widgets import (\n    FormCheckbox,\n    FormErrorList,\n    FormInput,\n    FormNumberInput,\n    FormSelect,\n    FormTextarea,\n    TagAutocomplete,\n)\n\n\nclass BookmarkForm(forms.ModelForm):\n    # Use URLField for URL\n    url = forms.CharField(validators=[BookmarkURLValidator()], widget=FormInput)\n    tag_string = forms.CharField(required=False, widget=TagAutocomplete)\n    # Do not require title and description as they may be empty\n    title = forms.CharField(max_length=512, required=False, widget=FormInput)\n    description = forms.CharField(required=False, widget=FormTextarea)\n    notes = forms.CharField(required=False, widget=FormTextarea)\n    unread = forms.BooleanField(required=False, widget=FormCheckbox)\n    shared = forms.BooleanField(required=False, widget=FormCheckbox)\n    # Hidden field that determines whether to close window/tab after saving the bookmark\n    auto_close = forms.CharField(required=False, widget=forms.HiddenInput)\n\n    class Meta:\n        model = Bookmark\n        fields = [\n            \"url\",\n            \"tag_string\",\n            \"title\",\n            \"description\",\n            \"notes\",\n            \"unread\",\n            \"shared\",\n            \"auto_close\",\n        ]\n\n    def __init__(self, request: HttpRequest, instance: Bookmark = None):\n        self.request = request\n\n        initial = None\n        if instance is None and request.method == \"GET\":\n            initial = {\n                \"url\": request.GET.get(\"url\"),\n                \"title\": request.GET.get(\"title\"),\n                \"description\": request.GET.get(\"description\"),\n                \"notes\": request.GET.get(\"notes\"),\n                \"tag_string\": request.GET.get(\"tags\"),\n                \"auto_close\": \"auto_close\" in request.GET,\n                \"unread\": request.user_profile.default_mark_unread,\n                \"shared\": request.user_profile.default_mark_shared,\n            }\n        if instance is not None and request.method == \"GET\":\n            initial = {\"tag_string\": build_tag_string(instance.tag_names, \" \")}\n        data = request.POST if request.method == \"POST\" else None\n        super().__init__(\n            data, instance=instance, initial=initial, error_class=FormErrorList\n        )\n\n    @property\n    def is_auto_close(self):\n        return self.data.get(\"auto_close\", False) == \"True\" or self.initial.get(\n            \"auto_close\", False\n        )\n\n    @property\n    def has_notes(self):\n        return self.initial.get(\"notes\", None) or (\n            self.instance and self.instance.notes\n        )\n\n    def save(self, commit=False):\n        tag_string = convert_tag_string(self.data[\"tag_string\"])\n        bookmark = super().save(commit=False)\n        if self.instance.pk:\n            return update_bookmark(bookmark, tag_string, self.request.user)\n        else:\n            return create_bookmark(bookmark, tag_string, self.request.user)\n\n    def clean_url(self):\n        # When creating a bookmark, the service logic prevents duplicate URLs by\n        # updating the existing bookmark instead, which is also communicated in\n        # the form's UI. When editing a bookmark, there is no assumption that\n        # it would update a different bookmark if the URL is a duplicate, so\n        # raise a validation error in that case.\n        url = self.cleaned_data[\"url\"]\n        if self.instance.pk:\n            is_duplicate = (\n                Bookmark.query_existing(self.instance.owner, url)\n                .exclude(pk=self.instance.pk)\n                .exists()\n            )\n            if is_duplicate:\n                raise forms.ValidationError(\"A bookmark with this URL already exists.\")\n\n        return url\n\n\ndef convert_tag_string(tag_string: str):\n    # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated\n    # strings\n    return tag_string.replace(\" \", \",\")\n\n\nclass TagForm(forms.ModelForm):\n    name = forms.CharField(widget=FormInput)\n\n    class Meta:\n        model = Tag\n        fields = [\"name\"]\n\n    def __init__(self, user, *args, **kwargs):\n        super().__init__(*args, **kwargs, error_class=FormErrorList)\n        self.user = user\n\n    def clean_name(self):\n        name = self.cleaned_data.get(\"name\", \"\").strip()\n\n        name = sanitize_tag_name(name)\n\n        queryset = Tag.objects.filter(name__iexact=name, owner=self.user)\n        if self.instance.pk:\n            queryset = queryset.exclude(pk=self.instance.pk)\n\n        if queryset.exists():\n            raise forms.ValidationError(f'Tag \"{name}\" already exists.')\n\n        return name\n\n    def save(self, commit=True):\n        tag = super().save(commit=False)\n        if not self.instance.pk:\n            tag.owner = self.user\n            tag.date_added = timezone.now()\n        else:\n            tag.date_modified = timezone.now()\n        if commit:\n            tag.save()\n        return tag\n\n\nclass TagMergeForm(forms.Form):\n    target_tag = forms.CharField(widget=TagAutocomplete)\n    merge_tags = forms.CharField(widget=TagAutocomplete)\n\n    def __init__(self, user, *args, **kwargs):\n        super().__init__(*args, **kwargs, error_class=FormErrorList)\n        self.user = user\n\n    def clean_target_tag(self):\n        target_tag_name = self.cleaned_data.get(\"target_tag\", \"\")\n\n        target_tag_names = parse_tag_string(target_tag_name, \" \")\n        if len(target_tag_names) != 1:\n            raise forms.ValidationError(\n                \"Please enter only one tag name for the target tag.\"\n            )\n\n        target_tag_name = target_tag_names[0]\n\n        try:\n            target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)\n        except Tag.DoesNotExist:\n            raise forms.ValidationError(\n                f'Tag \"{target_tag_name}\" does not exist.'\n            ) from None\n\n        return target_tag\n\n    def clean_merge_tags(self):\n        merge_tags_string = self.cleaned_data.get(\"merge_tags\", \"\")\n\n        merge_tag_names = parse_tag_string(merge_tags_string, \" \")\n        if not merge_tag_names:\n            raise forms.ValidationError(\"Please enter at least one tag to merge.\")\n\n        merge_tags = []\n        for tag_name in merge_tag_names:\n            try:\n                tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)\n                merge_tags.append(tag)\n            except Tag.DoesNotExist:\n                raise forms.ValidationError(\n                    f'Tag \"{tag_name}\" does not exist.'\n                ) from None\n\n        target_tag = self.cleaned_data.get(\"target_tag\")\n        if target_tag and target_tag in merge_tags:\n            raise forms.ValidationError(\n                \"The target tag cannot be selected for merging.\"\n            )\n\n        return merge_tags\n\n\nclass BookmarkBundleForm(forms.ModelForm):\n    name = forms.CharField(max_length=256, widget=FormInput)\n    search = forms.CharField(max_length=256, required=False, widget=FormInput)\n    any_tags = forms.CharField(required=False, widget=TagAutocomplete)\n    all_tags = forms.CharField(required=False, widget=TagAutocomplete)\n    excluded_tags = forms.CharField(required=False, widget=TagAutocomplete)\n    filter_unread = forms.ChoiceField(\n        choices=BookmarkBundle.FILTER_UNREAD_CHOICES,\n        required=False,\n        widget=FormSelect,\n    )\n    filter_shared = forms.ChoiceField(\n        choices=BookmarkBundle.FILTER_SHARED_CHOICES,\n        required=False,\n        widget=FormSelect,\n    )\n\n    class Meta:\n        model = BookmarkBundle\n        fields = [\n            \"name\",\n            \"search\",\n            \"any_tags\",\n            \"all_tags\",\n            \"excluded_tags\",\n            \"filter_unread\",\n            \"filter_shared\",\n        ]\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs, error_class=FormErrorList)\n\n\nclass BookmarkSearchForm(forms.Form):\n    SORT_CHOICES = [\n        (BookmarkSearch.SORT_ADDED_ASC, \"Added ↑\"),\n        (BookmarkSearch.SORT_ADDED_DESC, \"Added ↓\"),\n        (BookmarkSearch.SORT_TITLE_ASC, \"Title ↑\"),\n        (BookmarkSearch.SORT_TITLE_DESC, \"Title ↓\"),\n    ]\n    FILTER_SHARED_CHOICES = [\n        (BookmarkSearch.FILTER_SHARED_OFF, \"Off\"),\n        (BookmarkSearch.FILTER_SHARED_SHARED, \"Shared\"),\n        (BookmarkSearch.FILTER_SHARED_UNSHARED, \"Unshared\"),\n    ]\n    FILTER_UNREAD_CHOICES = [\n        (BookmarkSearch.FILTER_UNREAD_OFF, \"Off\"),\n        (BookmarkSearch.FILTER_UNREAD_YES, \"Unread\"),\n        (BookmarkSearch.FILTER_UNREAD_NO, \"Read\"),\n    ]\n\n    q = forms.CharField()\n    user = forms.ChoiceField(required=False, widget=FormSelect)\n    bundle = forms.CharField(required=False)\n    sort = forms.ChoiceField(choices=SORT_CHOICES, widget=FormSelect)\n    shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)\n    unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)\n    modified_since = forms.CharField(required=False)\n    added_since = forms.CharField(required=False)\n\n    def __init__(\n        self,\n        search: BookmarkSearch,\n        editable_fields: list[str] = None,\n        users: list[User] = None,\n    ):\n        super().__init__()\n        editable_fields = editable_fields or []\n        self.editable_fields = editable_fields\n\n        # set choices for user field if users are provided\n        if users:\n            user_choices = [(user.username, user.username) for user in users]\n            user_choices.insert(0, (\"\", \"Everyone\"))\n            self.fields[\"user\"].choices = user_choices\n\n        for param in search.params:\n            # set initial values for modified params\n            value = search.__dict__.get(param)\n            if isinstance(value, models.Model):\n                self.fields[param].initial = value.id\n            else:\n                self.fields[param].initial = value\n\n            # Mark non-editable modified fields as hidden. That way, templates\n            # rendering a form can just loop over hidden_fields to ensure that\n            # all necessary search options are kept when submitting the form.\n            if search.is_modified(param) and param not in editable_fields:\n                self.fields[param].widget = forms.HiddenInput()\n\n\nclass UserProfileForm(forms.ModelForm):\n    class Meta:\n        model = UserProfile\n        fields = [\n            \"theme\",\n            \"bookmark_date_display\",\n            \"bookmark_description_display\",\n            \"bookmark_description_max_lines\",\n            \"bookmark_link_target\",\n            \"web_archive_integration\",\n            \"tag_search\",\n            \"tag_grouping\",\n            \"enable_sharing\",\n            \"enable_public_sharing\",\n            \"enable_favicons\",\n            \"enable_preview_images\",\n            \"enable_automatic_html_snapshots\",\n            \"display_url\",\n            \"display_view_bookmark_action\",\n            \"display_edit_bookmark_action\",\n            \"display_archive_bookmark_action\",\n            \"display_remove_bookmark_action\",\n            \"permanent_notes\",\n            \"default_mark_unread\",\n            \"default_mark_shared\",\n            \"custom_css\",\n            \"auto_tagging_rules\",\n            \"items_per_page\",\n            \"sticky_pagination\",\n            \"collapse_side_panel\",\n            \"hide_bundles\",\n            \"legacy_search\",\n        ]\n        widgets = {\n            \"theme\": FormSelect,\n            \"bookmark_date_display\": FormSelect,\n            \"bookmark_description_display\": FormSelect,\n            \"bookmark_description_max_lines\": FormNumberInput,\n            \"bookmark_link_target\": FormSelect,\n            \"web_archive_integration\": FormSelect,\n            \"tag_search\": FormSelect,\n            \"tag_grouping\": FormSelect,\n            \"auto_tagging_rules\": FormTextarea,\n            \"custom_css\": FormTextarea,\n            \"items_per_page\": FormNumberInput,\n            \"display_url\": FormCheckbox,\n            \"permanent_notes\": FormCheckbox,\n            \"display_view_bookmark_action\": FormCheckbox,\n            \"display_edit_bookmark_action\": FormCheckbox,\n            \"display_archive_bookmark_action\": FormCheckbox,\n            \"display_remove_bookmark_action\": FormCheckbox,\n            \"sticky_pagination\": FormCheckbox,\n            \"collapse_side_panel\": FormCheckbox,\n            \"hide_bundles\": FormCheckbox,\n            \"legacy_search\": FormCheckbox,\n            \"enable_favicons\": FormCheckbox,\n            \"enable_preview_images\": FormCheckbox,\n            \"enable_sharing\": FormCheckbox,\n            \"enable_public_sharing\": FormCheckbox,\n            \"enable_automatic_html_snapshots\": FormCheckbox,\n            \"default_mark_unread\": FormCheckbox,\n            \"default_mark_shared\": FormCheckbox,\n        }\n\n\nclass GlobalSettingsForm(forms.ModelForm):\n    class Meta:\n        model = GlobalSettings\n        fields = [\"landing_page\", \"guest_profile_user\", \"enable_link_prefetch\"]\n        widgets = {\n            \"landing_page\": FormSelect,\n            \"guest_profile_user\": FormSelect,\n            \"enable_link_prefetch\": FormCheckbox,\n        }\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.fields[\"guest_profile_user\"].empty_label = \"Standard profile\"\n"
  },
  {
    "path": "bookmarks/frontend/api.js",
    "content": "export class Api {\n  constructor(baseUrl) {\n    this.baseUrl = baseUrl;\n  }\n\n  listBookmarks(search, options = { limit: 100, offset: 0, path: \"\" }) {\n    const query = [`limit=${options.limit}`, `offset=${options.offset}`];\n    Object.keys(search).forEach((key) => {\n      const value = search[key];\n      if (value) {\n        query.push(`${key}=${encodeURIComponent(value)}`);\n      }\n    });\n    const queryString = query.join(\"&\");\n    const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`;\n\n    return fetch(url)\n      .then((response) => response.json())\n      .then((data) => data.results);\n  }\n\n  getTags(options = { limit: 100, offset: 0 }) {\n    const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;\n\n    return fetch(url)\n      .then((response) => response.json())\n      .then((data) => data.results);\n  }\n}\n\nconst apiBaseUrl = document.documentElement.dataset.apiBaseUrl || \"\";\nexport const api = new Api(apiBaseUrl);\n"
  },
  {
    "path": "bookmarks/frontend/components/bookmark-page.js",
    "content": "import { HeadlessElement } from \"../utils/element.js\";\n\nclass BookmarkPage extends HeadlessElement {\n  init() {\n    this.update = this.update.bind(this);\n    this.onToggleNotes = this.onToggleNotes.bind(this);\n    this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this);\n    this.onBulkActionChange = this.onBulkActionChange.bind(this);\n    this.onToggleAll = this.onToggleAll.bind(this);\n    this.onToggleBookmark = this.onToggleBookmark.bind(this);\n\n    this.oldItems = [];\n    this.update();\n    document.addEventListener(\"bookmark-list-updated\", this.update);\n  }\n\n  disconnectedCallback() {\n    document.removeEventListener(\"bookmark-list-updated\", this.update);\n  }\n\n  update() {\n    const items = this.querySelectorAll(\"ul.bookmark-list > li\");\n    this.updateTooltips(items);\n    this.updateNotesToggles(items, this.oldItems);\n    this.updateBulkEdit(items, this.oldItems);\n    this.oldItems = items;\n  }\n\n  updateTooltips(items) {\n    // Add tooltip to title if it is truncated\n    items.forEach((item) => {\n      const titleAnchor = item.querySelector(\".title > a\");\n      const titleSpan = titleAnchor.querySelector(\"span\");\n      if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {\n        titleAnchor.dataset.tooltip = titleSpan.textContent;\n      } else {\n        delete titleAnchor.dataset.tooltip;\n      }\n    });\n  }\n\n  updateNotesToggles(items, oldItems) {\n    oldItems.forEach((oldItem) => {\n      const oldToggle = oldItem.querySelector(\".toggle-notes\");\n      if (oldToggle) {\n        oldToggle.removeEventListener(\"click\", this.onToggleNotes);\n      }\n    });\n\n    items.forEach((item) => {\n      const notesToggle = item.querySelector(\".toggle-notes\");\n      if (notesToggle) {\n        notesToggle.addEventListener(\"click\", this.onToggleNotes);\n      }\n    });\n  }\n\n  onToggleNotes(event) {\n    event.preventDefault();\n    event.stopPropagation();\n    event.target.closest(\"li\").classList.toggle(\"show-notes\");\n  }\n\n  updateBulkEdit() {\n    if (this.hasAttribute(\"no-bulk-edit\")) {\n      return;\n    }\n\n    // Remove existing listeners\n    this.activeToggle?.removeEventListener(\"click\", this.onToggleBulkEdit);\n    this.actionSelect?.removeEventListener(\"change\", this.onBulkActionChange);\n    this.allCheckbox?.removeEventListener(\"change\", this.onToggleAll);\n    this.bookmarkCheckboxes?.forEach((checkbox) => {\n      checkbox.removeEventListener(\"change\", this.onToggleBookmark);\n    });\n\n    // Re-query elements\n    this.activeToggle = this.querySelector(\".bulk-edit-active-toggle\");\n    this.actionSelect = this.querySelector(\"select[name='bulk_action']\");\n    this.allCheckbox = this.querySelector(\".bulk-edit-checkbox.all input\");\n    this.bookmarkCheckboxes = Array.from(\n      this.querySelectorAll(\".bulk-edit-checkbox:not(.all) input\"),\n    );\n    this.selectAcross = this.querySelector(\"label.select-across\");\n    this.executeButton = this.querySelector(\"button[name='bulk_execute']\");\n\n    // Add listeners\n    this.activeToggle.addEventListener(\"click\", this.onToggleBulkEdit);\n    this.actionSelect.addEventListener(\"change\", this.onBulkActionChange);\n    this.allCheckbox.addEventListener(\"change\", this.onToggleAll);\n    this.bookmarkCheckboxes.forEach((checkbox) => {\n      checkbox.addEventListener(\"change\", this.onToggleBookmark);\n    });\n\n    // Reset checkbox states\n    this.allCheckbox.checked = false;\n    this.bookmarkCheckboxes.forEach((checkbox) => {\n      checkbox.checked = false;\n    });\n    this.updateSelectAcross(false);\n    this.updateExecuteButton();\n\n    // Update total number of bookmarks\n    const totalHolder = this.querySelector(\"[data-bookmarks-total]\");\n    const total = totalHolder?.dataset.bookmarksTotal || 0;\n    const totalSpan = this.selectAcross.querySelector(\"span.total\");\n    totalSpan.textContent = total;\n  }\n\n  onToggleBulkEdit() {\n    this.classList.toggle(\"active\");\n  }\n\n  onBulkActionChange() {\n    this.dataset.bulkAction = this.actionSelect.value;\n  }\n\n  onToggleAll() {\n    const allChecked = this.allCheckbox.checked;\n    this.bookmarkCheckboxes.forEach((checkbox) => {\n      checkbox.checked = allChecked;\n    });\n    this.updateSelectAcross(allChecked);\n    this.updateExecuteButton();\n  }\n\n  onToggleBookmark() {\n    const allChecked = this.bookmarkCheckboxes.every((checkbox) => {\n      return checkbox.checked;\n    });\n    this.allCheckbox.checked = allChecked;\n    this.updateSelectAcross(allChecked);\n    this.updateExecuteButton();\n  }\n\n  updateSelectAcross(allChecked) {\n    if (allChecked) {\n      this.selectAcross.classList.remove(\"d-none\");\n    } else {\n      this.selectAcross.classList.add(\"d-none\");\n      this.selectAcross.querySelector(\"input\").checked = false;\n    }\n  }\n\n  updateExecuteButton() {\n    const anyChecked = this.bookmarkCheckboxes.some((checkbox) => {\n      return checkbox.checked;\n    });\n    this.executeButton.disabled = !anyChecked;\n  }\n}\n\ncustomElements.define(\"ld-bookmark-page\", BookmarkPage);\n"
  },
  {
    "path": "bookmarks/frontend/components/clear-button.js",
    "content": "import { HeadlessElement } from \"../utils/element\";\n\nclass ClearButton extends HeadlessElement {\n  init() {\n    this.field = document.getElementById(this.dataset.for);\n    if (!this.field) {\n      console.error(`Field with ID ${this.dataset.for} not found`);\n      return;\n    }\n    this.update = this.update.bind(this);\n    this.clear = this.clear.bind(this);\n\n    this.addEventListener(\"click\", this.clear);\n    this.field.addEventListener(\"input\", this.update);\n    this.field.addEventListener(\"value-changed\", this.update);\n    this.update();\n  }\n\n  update() {\n    this.style.display = this.field.value ? \"inline\" : \"none\";\n  }\n\n  clear() {\n    this.field.value = \"\";\n    this.field.focus();\n    this.update();\n  }\n}\n\ncustomElements.define(\"ld-clear-button\", ClearButton);\n"
  },
  {
    "path": "bookmarks/frontend/components/confirm-dropdown.js",
    "content": "import { html, LitElement } from \"lit\";\nimport { FocusTrapController, isKeyboardActive } from \"../utils/focus.js\";\nimport { PositionController } from \"../utils/position-controller.js\";\n\nlet confirmId = 0;\n\nfunction nextConfirmId() {\n  return `confirm-${confirmId++}`;\n}\n\nfunction removeAll() {\n  document\n    .querySelectorAll(\"ld-confirm-dropdown\")\n    .forEach((dropdown) => dropdown.close());\n}\n\n// Create a confirm dropdown whenever a button with the data-confirm attribute is clicked\ndocument.addEventListener(\"click\", (event) => {\n  // Check if the clicked element is a button with data-confirm\n  const button = event.target.closest(\"button[data-confirm]\");\n  if (!button) return;\n\n  // Remove any existing confirm dropdowns\n  removeAll();\n\n  // Show confirmation dropdown\n  event.preventDefault();\n\n  const dropdown = document.createElement(\"ld-confirm-dropdown\");\n  dropdown.button = button;\n  document.body.appendChild(dropdown);\n});\n\n// Remove all confirm dropdowns when:\n// - Turbo caches the page\n// - The escape key is pressed\ndocument.addEventListener(\"turbo:before-cache\", removeAll);\ndocument.addEventListener(\"keydown\", (event) => {\n  if (event.key === \"Escape\") {\n    removeAll();\n  }\n});\n\nclass ConfirmDropdown extends LitElement {\n  constructor() {\n    super();\n    this.confirmId = nextConfirmId();\n  }\n\n  createRenderRoot() {\n    return this;\n  }\n\n  firstUpdated(props) {\n    super.firstUpdated(props);\n    this.classList.add(\"dropdown\", \"confirm-dropdown\", \"active\");\n\n    const menu = this.querySelector(\".menu\");\n    this.positionController = new PositionController({\n      anchor: this.button,\n      overlay: menu,\n      arrow: this.querySelector(\".menu-arrow\"),\n      offset: 12,\n    });\n    this.positionController.enable();\n    this.focusTrap = new FocusTrapController(menu);\n  }\n\n  render() {\n    const questionText = this.button.dataset.confirmQuestion || \"Are you sure?\";\n    return html`\n      <div\n        class=\"menu with-arrow\"\n        role=\"alertdialog\"\n        aria-modal=\"true\"\n        aria-labelledby=${this.confirmId}\n      >\n        <span id=${this.confirmId} style=\"font-weight: bold;\">\n          ${questionText}\n        </span>\n        <button type=\"button\" class=\"btn\" @click=${this.close}>Cancel</button>\n        <button type=\"submit\" class=\"btn btn-error\" @click=${this.confirm}>\n          Confirm\n        </button>\n        <div class=\"menu-arrow\"></div>\n      </div>\n    `;\n  }\n\n  confirm() {\n    this.button.closest(\"form\").requestSubmit(this.button);\n    this.close();\n  }\n\n  close() {\n    this.positionController.disable();\n    this.focusTrap.destroy();\n    this.remove();\n    this.button.focus({ focusVisible: isKeyboardActive() });\n  }\n}\n\ncustomElements.define(\"ld-confirm-dropdown\", ConfirmDropdown);\n"
  },
  {
    "path": "bookmarks/frontend/components/details-modal.js",
    "content": "import { setAfterPageLoadFocusTarget } from \"../utils/focus.js\";\nimport { Modal } from \"./modal.js\";\n\nclass DetailsModal extends Modal {\n  doClose() {\n    super.doClose();\n\n    // Try restore focus to view details to view details link of respective bookmark\n    const bookmarkId = this.dataset.bookmarkId;\n    setAfterPageLoadFocusTarget(\n      `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,\n    );\n  }\n}\n\ncustomElements.define(\"ld-details-modal\", DetailsModal);\n"
  },
  {
    "path": "bookmarks/frontend/components/dev-tool.js",
    "content": "import { LitElement, html, css } from \"lit\";\n\nclass DevTool extends LitElement {\n  static properties = {\n    profile: { type: Object, state: true },\n    formAction: { type: String, attribute: \"data-form-action\" },\n    csrfToken: { type: String, attribute: \"data-csrf-token\" },\n    isOpen: { type: Boolean, state: true },\n  };\n\n  static styles = css`\n    :host {\n      position: fixed;\n      bottom: 1rem;\n      right: 1rem;\n      z-index: 10000;\n    }\n\n    .button {\n      background: var(--btn-primary-bg-color);\n      color: var(--btn-primary-text-color);\n      border: none;\n      padding: var(--unit-2);\n      border-radius: var(--border-radius);\n      box-shadow: var(--btn-box-shadow);\n      cursor: pointer;\n      height: auto;\n      line-height: 0;\n    }\n\n    .overlay {\n      display: none;\n      position: absolute;\n      bottom: 100%;\n      right: 0;\n      background: var(--body-color);\n      color: var(--text-color);\n      border: 1px solid var(--border-color);\n      border-radius: var(--border-radius);\n      padding: var(--unit-2);\n      margin-bottom: var(--unit-2);\n      min-width: 220px;\n      box-shadow: var(--box-shadow-lg);\n      font-size: var(--font-size-sm);\n    }\n\n    :host([open]) .overlay {\n      display: block;\n    }\n\n    h3 {\n      margin: 0 0 var(--unit-2) 0;\n    }\n\n    label {\n      display: flex;\n      align-items: center;\n      gap: var(--unit-1);\n      cursor: pointer;\n    }\n\n    label:has(select) {\n      margin-bottom: var(--unit-1);\n    }\n\n    label:has(select) span {\n      min-width: 100px;\n    }\n\n    hr {\n      margin: var(--unit-2) 0;\n      border: none;\n      border-top: 1px solid var(--border-color);\n    }\n  `;\n\n  static fields = [\n    {\n      type: \"select\",\n      key: \"theme\",\n      label: \"Theme\",\n      options: [\n        { value: \"auto\", label: \"Auto\" },\n        { value: \"light\", label: \"Light\" },\n        { value: \"dark\", label: \"Dark\" },\n      ],\n    },\n    {\n      type: \"select\",\n      key: \"bookmark_date_display\",\n      label: \"Date\",\n      options: [\n        { value: \"relative\", label: \"Relative\" },\n        { value: \"absolute\", label: \"Absolute\" },\n        { value: \"hidden\", label: \"Hidden\" },\n      ],\n    },\n    {\n      type: \"select\",\n      key: \"bookmark_description_display\",\n      label: \"Description\",\n      options: [\n        { value: \"inline\", label: \"Inline\" },\n        { value: \"separate\", label: \"Separate\" },\n      ],\n    },\n    { type: \"checkbox\", key: \"enable_favicons\", label: \"Favicons\" },\n    { type: \"checkbox\", key: \"enable_preview_images\", label: \"Preview images\" },\n    { type: \"checkbox\", key: \"display_url\", label: \"Display URL\" },\n    { type: \"checkbox\", key: \"permanent_notes\", label: \"Permanent notes\" },\n    { type: \"checkbox\", key: \"collapse_side_panel\", label: \"Collapse sidebar\" },\n    { type: \"checkbox\", key: \"sticky_pagination\", label: \"Sticky pagination\" },\n    { type: \"checkbox\", key: \"hide_bundles\", label: \"Hide bundles\" },\n  ];\n\n  constructor() {\n    super();\n    this.isOpen = false;\n    this.profile = {};\n    this._onOutsideClick = this._onOutsideClick.bind(this);\n  }\n\n  connectedCallback() {\n    super.connectedCallback();\n    const profileData = document.getElementById(\"json_profile\");\n    this.profile = JSON.parse(profileData.textContent || \"{}\");\n    document.addEventListener(\"click\", this._onOutsideClick);\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    document.removeEventListener(\"click\", this._onOutsideClick);\n  }\n\n  _onOutsideClick(e) {\n    if (!this.contains(e.target) && this.isOpen) {\n      this.isOpen = false;\n      this.removeAttribute(\"open\");\n    }\n  }\n\n  _toggle() {\n    this.isOpen = !this.isOpen;\n    if (this.isOpen) {\n      this.setAttribute(\"open\", \"\");\n    } else {\n      this.removeAttribute(\"open\");\n    }\n  }\n\n  _handleChange(key, value) {\n    this.profile = { ...this.profile, [key]: value };\n    if (key === \"theme\") {\n      const themeLinks = document.head.querySelectorAll('link[href*=\"theme\"]');\n      themeLinks.forEach((link) => link.remove());\n    }\n    this._submitForm();\n  }\n\n  _renderField(field) {\n    switch (field.type) {\n      case \"checkbox\":\n        return html`\n          <label>\n            <input\n              type=\"checkbox\"\n              .checked=${this.profile[field.key] || false}\n              @change=${(e) => this._handleChange(field.key, e.target.checked)}\n            />\n            ${field.label}\n          </label>\n        `;\n      case \"select\":\n        return html`\n          <label>\n            <span>${field.label}:</span>\n            <select\n              @change=${(e) => this._handleChange(field.key, e.target.value)}\n            >\n              ${field.options.map(\n                (opt) => html`\n                  <option\n                    value=${opt.value}\n                    ?selected=${this.profile[field.key] === opt.value}\n                  >\n                    ${opt.label}\n                  </option>\n                `,\n              )}\n            </select>\n          </label>\n        `;\n      case \"divider\":\n        return html`<hr />`;\n      default:\n        return null;\n    }\n  }\n\n  async _submitForm() {\n    const formData = new FormData();\n    formData.append(\"csrfmiddlewaretoken\", this.csrfToken);\n\n    // Profile fields\n    for (const [key, value] of Object.entries(this.profile)) {\n      if (typeof value === \"boolean\" && value) {\n        formData.append(key, \"on\");\n      } else if (typeof value !== \"boolean\") {\n        formData.append(key, value);\n      }\n    }\n\n    // Submit button name that settings.update expects\n    formData.append(\"update_profile\", \"1\");\n\n    await fetch(this.formAction, {\n      method: \"POST\",\n      body: formData,\n    });\n\n    const url = new URL(window.location);\n    url.searchParams.set(\"ts\", Date.now().toString());\n    window.history.replaceState({}, \"\", url);\n\n    Turbo.visit(url.toString());\n  }\n\n  render() {\n    return html`\n      <button class=\"button\" @click=${() => this._toggle()}>\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n        >\n          <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n          <path\n            d=\"M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065\"\n          />\n          <path d=\"M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" />\n        </svg>\n      </button>\n      <div class=\"overlay\">\n        <h3>Dev Tools</h3>\n        ${DevTool.fields.map((field) => this._renderField(field))}\n      </div>\n    `;\n  }\n}\n\ncustomElements.define(\"ld-dev-tool\", DevTool);\n"
  },
  {
    "path": "bookmarks/frontend/components/dropdown.js",
    "content": "import { HeadlessElement } from \"../utils/element.js\";\n\nclass Dropdown extends HeadlessElement {\n  constructor() {\n    super();\n    this.opened = false;\n    this.onClick = this.onClick.bind(this);\n    this.onOutsideClick = this.onOutsideClick.bind(this);\n    this.onEscape = this.onEscape.bind(this);\n    this.onFocusOut = this.onFocusOut.bind(this);\n  }\n\n  init() {\n    // Prevent opening the dropdown automatically on focus, so that it only\n    // opens on click when JS is enabled\n    this.style.setProperty(\"--dropdown-focus-display\", \"none\");\n    this.addEventListener(\"keydown\", this.onEscape);\n    this.addEventListener(\"focusout\", this.onFocusOut);\n\n    this.toggle = this.querySelector(\".dropdown-toggle\");\n    this.toggle.setAttribute(\"aria-expanded\", \"false\");\n    this.toggle.addEventListener(\"click\", this.onClick);\n  }\n\n  disconnectedCallback() {\n    this.close();\n  }\n\n  open() {\n    this.opened = true;\n    this.classList.add(\"active\");\n    this.toggle.setAttribute(\"aria-expanded\", \"true\");\n    document.addEventListener(\"click\", this.onOutsideClick);\n  }\n\n  close() {\n    this.opened = false;\n    this.classList.remove(\"active\");\n    this.toggle?.setAttribute(\"aria-expanded\", \"false\");\n    document.removeEventListener(\"click\", this.onOutsideClick);\n  }\n\n  onClick() {\n    if (this.opened) {\n      this.close();\n    } else {\n      this.open();\n    }\n  }\n\n  onOutsideClick(event) {\n    if (!this.contains(event.target)) {\n      this.close();\n    }\n  }\n\n  onEscape(event) {\n    if (event.key === \"Escape\" && this.opened) {\n      event.preventDefault();\n      this.close();\n      this.toggle.focus();\n    }\n  }\n\n  onFocusOut(event) {\n    if (!this.contains(event.relatedTarget)) {\n      this.close();\n    }\n  }\n}\n\ncustomElements.define(\"ld-dropdown\", Dropdown);\n"
  },
  {
    "path": "bookmarks/frontend/components/filter-drawer.js",
    "content": "import { html, render } from \"lit\";\nimport { Modal } from \"./modal.js\";\nimport { HeadlessElement } from \"../utils/element.js\";\nimport { isKeyboardActive } from \"../utils/focus.js\";\n\nclass FilterDrawerTrigger extends HeadlessElement {\n  init() {\n    this.onClick = this.onClick.bind(this);\n    this.addEventListener(\"click\", this.onClick.bind(this));\n  }\n\n  onClick() {\n    const modal = document.createElement(\"ld-filter-drawer\");\n    document.body.querySelector(\".modals\").appendChild(modal);\n  }\n}\n\ncustomElements.define(\"ld-filter-drawer-trigger\", FilterDrawerTrigger);\n\nclass FilterDrawer extends Modal {\n  connectedCallback() {\n    this.classList.add(\"modal\", \"drawer\");\n\n    // Render modal structure\n    render(\n      html`\n        <div class=\"modal-overlay\" data-close-modal></div>\n        <div class=\"modal-container\" role=\"dialog\" aria-modal=\"true\">\n          <div class=\"modal-header\">\n            <h2>Filters</h2>\n            <button\n              class=\"btn btn-noborder close\"\n              aria-label=\"Close dialog\"\n              data-close-modal\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"24\"\n                height=\"24\"\n                viewBox=\"0 0 24 24\"\n                stroke-width=\"2\"\n                stroke=\"currentColor\"\n                fill=\"none\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n              >\n                <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"></path>\n                <path d=\"M18 6l-12 12\"></path>\n                <path d=\"M6 6l12 12\"></path>\n              </svg>\n            </button>\n          </div>\n          <div class=\"modal-body\"></div>\n        </div>\n      `,\n      this,\n    );\n    // Teleport filter content\n    this.teleport();\n    // Force close on turbo cache to restore content\n    this.doClose = this.doClose.bind(this);\n    document.addEventListener(\"turbo:before-cache\", this.doClose);\n    // Force reflow to make transform transition work\n    this.getBoundingClientRect();\n    // Add active class to start slide-in animation\n    requestAnimationFrame(() => this.classList.add(\"active\"));\n    // Call super.init() after rendering to ensure elements are available\n    super.init();\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    this.teleportBack();\n    document.removeEventListener(\"turbo:before-cache\", this.doClose);\n  }\n\n  mapHeading(container, from, to) {\n    const headings = container.querySelectorAll(from);\n    headings.forEach((heading) => {\n      const newHeading = document.createElement(to);\n      newHeading.textContent = heading.textContent;\n      heading.replaceWith(newHeading);\n    });\n  }\n\n  teleport() {\n    const content = this.querySelector(\".modal-body\");\n    const sidePanel = document.querySelector(\".side-panel\");\n    content.append(...sidePanel.children);\n    this.mapHeading(content, \"h2\", \"h3\");\n  }\n\n  teleportBack() {\n    const sidePanel = document.querySelector(\".side-panel\");\n    const content = this.querySelector(\".modal-body\");\n    sidePanel.append(...content.children);\n    this.mapHeading(sidePanel, \"h3\", \"h2\");\n  }\n\n  doClose() {\n    super.doClose();\n\n    // Try restore focus to drawer trigger\n    const restoreFocusElement =\n      document.querySelector(\"ld-filter-drawer-trigger\") || document.body;\n    restoreFocusElement.focus({ focusVisible: isKeyboardActive() });\n  }\n}\n\ncustomElements.define(\"ld-filter-drawer\", FilterDrawer);\n"
  },
  {
    "path": "bookmarks/frontend/components/form.js",
    "content": "import { HeadlessElement } from \"../utils/element.js\";\n\nclass Form extends HeadlessElement {\n  constructor() {\n    super();\n    this.onKeyDown = this.onKeyDown.bind(this);\n    this.onChange = this.onChange.bind(this);\n  }\n\n  init() {\n    this.addEventListener(\"keydown\", this.onKeyDown);\n    this.addEventListener(\"change\", this.onChange);\n\n    if (this.hasAttribute(\"data-form-reset\")) {\n      // Resets form controls to their initial values before Turbo caches the DOM.\n      // Useful for filter forms where navigating back would otherwise still show\n      // values from after the form submission, which means the filters would be out\n      // of sync with the URL.\n      this.initFormReset();\n    }\n  }\n\n  disconnectedCallback() {\n    if (this.hasAttribute(\"data-form-reset\")) {\n      this.resetForm();\n    }\n  }\n\n  onChange(event) {\n    if (event.target.hasAttribute(\"data-submit-on-change\")) {\n      this.querySelector(\"form\")?.requestSubmit();\n    }\n  }\n\n  onKeyDown(event) {\n    // Check for Ctrl/Cmd + Enter combination\n    if (\n      this.hasAttribute(\"data-submit-on-ctrl-enter\") &&\n      event.key === \"Enter\" &&\n      (event.metaKey || event.ctrlKey)\n    ) {\n      event.preventDefault();\n      event.stopPropagation();\n      this.querySelector(\"form\")?.requestSubmit();\n    }\n  }\n\n  initFormReset() {\n    this.controls = this.querySelectorAll(\"input, select\");\n    this.controls.forEach((control) => {\n      if (control.type === \"checkbox\" || control.type === \"radio\") {\n        control.__initialValue = control.checked;\n      } else {\n        control.__initialValue = control.value;\n      }\n    });\n  }\n\n  resetForm() {\n    this.controls.forEach((control) => {\n      if (control.type === \"checkbox\" || control.type === \"radio\") {\n        control.checked = control.__initialValue;\n      } else {\n        control.value = control.__initialValue;\n      }\n      delete control.__initialValue;\n    });\n  }\n}\n\ncustomElements.define(\"ld-form\", Form);\n"
  },
  {
    "path": "bookmarks/frontend/components/modal.js",
    "content": "import { FocusTrapController } from \"../utils/focus.js\";\nimport { HeadlessElement } from \"../utils/element.js\";\n\nexport class Modal extends HeadlessElement {\n  init() {\n    this.onClose = this.onClose.bind(this);\n    this.onKeyDown = this.onKeyDown.bind(this);\n\n    this.querySelectorAll(\"[data-close-modal]\").forEach((btn) => {\n      btn.addEventListener(\"click\", this.onClose);\n    });\n    this.addEventListener(\"keydown\", this.onKeyDown);\n\n    this.setupScrollLock();\n    this.focusTrap = new FocusTrapController(\n      this.querySelector(\".modal-container\"),\n    );\n  }\n\n  disconnectedCallback() {\n    this.removeScrollLock();\n    this.focusTrap.destroy();\n  }\n\n  setupScrollLock() {\n    document.body.classList.add(\"scroll-lock\");\n  }\n\n  removeScrollLock() {\n    document.body.classList.remove(\"scroll-lock\");\n  }\n\n  onKeyDown(event) {\n    // Skip if event occurred within an input element\n    const targetNodeName = event.target.nodeName;\n    const isInputTarget =\n      targetNodeName === \"INPUT\" ||\n      targetNodeName === \"SELECT\" ||\n      targetNodeName === \"TEXTAREA\";\n\n    if (isInputTarget) {\n      return;\n    }\n\n    if (event.key === \"Escape\") {\n      this.onClose(event);\n    }\n  }\n\n  onClose(event) {\n    event.preventDefault();\n    this.classList.add(\"closing\");\n    this.addEventListener(\n      \"animationend\",\n      (event) => {\n        if (event.animationName === \"fade-out\") {\n          this.doClose();\n        }\n      },\n      { once: true },\n    );\n  }\n\n  doClose() {\n    this.remove();\n    this.dispatchEvent(new CustomEvent(\"modal:close\"));\n\n    // Navigate to close URL\n    const closeUrl = this.dataset.closeUrl;\n    const frame = this.dataset.turboFrame;\n    const action = this.dataset.turboAction || \"replace\";\n    if (closeUrl) {\n      Turbo.visit(closeUrl, { action, frame: frame });\n    }\n  }\n}\n\ncustomElements.define(\"ld-modal\", Modal);\n"
  },
  {
    "path": "bookmarks/frontend/components/search-autocomplete.js",
    "content": "import { html } from \"lit\";\nimport { api } from \"../api.js\";\nimport { TurboLitElement } from \"../utils/element.js\";\nimport {\n  clampText,\n  debounce,\n  getCurrentWord,\n  getCurrentWordBounds,\n} from \"../utils/input.js\";\nimport { PositionController } from \"../utils/position-controller.js\";\nimport { SearchHistory } from \"../utils/search-history.js\";\nimport { cache } from \"../utils/tag-cache.js\";\n\nexport class SearchAutocomplete extends TurboLitElement {\n  static properties = {\n    inputName: { type: String, attribute: \"input-name\" },\n    inputPlaceholder: { type: String, attribute: \"input-placeholder\" },\n    inputValue: { type: String, attribute: \"input-value\" },\n    mode: { type: String },\n    user: { type: String },\n    shared: { type: String },\n    unread: { type: String },\n    target: { type: String },\n    isFocus: { state: true },\n    isOpen: { state: true },\n    suggestions: { state: true },\n    selectedIndex: { state: true },\n  };\n\n  constructor() {\n    super();\n    this.inputName = \"\";\n    this.inputPlaceholder = \"\";\n    this.inputValue = \"\";\n    this.mode = \"\";\n    this.target = \"_blank\";\n    this.isFocus = false;\n    this.isOpen = false;\n    this.suggestions = {\n      recentSearches: [],\n      bookmarks: [],\n      tags: [],\n      total: [],\n    };\n    this.selectedIndex = undefined;\n    this.input = null;\n    this.menu = null;\n    this.searchHistory = new SearchHistory();\n    this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());\n  }\n\n  firstUpdated() {\n    this.style.setProperty(\"--menu-max-height\", \"400px\");\n    this.input = this.querySelector(\"input\");\n    this.menu = this.querySelector(\".menu\");\n    // Track current search query after loading the page\n    this.searchHistory.pushCurrent();\n    this.updateSuggestions();\n    this.positionController = new PositionController({\n      anchor: this.input,\n      overlay: this.menu,\n      autoWidth: true,\n      placement: \"bottom-start\",\n    });\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    this.close();\n  }\n\n  handleFocus() {\n    this.isFocus = true;\n  }\n\n  handleBlur() {\n    this.isFocus = false;\n    this.close();\n  }\n\n  handleInput(e) {\n    this.inputValue = e.target.value;\n    this.debouncedLoadSuggestions();\n  }\n\n  handleKeyDown(e) {\n    // Enter\n    if (\n      this.isOpen &&\n      this.selectedIndex !== undefined &&\n      (e.keyCode === 13 || e.keyCode === 9)\n    ) {\n      const suggestion = this.suggestions.total[this.selectedIndex];\n      if (suggestion) this.completeSuggestion(suggestion);\n      e.preventDefault();\n    }\n    // Escape\n    if (e.keyCode === 27) {\n      this.close();\n      e.preventDefault();\n    }\n    // Up arrow\n    if (e.keyCode === 38) {\n      this.updateSelection(-1);\n      e.preventDefault();\n    }\n    // Down arrow\n    if (e.keyCode === 40) {\n      if (!this.isOpen) {\n        this.loadSuggestions();\n      } else {\n        this.updateSelection(1);\n      }\n      e.preventDefault();\n    }\n  }\n\n  open() {\n    this.isOpen = true;\n    this.positionController.enable();\n  }\n\n  close() {\n    this.isOpen = false;\n    this.updateSuggestions();\n    this.selectedIndex = undefined;\n    this.positionController.disable();\n  }\n\n  hasSuggestions() {\n    return this.suggestions.total.length > 0;\n  }\n\n  async loadSuggestions() {\n    let suggestionIndex = 0;\n\n    function nextIndex() {\n      return suggestionIndex++;\n    }\n\n    // Tag suggestions\n    const tags = await cache.getTags();\n    let tagSuggestions = [];\n    const currentWord = getCurrentWord(this.input);\n    if (currentWord && currentWord.length > 1 && currentWord[0] === \"#\") {\n      const searchTag = currentWord.substring(1, currentWord.length);\n      tagSuggestions = (tags || [])\n        .filter(\n          (tag) =>\n            tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,\n        )\n        .slice(0, 5)\n        .map((tag) => ({\n          type: \"tag\",\n          index: nextIndex(),\n          label: `#${tag.name}`,\n          tagName: tag.name,\n        }));\n    }\n\n    // Recent search suggestions\n    const recentSearches = this.searchHistory\n      .getRecentSearches(this.inputValue, 5)\n      .map((value) => ({\n        type: \"search\",\n        index: nextIndex(),\n        label: value,\n        value,\n      }));\n\n    // Bookmark suggestions\n    let bookmarks = [];\n\n    if (this.inputValue && this.inputValue.length >= 3) {\n      const path = this.mode ? `/${this.mode}` : \"\";\n      const suggestionSearch = {\n        user: this.user,\n        shared: this.shared,\n        unread: this.unread,\n        q: this.inputValue,\n      };\n      const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {\n        limit: 5,\n        offset: 0,\n        path,\n      });\n      bookmarks = fetchedBookmarks.map((bookmark) => {\n        const fullLabel = bookmark.title || bookmark.url;\n        const label = clampText(fullLabel, 60);\n        return {\n          type: \"bookmark\",\n          index: nextIndex(),\n          label,\n          bookmark,\n        };\n      });\n    }\n\n    this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);\n\n    if (this.hasSuggestions()) {\n      this.open();\n    } else {\n      this.close();\n    }\n  }\n\n  updateSuggestions(recentSearches, bookmarks, tagSuggestions) {\n    recentSearches = recentSearches || [];\n    bookmarks = bookmarks || [];\n    tagSuggestions = tagSuggestions || [];\n    this.suggestions = {\n      recentSearches,\n      bookmarks,\n      tags: tagSuggestions,\n      total: [...tagSuggestions, ...recentSearches, ...bookmarks],\n    };\n  }\n\n  completeSuggestion(suggestion) {\n    if (suggestion.type === \"search\") {\n      this.inputValue = suggestion.value;\n      this.close();\n    }\n    if (suggestion.type === \"bookmark\") {\n      window.open(suggestion.bookmark.url, this.target);\n      this.close();\n    }\n    if (suggestion.type === \"tag\") {\n      const bounds = getCurrentWordBounds(this.input);\n      const inputValue = this.input.value;\n      this.input.value =\n        inputValue.substring(0, bounds.start) +\n        `#${suggestion.tagName} ` +\n        inputValue.substring(bounds.end);\n      this.close();\n    }\n  }\n\n  updateSelection(dir) {\n    const length = this.suggestions.total.length;\n\n    if (length === 0) return;\n\n    if (this.selectedIndex === undefined) {\n      this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);\n      return;\n    }\n\n    let newIndex = this.selectedIndex + dir;\n\n    if (newIndex < 0) newIndex = Math.max(length - 1, 0);\n    if (newIndex >= length) newIndex = 0;\n\n    this.selectedIndex = newIndex;\n  }\n\n  renderSuggestions(suggestions, title) {\n    if (suggestions.length === 0) return \"\";\n\n    return html`\n      <li class=\"menu-item group-item\">${title}</li>\n      ${suggestions.map(\n        (suggestion) => html`\n          <li\n            class=\"menu-item ${this.selectedIndex === suggestion.index\n              ? \"selected\"\n              : \"\"}\"\n          >\n            <a\n              href=\"#\"\n              @mousedown=${(e) => {\n                e.preventDefault();\n                this.completeSuggestion(suggestion);\n              }}\n            >\n              ${suggestion.label}\n            </a>\n          </li>\n        `,\n      )}\n    `;\n  }\n\n  render() {\n    return html`\n      <div class=\"form-autocomplete\">\n        <div\n          class=\"form-autocomplete-input form-input ${this.isFocus\n            ? \"is-focused\"\n            : \"\"}\"\n        >\n          <input\n            type=\"search\"\n            class=\"form-input\"\n            name=\"${this.inputName}\"\n            placeholder=\"${this.inputPlaceholder}\"\n            autocomplete=\"off\"\n            .value=\"${this.inputValue}\"\n            @input=${this.handleInput}\n            @keydown=${this.handleKeyDown}\n            @focus=${this.handleFocus}\n            @blur=${this.handleBlur}\n          />\n        </div>\n\n        <ul class=\"menu ${this.isOpen ? \"open\" : \"\"}\">\n          ${this.renderSuggestions(this.suggestions.tags, \"Tags\")}\n          ${this.renderSuggestions(\n            this.suggestions.recentSearches,\n            \"Recent Searches\",\n          )}\n          ${this.renderSuggestions(this.suggestions.bookmarks, \"Bookmarks\")}\n        </ul>\n      </div>\n    `;\n  }\n}\n\ncustomElements.define(\"ld-search-autocomplete\", SearchAutocomplete);\n"
  },
  {
    "path": "bookmarks/frontend/components/tag-autocomplete.js",
    "content": "import { html, nothing } from \"lit\";\nimport { TurboLitElement } from \"../utils/element.js\";\nimport { getCurrentWord, getCurrentWordBounds } from \"../utils/input.js\";\nimport { PositionController } from \"../utils/position-controller.js\";\nimport { cache } from \"../utils/tag-cache.js\";\n\nexport class TagAutocomplete extends TurboLitElement {\n  static properties = {\n    inputId: { type: String, attribute: \"input-id\" },\n    inputName: { type: String, attribute: \"input-name\" },\n    inputValue: { type: String, attribute: \"input-value\" },\n    inputClass: { type: String, attribute: \"input-class\" },\n    inputPlaceholder: { type: String, attribute: \"input-placeholder\" },\n    inputAriaDescribedBy: { type: String, attribute: \"input-aria-describedby\" },\n    variant: { type: String },\n    isFocus: { state: true },\n    isOpen: { state: true },\n    suggestions: { state: true },\n    selectedIndex: { state: true },\n  };\n\n  constructor() {\n    super();\n    this.inputId = \"\";\n    this.inputName = \"\";\n    this.inputValue = \"\";\n    this.inputPlaceholder = \"\";\n    this.inputAriaDescribedBy = \"\";\n    this.variant = \"default\";\n    this.isFocus = false;\n    this.isOpen = false;\n    this.suggestions = [];\n    this.selectedIndex = 0;\n    this.input = null;\n    this.suggestionList = null;\n  }\n\n  firstUpdated() {\n    this.input = this.querySelector(\"input\");\n    this.suggestionList = this.querySelector(\".menu\");\n    this.positionController = new PositionController({\n      anchor: this.input,\n      overlay: this.suggestionList,\n      autoWidth: true,\n      placement: \"bottom-start\",\n    });\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    this.close();\n  }\n\n  handleFocus() {\n    this.isFocus = true;\n  }\n\n  handleBlur() {\n    this.isFocus = false;\n    this.close();\n  }\n\n  async handleInput(e) {\n    this.input = e.target;\n\n    const tags = await cache.getTags();\n    const word = getCurrentWord(this.input);\n\n    this.suggestions = word\n      ? tags.filter(\n          (tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,\n        )\n      : [];\n\n    if (word && this.suggestions.length > 0) {\n      this.open();\n    } else {\n      this.close();\n    }\n  }\n\n  handleKeyDown(e) {\n    if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {\n      const suggestion = this.suggestions[this.selectedIndex];\n      this.complete(suggestion);\n      e.preventDefault();\n    }\n    if (e.keyCode === 27) {\n      this.close();\n      e.preventDefault();\n    }\n    if (e.keyCode === 38) {\n      this.updateSelection(-1);\n      e.preventDefault();\n    }\n    if (e.keyCode === 40) {\n      this.updateSelection(1);\n      e.preventDefault();\n    }\n  }\n\n  open() {\n    this.isOpen = true;\n    this.selectedIndex = 0;\n    this.positionController.enable();\n  }\n\n  close() {\n    this.isOpen = false;\n    this.suggestions = [];\n    this.selectedIndex = 0;\n    this.positionController.disable();\n  }\n\n  complete(suggestion) {\n    const bounds = getCurrentWordBounds(this.input);\n    const value = this.input.value;\n    this.input.value =\n      value.substring(0, bounds.start) +\n      suggestion.name +\n      \" \" +\n      value.substring(bounds.end);\n    this.dispatchEvent(new CustomEvent(\"input\", { bubbles: true }));\n\n    this.close();\n  }\n\n  updateSelection(dir) {\n    const length = this.suggestions.length;\n    let newIndex = this.selectedIndex + dir;\n\n    if (newIndex < 0) newIndex = Math.max(length - 1, 0);\n    if (newIndex >= length) newIndex = 0;\n\n    this.selectedIndex = newIndex;\n\n    // Scroll to selected list item\n    setTimeout(() => {\n      if (this.suggestionList) {\n        const selectedListItem =\n          this.suggestionList.querySelector(\"li.selected\");\n        if (selectedListItem) {\n          selectedListItem.scrollIntoView({ block: \"center\" });\n        }\n      }\n    }, 0);\n  }\n\n  render() {\n    return html`\n      <div class=\"form-autocomplete ${this.variant === \"small\" ? \"small\" : \"\"}\">\n        <!-- autocomplete input container -->\n        <div\n          class=\"form-autocomplete-input form-input ${this.isFocus\n            ? \"is-focused\"\n            : \"\"}\"\n        >\n          <!-- autocomplete real input box -->\n          <input\n            id=\"${this.inputId || nothing}\"\n            name=\"${this.inputName || nothing}\"\n            .value=\"${this.inputValue || \"\"}\"\n            placeholder=\"${this.inputPlaceholder || \" \"}\"\n            class=\"form-input ${this.inputClass || \"\"}\"\n            type=\"text\"\n            autocomplete=\"off\"\n            autocapitalize=\"off\"\n            aria-describedby=\"${this.inputAriaDescribedBy || nothing}\"\n            @input=${this.handleInput}\n            @keydown=${this.handleKeyDown}\n            @focus=${this.handleFocus}\n            @blur=${this.handleBlur}\n          />\n        </div>\n\n        <!-- autocomplete suggestion list -->\n        <ul\n          class=\"menu ${this.isOpen && this.suggestions.length > 0\n            ? \"open\"\n            : \"\"}\"\n        >\n          <!-- menu list items -->\n          ${this.suggestions.map(\n            (tag, i) => html`\n              <li\n                class=\"menu-item ${this.selectedIndex === i ? \"selected\" : \"\"}\"\n              >\n                <a\n                  href=\"#\"\n                  @mousedown=${(e) => {\n                    e.preventDefault();\n                    this.complete(tag);\n                  }}\n                >\n                  ${tag.name}\n                </a>\n              </li>\n            `,\n          )}\n        </ul>\n      </div>\n    `;\n  }\n}\n\ncustomElements.define(\"ld-tag-autocomplete\", TagAutocomplete);\n"
  },
  {
    "path": "bookmarks/frontend/components/upload-button.js",
    "content": "import { HeadlessElement } from \"../utils/element.js\";\n\nclass UploadButton extends HeadlessElement {\n  init() {\n    this.onClick = this.onClick.bind(this);\n    this.onChange = this.onChange.bind(this);\n\n    this.button = this.querySelector('button[type=\"submit\"]');\n    this.button.addEventListener(\"click\", this.onClick);\n\n    this.fileInput = this.querySelector('input[type=\"file\"]');\n    this.fileInput.addEventListener(\"change\", this.onChange);\n  }\n\n  onClick(event) {\n    event.preventDefault();\n    this.fileInput.click();\n  }\n\n  onChange() {\n    // Check if the file input has a file selected\n    if (!this.fileInput.files.length) {\n      return;\n    }\n    this.closest(\"form\").requestSubmit(this.button);\n    // remove selected file so it doesn't get submitted again\n    this.fileInput.value = \"\";\n  }\n}\n\ncustomElements.define(\"ld-upload-button\", UploadButton);\n"
  },
  {
    "path": "bookmarks/frontend/index.js",
    "content": "import \"@hotwired/turbo\";\nimport \"./components/bookmark-page.js\";\nimport \"./components/clear-button.js\";\nimport \"./components/confirm-dropdown.js\";\nimport \"./components/details-modal.js\";\nimport \"./components/dev-tool.js\";\nimport \"./components/dropdown.js\";\nimport \"./components/filter-drawer.js\";\nimport \"./components/form.js\";\nimport \"./components/modal.js\";\nimport \"./components/search-autocomplete.js\";\nimport \"./components/tag-autocomplete.js\";\nimport \"./components/upload-button.js\";\nimport \"./shortcuts.js\";\n"
  },
  {
    "path": "bookmarks/frontend/shortcuts.js",
    "content": "document.addEventListener(\"keydown\", (event) => {\n  // Skip if event occurred within an input element\n  const targetNodeName = event.target.nodeName;\n  const isInputTarget =\n    targetNodeName === \"INPUT\" ||\n    targetNodeName === \"SELECT\" ||\n    targetNodeName === \"TEXTAREA\";\n\n  if (isInputTarget) {\n    return;\n  }\n\n  // Handle shortcuts for navigating bookmarks with arrow keys\n  const isArrowUp = event.key === \"ArrowUp\";\n  const isArrowDown = event.key === \"ArrowDown\";\n  if (isArrowUp || isArrowDown) {\n    event.preventDefault();\n\n    // Detect current bookmark list item\n    const items = [...document.querySelectorAll(\"ul.bookmark-list > li\")];\n    const path = event.composedPath();\n    const currentItem = path.find((item) => items.includes(item));\n\n    // Find next item\n    let nextItem;\n    if (currentItem) {\n      nextItem = isArrowUp\n        ? currentItem.previousElementSibling\n        : currentItem.nextElementSibling;\n    } else {\n      // Select first item\n      nextItem = items[0];\n    }\n    // Focus first link\n    if (nextItem) {\n      nextItem.querySelector(\"a\").focus();\n    }\n  }\n\n  // Handle shortcut for toggling all notes\n  if (event.key === \"e\") {\n    const list = document.querySelector(\".bookmark-list\");\n    if (list) {\n      list.classList.toggle(\"show-notes\");\n    }\n  }\n\n  // Handle shortcut for focusing search input\n  if (event.key === \"s\") {\n    const searchInput = document.querySelector('input[type=\"search\"]');\n\n    if (searchInput) {\n      searchInput.focus();\n      event.preventDefault();\n    }\n  }\n\n  // Handle shortcut for adding new bookmark\n  if (event.key === \"n\") {\n    window.location.assign(\"/bookmarks/new\");\n  }\n});\n"
  },
  {
    "path": "bookmarks/frontend/utils/element.js",
    "content": "import { LitElement } from \"lit\";\n\n/**\n * Base class for custom elements that wrap existing server-rendered DOM.\n *\n * Handles timing issues where connectedCallback fires before child elements\n * are parsed during initial page load. With Turbo navigation, children are\n * always available, but on fresh page loads they may not be.\n *\n * Subclasses should override init() instead of connectedCallback().\n */\nexport class HeadlessElement extends HTMLElement {\n  connectedCallback() {\n    if (this.__initialized) {\n      return;\n    }\n    this.__initialized = true;\n    if (document.readyState === \"loading\") {\n      document.addEventListener(\"turbo:load\", () => this.init(), {\n        once: true,\n      });\n    } else {\n      this.init();\n    }\n  }\n\n  init() {\n    // Override in subclass\n  }\n}\n\nlet isTopFrameVisit = false;\n\ndocument.addEventListener(\"turbo:visit\", (event) => {\n  const url = event.detail.url;\n  isTopFrameVisit =\n    document.querySelector(`turbo-frame[src=\"${url}\"][target=\"_top\"]`) !== null;\n});\n\ndocument.addEventListener(\"turbo:render\", () => {\n  isTopFrameVisit = false;\n});\n\ndocument.addEventListener(\"turbo:before-morph-element\", (event) => {\n  const parent = event.target?.parentElement;\n  if (parent instanceof TurboLitElement) {\n    // Prevent Turbo from morphing Lit elements contents, which would remove\n    // elements rendered on the client side.\n    event.preventDefault();\n  }\n});\n\nexport class TurboLitElement extends LitElement {\n  constructor() {\n    super();\n    this.__prepareForCache = this.__prepareForCache.bind(this);\n  }\n\n  createRenderRoot() {\n    return this; // Render to light DOM\n  }\n\n  connectedCallback() {\n    document.addEventListener(\"turbo:before-cache\", this.__prepareForCache);\n    super.connectedCallback();\n  }\n\n  disconnectedCallback() {\n    document.removeEventListener(\"turbo:before-cache\", this.__prepareForCache);\n    super.disconnectedCallback();\n  }\n\n  __prepareForCache() {\n    // Remove rendered contents before caching, otherwise restoring the DOM from\n    // cache will result in duplicated contents. Turbo also fires before-cache\n    // when rendering a frame that does target the top frame, in which case we\n    // want to keep the contents.\n    if (!isTopFrameVisit) {\n      this.innerHTML = \"\";\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/frontend/utils/focus.js",
    "content": "let keyboardActive = false;\n\nwindow.addEventListener(\n  \"keydown\",\n  () => {\n    keyboardActive = true;\n  },\n  { capture: true },\n);\n\nwindow.addEventListener(\n  \"mousedown\",\n  () => {\n    keyboardActive = false;\n  },\n  { capture: true },\n);\n\nexport function isKeyboardActive() {\n  return keyboardActive;\n}\n\nexport class FocusTrapController {\n  constructor(element) {\n    this.element = element;\n    this.focusableElements = this.element.querySelectorAll(\n      'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type=\"text\"]:not([disabled]), input[type=\"radio\"]:not([disabled]), input[type=\"checkbox\"]:not([disabled]), select:not([disabled])',\n    );\n    this.firstFocusableElement = this.focusableElements[0];\n    this.lastFocusableElement =\n      this.focusableElements[this.focusableElements.length - 1];\n\n    this.onKeyDown = this.onKeyDown.bind(this);\n\n    this.firstFocusableElement.focus({ focusVisible: keyboardActive });\n    this.element.addEventListener(\"keydown\", this.onKeyDown);\n  }\n\n  destroy() {\n    this.element.removeEventListener(\"keydown\", this.onKeyDown);\n  }\n\n  onKeyDown(event) {\n    if (event.key !== \"Tab\") {\n      return;\n    }\n    if (event.shiftKey) {\n      if (document.activeElement === this.firstFocusableElement) {\n        event.preventDefault();\n        this.lastFocusableElement.focus();\n      }\n    } else {\n      if (document.activeElement === this.lastFocusableElement) {\n        event.preventDefault();\n        this.firstFocusableElement.focus();\n      }\n    }\n  }\n}\n\nlet afterPageLoadFocusTarget = [];\nlet firstPageLoad = true;\n\nexport function setAfterPageLoadFocusTarget(...targets) {\n  afterPageLoadFocusTarget = targets;\n}\n\nfunction programmaticFocus(element) {\n  // Ensure element is focusable\n  // Hide focus outline if element is not focusable by default - might\n  // reconsider this later\n  const isFocusable = element.tabIndex >= 0;\n  if (!isFocusable) {\n    // Apparently the default tabIndex is -1, even though an element is still\n    // not focusable with that. Setting an explicit -1 also sets the attribute\n    // and the element becomes focusable.\n    element.tabIndex = -1;\n    // `focusVisible` is not supported in all browsers, so hide the outline manually\n    element.style[\"outline\"] = \"none\";\n  }\n  element.focus({\n    focusVisible: isKeyboardActive() && isFocusable,\n    preventScroll: true,\n  });\n}\n\n// Register global listener for navigation and try to focus an element that\n// results in a meaningful announcement.\ndocument.addEventListener(\"turbo:load\", () => {\n  // Ignore initial page load to let the browser handle announcements\n  if (firstPageLoad) {\n    firstPageLoad = false;\n    return;\n  }\n\n  // Ignore if there is a modal dialog, which should handle its own focus\n  const modal = document.querySelector(\"[aria-modal='true']\");\n  if (modal) {\n    return;\n  }\n\n  // Check if there is an explicit focus target for the next page load\n  for (const target of afterPageLoadFocusTarget) {\n    const element = document.querySelector(target);\n    if (element) {\n      programmaticFocus(element);\n      return;\n    }\n  }\n  afterPageLoadFocusTarget = [];\n\n  // If there is some autofocus element, let the browser handle it\n  const autofocus = document.querySelector(\"[autofocus]\");\n  if (autofocus) {\n    return;\n  }\n\n  // If there is a toast as a result of some action, focus it\n  const toast = document.querySelector(\".toast\");\n  if (toast) {\n    programmaticFocus(toast);\n    return;\n  }\n\n  // Otherwise go with main\n  const main = document.querySelector(\"main\");\n  if (main) {\n    programmaticFocus(main);\n  }\n});\n"
  },
  {
    "path": "bookmarks/frontend/utils/input.js",
    "content": "export function debounce(callback, delay = 250) {\n  let timeoutId;\n  return (...args) => {\n    clearTimeout(timeoutId);\n    timeoutId = setTimeout(() => {\n      timeoutId = null;\n      callback(...args);\n    }, delay);\n  };\n}\n\nexport function clampText(text, maxChars = 30) {\n  if (!text || text.length <= 30) return text;\n\n  return text.substr(0, maxChars) + \"...\";\n}\n\nexport function getCurrentWordBounds(input) {\n  const text = input.value;\n  const end = input.selectionStart;\n  let start = end;\n\n  let currentChar = text.charAt(start - 1);\n\n  while (currentChar && currentChar !== \" \" && start > 0) {\n    start--;\n    currentChar = text.charAt(start - 1);\n  }\n\n  return { start, end };\n}\n\nexport function getCurrentWord(input) {\n  const bounds = getCurrentWordBounds(input);\n\n  return input.value.substring(bounds.start, bounds.end);\n}\n"
  },
  {
    "path": "bookmarks/frontend/utils/position-controller.js",
    "content": "import {\n  arrow,\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n} from \"@floating-ui/dom\";\n\nexport class PositionController {\n  constructor(options) {\n    this.anchor = options.anchor;\n    this.overlay = options.overlay;\n    this.arrow = options.arrow;\n    this.placement = options.placement || \"bottom\";\n    this.offset = options.offset;\n    this.autoWidth = options.autoWidth || false;\n    this.autoUpdateCleanup = null;\n  }\n\n  enable() {\n    if (!this.autoUpdateCleanup) {\n      this.autoUpdateCleanup = autoUpdate(this.anchor, this.overlay, () =>\n        this.updatePosition(),\n      );\n    }\n  }\n\n  disable() {\n    if (this.autoUpdateCleanup) {\n      this.autoUpdateCleanup();\n      this.autoUpdateCleanup = null;\n    }\n  }\n\n  updatePosition() {\n    const middleware = [flip(), shift()];\n    if (this.arrow) {\n      middleware.push(arrow({ element: this.arrow }));\n    }\n    if (this.offset) {\n      middleware.push(offset(this.offset));\n    }\n    computePosition(this.anchor, this.overlay, {\n      placement: this.placement,\n      strategy: \"fixed\",\n      middleware,\n    }).then(({ x, y, placement, middlewareData }) => {\n      Object.assign(this.overlay.style, {\n        left: `${x}px`,\n        top: `${y}px`,\n      });\n\n      this.overlay.classList.remove(\"top-aligned\", \"bottom-aligned\");\n      this.overlay.classList.add(`${placement}-aligned`);\n\n      if (this.arrow) {\n        const { x, y } = middlewareData.arrow;\n        Object.assign(this.arrow.style, {\n          left: x != null ? `${x}px` : \"\",\n          top: y != null ? `${y}px` : \"\",\n        });\n      }\n    });\n\n    if (this.autoWidth) {\n      const width = this.anchor.offsetWidth;\n      this.overlay.style.width = `${width}px`;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/frontend/utils/search-history.js",
    "content": "const SEARCH_HISTORY_KEY = \"searchHistory\";\nconst MAX_ENTRIES = 30;\n\nexport class SearchHistory {\n  getHistory() {\n    const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY);\n    return historyJson\n      ? JSON.parse(historyJson)\n      : {\n          recent: [],\n        };\n  }\n\n  pushCurrent() {\n    // Skip if browser is not compatible\n    if (!window.URLSearchParams) return;\n    const urlParams = new URLSearchParams(window.location.search);\n    const searchParam = urlParams.get(\"q\");\n\n    if (!searchParam) return;\n\n    this.push(searchParam);\n  }\n\n  push(search) {\n    const history = this.getHistory();\n\n    history.recent.unshift(search);\n\n    // Remove duplicates and clamp to max entries\n    history.recent = history.recent.reduce((acc, cur) => {\n      if (acc.length >= MAX_ENTRIES) return acc;\n      if (acc.indexOf(cur) >= 0) return acc;\n      acc.push(cur);\n      return acc;\n    }, []);\n\n    const newHistoryJson = JSON.stringify(history);\n    localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson);\n  }\n\n  getRecentSearches(query, max) {\n    const history = this.getHistory();\n\n    return history.recent\n      .filter(\n        (search) =>\n          !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0,\n      )\n      .slice(0, max);\n  }\n}\n"
  },
  {
    "path": "bookmarks/frontend/utils/tag-cache.js",
    "content": "import { api } from \"../api.js\";\n\nclass TagCache {\n  constructor(api) {\n    this.api = api;\n\n    // Reset cached tags after a form submission\n    document.addEventListener(\"turbo:submit-end\", () => {\n      this.tagsPromise = null;\n    });\n  }\n\n  getTags() {\n    if (!this.tagsPromise) {\n      this.tagsPromise = this.api\n        .getTags({\n          limit: 5000,\n          offset: 0,\n        })\n        .then((tags) =>\n          tags.sort((left, right) =>\n            left.name.toLowerCase().localeCompare(right.name.toLowerCase()),\n          ),\n        )\n        .catch((e) => {\n          console.warn(\"Cache: Error loading tags\", e);\n          return [];\n        });\n    }\n\n    return this.tagsPromise;\n  }\n}\n\nexport const cache = new TagCache(api);\n"
  },
  {
    "path": "bookmarks/management/commands/backup.py",
    "content": "import os\nimport sqlite3\n\nfrom django.core.management.base import BaseCommand\n\n\nclass Command(BaseCommand):\n    help = \"Creates a backup of the linkding database\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"destination\", type=str, help=\"Backup file destination\")\n\n    def handle(self, *args, **options):\n        destination = options[\"destination\"]\n\n        def progress(status, remaining, total):\n            self.stdout.write(f\"Copied {total - remaining} of {total} pages...\")\n\n        source_db = sqlite3.connect(os.path.join(\"data\", \"db.sqlite3\"))\n        backup_db = sqlite3.connect(destination)\n        with backup_db:\n            source_db.backup(backup_db, pages=50, progress=progress)\n        backup_db.close()\n        source_db.close()\n\n        self.stdout.write(self.style.SUCCESS(f\"Backup created at {destination}\"))\n        self.stdout.write(\n            self.style.WARNING(\n                \"This backup method is deprecated and may be removed in the future. Please use the full_backup command instead, which creates backup zip file with all contents of the data folder.\"\n            )\n        )\n"
  },
  {
    "path": "bookmarks/management/commands/create_initial_superuser.py",
    "content": "import logging\nimport os\n\nfrom django.contrib.auth import get_user_model\nfrom django.core.management.base import BaseCommand\n\nlogger = logging.getLogger(__name__)\n\n\nclass Command(BaseCommand):\n    help = \"Creates an initial superuser for a deployment using env variables\"\n\n    def handle(self, *args, **options):\n        User = get_user_model()\n        superuser_name = os.getenv(\"LD_SUPERUSER_NAME\", None)\n        superuser_password = os.getenv(\"LD_SUPERUSER_PASSWORD\", None)\n\n        # Skip if option is undefined\n        if not superuser_name:\n            logger.info(\n                \"Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined\"\n            )\n            return\n\n        # Skip if user already exists\n        user_exists = User.objects.filter(username=superuser_name).exists()\n        if user_exists:\n            logger.info(\"Skip creating initial superuser, user already exists\")\n            return\n\n        user = User(username=superuser_name, is_superuser=True, is_staff=True)\n\n        if superuser_password:\n            user.set_password(superuser_password)\n        else:\n            user.set_unusable_password()\n\n        user.save()\n        logger.info(\"Created initial superuser\")\n"
  },
  {
    "path": "bookmarks/management/commands/enable_wal.py",
    "content": "import logging\n\nfrom django.conf import settings\nfrom django.core.management.base import BaseCommand\nfrom django.db import connections\n\nlogger = logging.getLogger(__name__)\n\n\nclass Command(BaseCommand):\n    help = \"Enable WAL journal mode when using an SQLite database\"\n\n    def handle(self, *args, **options):\n        if not settings.USE_SQLITE:\n            return\n\n        connection = connections[\"default\"]\n        with connection.cursor() as cursor:\n            cursor.execute(\"PRAGMA journal_mode\")\n            current_mode = cursor.fetchone()[0]\n            logger.info(f\"Current journal mode: {current_mode}\")\n            if current_mode != \"wal\":\n                cursor.execute(\"PRAGMA journal_mode=wal;\")\n                logger.info(\"Switched to WAL journal mode\")\n"
  },
  {
    "path": "bookmarks/management/commands/ensure_superuser.py",
    "content": "from django.contrib.auth import get_user_model\nfrom django.core.management.base import BaseCommand\n\n\nclass Command(BaseCommand):\n    help = \"Creates an admin user non-interactively if it doesn't exist\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"--username\", help=\"Admin's username\")\n        parser.add_argument(\"--email\", help=\"Admin's email\")\n        parser.add_argument(\"--password\", help=\"Admin's password\")\n\n    def handle(self, *args, **options):\n        User = get_user_model()\n        if not User.objects.filter(username=options[\"username\"]).exists():\n            User.objects.create_superuser(\n                username=options[\"username\"],\n                email=options[\"email\"],\n                password=options[\"password\"],\n            )\n"
  },
  {
    "path": "bookmarks/management/commands/full_backup.py",
    "content": "import os\nimport sqlite3\nimport tempfile\nimport zipfile\n\nfrom django.core.management.base import BaseCommand\n\n\nclass Command(BaseCommand):\n    help = \"Creates a backup of the linkding data folder\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"backup_file\", type=str, help=\"Backup zip file destination\")\n\n    def handle(self, *args, **options):\n        backup_file = options[\"backup_file\"]\n        with zipfile.ZipFile(backup_file, \"w\", zipfile.ZIP_DEFLATED) as zip_file:\n            # Backup the database\n            self.stdout.write(\"Create database backup...\")\n            with tempfile.TemporaryDirectory() as temp_dir:\n                backup_db_file = os.path.join(temp_dir, \"db.sqlite3\")\n                self.backup_database(backup_db_file)\n                zip_file.write(backup_db_file, \"db.sqlite3\")\n\n            # Backup the assets folder\n            if not os.path.exists(os.path.join(\"data\", \"assets\")):\n                self.stdout.write(\n                    self.style.WARNING(\"No assets folder found. Skipping...\")\n                )\n            else:\n                self.stdout.write(\"Backup bookmark assets...\")\n                assets_folder = os.path.join(\"data\", \"assets\")\n                for root, _, files in os.walk(assets_folder):\n                    for file in files:\n                        file_path = os.path.join(root, file)\n                        zip_file.write(file_path, os.path.join(\"assets\", file))\n\n            # Backup the favicons folder\n            if not os.path.exists(os.path.join(\"data\", \"favicons\")):\n                self.stdout.write(\n                    self.style.WARNING(\"No favicons folder found. Skipping...\")\n                )\n            else:\n                self.stdout.write(\"Backup bookmark favicons...\")\n                favicons_folder = os.path.join(\"data\", \"favicons\")\n                for root, _, files in os.walk(favicons_folder):\n                    for file in files:\n                        file_path = os.path.join(root, file)\n                        zip_file.write(file_path, os.path.join(\"favicons\", file))\n\n            # Backup the previews folder\n            if not os.path.exists(os.path.join(\"data\", \"previews\")):\n                self.stdout.write(\n                    self.style.WARNING(\"No previews folder found. Skipping...\")\n                )\n            else:\n                self.stdout.write(\"Backup bookmark previews...\")\n                previews_folder = os.path.join(\"data\", \"previews\")\n                for root, _, files in os.walk(previews_folder):\n                    for file in files:\n                        file_path = os.path.join(root, file)\n                        zip_file.write(file_path, os.path.join(\"previews\", file))\n\n        self.stdout.write(self.style.SUCCESS(f\"Backup created at {backup_file}\"))\n\n    def backup_database(self, backup_db_file):\n        def progress(status, remaining, total):\n            self.stdout.write(f\"Copied {total - remaining} of {total} pages...\")\n\n        source_db = sqlite3.connect(os.path.join(\"data\", \"db.sqlite3\"))\n        backup_db = sqlite3.connect(backup_db_file)\n        with backup_db:\n            source_db.backup(backup_db, pages=50, progress=progress)\n        backup_db.close()\n        source_db.close()\n"
  },
  {
    "path": "bookmarks/management/commands/generate_secret_key.py",
    "content": "import logging\nimport os\n\nfrom django.core.management.base import BaseCommand\nfrom django.core.management.utils import get_random_secret_key\n\nlogger = logging.getLogger(__name__)\n\n\nclass Command(BaseCommand):\n    help = \"Generate secret key file if it does not exist\"\n\n    def handle(self, *args, **options):\n        secret_key_file = os.path.join(\"data\", \"secretkey.txt\")\n\n        if os.path.exists(secret_key_file):\n            logger.info(\"Secret key file already exists\")\n            return\n\n        secret_key = get_random_secret_key()\n        with open(secret_key_file, \"w\") as f:\n            f.write(secret_key)\n        logger.info(\"Generated secret key file\")\n"
  },
  {
    "path": "bookmarks/management/commands/import_netscape.py",
    "content": "from django.contrib.auth.models import User\nfrom django.core.management.base import BaseCommand\n\nfrom bookmarks.services.importer import import_netscape_html\n\n\nclass Command(BaseCommand):\n    help = \"Import Netscape HTML bookmark file\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"file\", type=str, help=\"Path to file\")\n        parser.add_argument(\n            \"user\", type=str, help=\"Name of the user for which to import\"\n        )\n\n    def handle(self, *args, **kwargs):\n        filepath = kwargs[\"file\"]\n        username = kwargs[\"user\"]\n        with open(filepath) as html_file:\n            html = html_file.read()\n        user = User.objects.get(username=username)\n\n        import_netscape_html(html, user)\n"
  },
  {
    "path": "bookmarks/management/commands/migrate_tasks.py",
    "content": "import importlib\nimport json\nimport os\nimport sqlite3\n\nfrom django.core.management.base import BaseCommand\n\n\nclass Command(BaseCommand):\n    help = \"Migrate tasks from django-background-tasks to Huey\"\n\n    def handle(self, *args, **options):\n        db = sqlite3.connect(os.path.join(\"data\", \"db.sqlite3\"))\n\n        # Check if background_task table exists\n        cursor = db.cursor()\n        cursor.execute(\n            \"SELECT name FROM sqlite_master WHERE type='table' AND name='background_task'\"\n        )\n        row = cursor.fetchone()\n        if not row:\n            self.stdout.write(\n                \"Legacy task table does not exist. Skipping task migration\"\n            )\n            return\n\n        # Load legacy tasks\n        cursor.execute(\"SELECT id, task_name, task_params FROM background_task\")\n        legacy_tasks = cursor.fetchall()\n\n        if len(legacy_tasks) == 0:\n            self.stdout.write(\"No legacy tasks found. Skipping task migration\")\n            return\n\n        self.stdout.write(\n            f\"Found {len(legacy_tasks)} legacy tasks. Migrating to Huey...\"\n        )\n\n        # Migrate tasks to Huey\n        succeeded_tasks = []\n        for task in legacy_tasks:\n            task_id = task[0]\n            task_name = task[1]\n            task_params_json = task[2]\n            try:\n                task_params = json.loads(task_params_json)\n                function_params = task_params[0]\n\n                # Resolve task function\n                module_name, func_name = task_name.rsplit(\".\", 1)\n                module = importlib.import_module(module_name)\n                func = getattr(module, func_name)\n\n                # Call task function\n                func(*function_params)\n                succeeded_tasks.append(task_id)\n            except Exception:\n                self.stderr.write(f\"Error migrating task [{task_id}] {task_name}\")\n\n        self.stdout.write(f\"Migrated {len(succeeded_tasks)} tasks successfully\")\n\n        # Clean up\n        try:\n            placeholders = \", \".join(\"?\" for _ in succeeded_tasks)\n            sql = f\"DELETE FROM background_task WHERE id IN ({placeholders})\"\n            cursor.execute(sql, succeeded_tasks)\n            db.commit()\n            self.stdout.write(\n                f\"Deleted {len(succeeded_tasks)} migrated tasks from legacy table\"\n            )\n        except Exception:\n            self.stderr.write(\"Error cleaning up legacy tasks\")\n\n        cursor.close()\n        db.close()\n"
  },
  {
    "path": "bookmarks/middlewares.py",
    "content": "from django.conf import settings\nfrom django.contrib.auth.middleware import RemoteUserMiddleware\n\nfrom bookmarks.models import GlobalSettings, UserProfile\n\n\nclass CustomRemoteUserMiddleware(RemoteUserMiddleware):\n    header = settings.LD_AUTH_PROXY_USERNAME_HEADER\n\n\ndefault_global_settings = GlobalSettings()\n\nstandard_profile = UserProfile()\nstandard_profile.enable_favicons = True\n\n\nclass LinkdingMiddleware:\n    def __init__(self, get_response):\n        self.get_response = get_response\n\n    def __call__(self, request):\n        # add global settings to request\n        try:\n            global_settings = GlobalSettings.get()\n        except Exception:\n            global_settings = default_global_settings\n        request.global_settings = global_settings\n\n        # add user profile to request\n        if request.user.is_authenticated:\n            request.user_profile = request.user.profile\n        else:\n            # check if a custom profile for guests exists, otherwise use standard profile\n            if global_settings.guest_profile_user:\n                request.user_profile = global_settings.guest_profile_user.profile\n            else:\n                request.user_profile = standard_profile\n\n        response = self.get_response(request)\n\n        return response\n"
  },
  {
    "path": "bookmarks/migrations/0001_initial.py",
    "content": "# Generated by Django 2.2.2 on 2019-06-28 23:49\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Bookmark\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"url\", models.URLField()),\n                (\"title\", models.CharField(max_length=512)),\n                (\"description\", models.TextField()),\n                (\n                    \"website_title\",\n                    models.CharField(blank=True, max_length=512, null=True),\n                ),\n                (\"website_description\", models.TextField(blank=True, null=True)),\n                (\"unread\", models.BooleanField(default=True)),\n                (\"date_added\", models.DateTimeField()),\n                (\"date_modified\", models.DateTimeField()),\n                (\"date_accessed\", models.DateTimeField(blank=True, null=True)),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0002_auto_20190629_2303.py",
    "content": "# Generated by Django 2.2.2 on 2019-06-29 23:03\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"bookmarks\", \"0001_initial\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Tag\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"name\", models.CharField(max_length=64)),\n                (\"date_added\", models.DateTimeField()),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"tags\",\n            field=models.ManyToManyField(to=\"bookmarks.Tag\"),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0003_auto_20200913_0656.py",
    "content": "# Generated by Django 2.2.13 on 2020-09-13 06:56\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0002_auto_20190629_2303\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"bookmark\",\n            name=\"url\",\n            field=models.URLField(max_length=2048),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0004_auto_20200926_1028.py",
    "content": "# Generated by Django 2.2.13 on 2020-09-26 10:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0003_auto_20200913_0656\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"bookmark\",\n            name=\"description\",\n            field=models.TextField(blank=True),\n        ),\n        migrations.AlterField(\n            model_name=\"bookmark\",\n            name=\"title\",\n            field=models.CharField(blank=True, max_length=512),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0005_auto_20210103_1212.py",
    "content": "# Generated by Django 2.2.13 on 2021-01-03 12:12\n\nfrom django.db import migrations, models\n\nimport bookmarks.validators\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0004_auto_20200926_1028\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"bookmark\",\n            name=\"url\",\n            field=models.CharField(\n                max_length=2048,\n                validators=[bookmarks.validators.BookmarkURLValidator()],\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0006_bookmark_is_archived.py",
    "content": "# Generated by Django 2.2.13 on 2021-02-14 09:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0005_auto_20210103_1212\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"is_archived\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0007_userprofile.py",
    "content": "# Generated by Django 2.2.18 on 2021-03-26 22:39\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\ndef forwards(apps, schema_editor):\n    User = apps.get_model(\"auth\", \"User\")\n    UserProfile = apps.get_model(\"bookmarks\", \"UserProfile\")\n    for user in User.objects.all():\n        try:\n            if user.profile:\n                continue\n        except UserProfile.DoesNotExist:\n            profile = UserProfile(user=user)\n            profile.save()\n\n\ndef reverse(apps, schema_editor):\n    pass\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"bookmarks\", \"0006_bookmark_is_archived\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"UserProfile\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"theme\",\n                    models.CharField(\n                        choices=[\n                            (\"auto\", \"Auto\"),\n                            (\"light\", \"Light\"),\n                            (\"dark\", \"Dark\"),\n                        ],\n                        default=\"auto\",\n                        max_length=10,\n                    ),\n                ),\n                (\n                    \"user\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        related_name=\"profile\",\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0008_userprofile_bookmark_date_display.py",
    "content": "# Generated by Django 2.2.18 on 2021-03-30 10:40\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0007_userprofile\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"bookmark_date_display\",\n            field=models.CharField(\n                choices=[\n                    (\"relative\", \"Relative\"),\n                    (\"absolute\", \"Absolute\"),\n                    (\"hidden\", \"Hidden\"),\n                ],\n                default=\"relative\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0009_bookmark_web_archive_snapshot_url.py",
    "content": "# Generated by Django 2.2.20 on 2021-05-16 14:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0008_userprofile_bookmark_date_display\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"web_archive_snapshot_url\",\n            field=models.CharField(blank=True, max_length=2048),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0010_userprofile_bookmark_link_target.py",
    "content": "# Generated by Django 3.2.6 on 2021-10-03 06:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0009_bookmark_web_archive_snapshot_url\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"bookmark_link_target\",\n            field=models.CharField(\n                choices=[(\"_blank\", \"New page\"), (\"_self\", \"Same page\")],\n                default=\"_blank\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0011_userprofile_web_archive_integration.py",
    "content": "# Generated by Django 3.2.6 on 2022-01-08 12:39\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0010_userprofile_bookmark_link_target\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"web_archive_integration\",\n            field=models.CharField(\n                choices=[(\"disabled\", \"Disabled\"), (\"enabled\", \"Enabled\")],\n                default=\"disabled\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0012_toast.py",
    "content": "# Generated by Django 3.2.6 on 2022-01-08 19:24\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"bookmarks\", \"0011_userprofile_web_archive_integration\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Toast\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"key\", models.CharField(max_length=50)),\n                (\"message\", models.TextField()),\n                (\"acknowledged\", models.BooleanField(default=False)),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0013_web_archive_optin_toast.py",
    "content": "# Generated by Django 3.2.6 on 2022-01-08 19:27\n\nfrom django.contrib.auth import get_user_model\nfrom django.db import migrations\n\nfrom bookmarks.models import Toast\n\nUser = get_user_model()\n\n\ndef forwards(apps, schema_editor):\n    for user in User.objects.all():\n        toast = Toast(\n            key=\"web_archive_opt_in_hint\",\n            message=\"The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.\",\n            owner=user,\n        )\n        toast.save()\n\n\ndef reverse(apps, schema_editor):\n    Toast.objects.filter(key=\"web_archive_opt_in_hint\").delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0012_toast\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0014_alter_bookmark_unread.py",
    "content": "# Generated by Django 3.2.13 on 2022-07-23 12:30\n\nfrom django.db import migrations, models\n\n\ndef forwards(apps, schema_editor):\n    Bookmark = apps.get_model(\"bookmarks\", \"Bookmark\")\n    Bookmark.objects.update(unread=False)\n\n\ndef reverse(apps, schema_editor):\n    pass\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0013_web_archive_optin_toast\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"bookmark\",\n            name=\"unread\",\n            field=models.BooleanField(default=False),\n        ),\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0015_feedtoken.py",
    "content": "# Generated by Django 3.2.13 on 2022-07-23 20:35\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"bookmarks\", \"0014_alter_bookmark_unread\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"FeedToken\",\n            fields=[\n                (\n                    \"key\",\n                    models.CharField(max_length=40, primary_key=True, serialize=False),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\n                    \"user\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        related_name=\"feed_token\",\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0016_bookmark_shared.py",
    "content": "# Generated by Django 3.2.14 on 2022-08-02 18:42\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0015_feedtoken\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"shared\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0017_userprofile_enable_sharing.py",
    "content": "# Generated by Django 3.2.14 on 2022-08-04 09:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0016_bookmark_shared\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"enable_sharing\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0018_bookmark_favicon_file.py",
    "content": "# Generated by Django 4.1 on 2023-01-07 23:42\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0017_userprofile_enable_sharing\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"favicon_file\",\n            field=models.CharField(blank=True, max_length=512),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0019_userprofile_enable_favicons.py",
    "content": "# Generated by Django 4.1 on 2023-01-09 21:16\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0018_bookmark_favicon_file\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"enable_favicons\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0020_userprofile_tag_search.py",
    "content": "# Generated by Django 4.1.7 on 2023-04-10 01:55\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0019_userprofile_enable_favicons\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"tag_search\",\n            field=models.CharField(\n                choices=[(\"strict\", \"Strict\"), (\"lax\", \"Lax\")],\n                default=\"strict\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0021_userprofile_display_url.py",
    "content": "# Generated by Django 4.1.7 on 2023-05-18 07:58\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0020_userprofile_tag_search\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"display_url\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0022_bookmark_notes.py",
    "content": "# Generated by Django 4.1.7 on 2023-05-19 10:52\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0021_userprofile_display_url\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"notes\",\n            field=models.TextField(blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0023_userprofile_permanent_notes.py",
    "content": "# Generated by Django 4.1.9 on 2023-05-20 08:00\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0022_bookmark_notes\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"permanent_notes\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0024_userprofile_enable_public_sharing.py",
    "content": "# Generated by Django 4.1.9 on 2023-08-14 07:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0023_userprofile_permanent_notes\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"enable_public_sharing\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0025_userprofile_search_preferences.py",
    "content": "# Generated by Django 4.1.9 on 2023-09-30 10:44\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0024_userprofile_enable_public_sharing\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"search_preferences\",\n            field=models.JSONField(default=dict),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0026_userprofile_custom_css.py",
    "content": "# Generated by Django 5.0.2 on 2024-03-16 23:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0025_userprofile_search_preferences\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"custom_css\",\n            field=models.TextField(blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0027_userprofile_bookmark_description_display_and_more.py",
    "content": "# Generated by Django 5.0.2 on 2024-03-23 21:48\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0026_userprofile_custom_css\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"bookmark_description_display\",\n            field=models.CharField(\n                choices=[(\"inline\", \"Inline\"), (\"separate\", \"Separate\")],\n                default=\"inline\",\n                max_length=10,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"bookmark_description_max_lines\",\n            field=models.IntegerField(default=1),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py",
    "content": "# Generated by Django 5.0.2 on 2024-03-29 20:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0027_userprofile_bookmark_description_display_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"display_archive_bookmark_action\",\n            field=models.BooleanField(default=True),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"display_edit_bookmark_action\",\n            field=models.BooleanField(default=True),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"display_remove_bookmark_action\",\n            field=models.BooleanField(default=True),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"display_view_bookmark_action\",\n            field=models.BooleanField(default=True),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0029_bookmark_list_actions_toast.py",
    "content": "# Generated by Django 5.0.2 on 2024-03-29 21:25\n\nfrom django.contrib.auth import get_user_model\nfrom django.db import migrations\n\nfrom bookmarks.models import Toast\n\nUser = get_user_model()\n\n\ndef forwards(apps, schema_editor):\n    for user in User.objects.all():\n        toast = Toast(\n            key=\"bookmark_list_actions_hint\",\n            message=\"This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.\",\n            owner=user,\n        )\n        toast.save()\n\n\ndef reverse(apps, schema_editor):\n    Toast.objects.filter(key=\"bookmark_list_actions_hint\").delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0028_userprofile_display_archive_bookmark_action_and_more\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0030_bookmarkasset.py",
    "content": "# Generated by Django 5.0.2 on 2024-03-31 08:21\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0029_bookmark_list_actions_toast\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"BookmarkAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"date_created\", models.DateTimeField(auto_now_add=True)),\n                (\"file\", models.CharField(blank=True, max_length=2048)),\n                (\"file_size\", models.IntegerField(null=True)),\n                (\"asset_type\", models.CharField(max_length=64)),\n                (\"content_type\", models.CharField(max_length=128)),\n                (\"display_name\", models.CharField(blank=True, max_length=2048)),\n                (\"status\", models.CharField(max_length=64)),\n                (\"gzip\", models.BooleanField(default=False)),\n                (\n                    \"bookmark\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"bookmarks.bookmark\",\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0031_userprofile_enable_automatic_html_snapshots.py",
    "content": "# Generated by Django 5.0.2 on 2024-04-01 10:29\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0030_bookmarkasset\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"enable_automatic_html_snapshots\",\n            field=models.BooleanField(default=True),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0032_html_snapshots_hint_toast.py",
    "content": "# Generated by Django 5.0.2 on 2024-04-01 12:17\n\nfrom django.contrib.auth import get_user_model\nfrom django.db import migrations\n\nfrom bookmarks.models import Toast\n\nUser = get_user_model()\n\n\ndef forwards(apps, schema_editor):\n    for user in User.objects.all():\n        toast = Toast(\n            key=\"html_snapshots_hint\",\n            message=\"This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.\",\n            owner=user,\n        )\n        toast.save()\n\n\ndef reverse(apps, schema_editor):\n    Toast.objects.filter(key=\"bookmark_list_actions_hint\").delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0031_userprofile_enable_automatic_html_snapshots\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0033_userprofile_default_mark_unread.py",
    "content": "# Generated by Django 5.0.3 on 2024-04-17 19:27\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0032_html_snapshots_hint_toast\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"default_mark_unread\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0034_bookmark_preview_image_file_and_more.py",
    "content": "# Generated by Django 5.0.3 on 2024-05-10 07:01\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0033_userprofile_default_mark_unread\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"preview_image_file\",\n            field=models.CharField(blank=True, max_length=512),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"enable_preview_images\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0035_userprofile_tag_grouping.py",
    "content": "# Generated by Django 5.0.3 on 2024-05-14 08:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0034_bookmark_preview_image_file_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"tag_grouping\",\n            field=models.CharField(\n                choices=[(\"alphabetical\", \"Alphabetical\"), (\"disabled\", \"Disabled\")],\n                default=\"alphabetical\",\n                max_length=12,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0036_userprofile_auto_tagging_rules.py",
    "content": "# Generated by Django 5.0.3 on 2024-05-17 07:09\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0035_userprofile_tag_grouping\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"auto_tagging_rules\",\n            field=models.TextField(blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0037_globalsettings.py",
    "content": "# Generated by Django 5.0.8 on 2024-08-31 12:39\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0036_userprofile_auto_tagging_rules\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"GlobalSettings\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"landing_page\",\n                    models.CharField(\n                        choices=[\n                            (\"login\", \"Login\"),\n                            (\"shared_bookmarks\", \"Shared Bookmarks\"),\n                        ],\n                        default=\"login\",\n                        max_length=50,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0038_globalsettings_guest_profile_user.py",
    "content": "# Generated by Django 5.0.8 on 2024-08-31 17:54\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0037_globalsettings\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"globalsettings\",\n            name=\"guest_profile_user\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0039_globalsettings_enable_link_prefetch.py",
    "content": "# Generated by Django 5.0.8 on 2024-09-14 07:48\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0038_globalsettings_guest_profile_user\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"globalsettings\",\n            name=\"enable_link_prefetch\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0040_userprofile_items_per_page_and_more.py",
    "content": "# Generated by Django 5.0.8 on 2024-09-18 20:11\n\nimport django.core.validators\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0039_globalsettings_enable_link_prefetch\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"items_per_page\",\n            field=models.IntegerField(\n                default=30, validators=[django.core.validators.MinValueValidator(10)]\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"sticky_pagination\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0041_merge_metadata.py",
    "content": "# Generated by Django 5.1.1 on 2024-09-21 08:13\n\nfrom django.db import migrations\nfrom django.db.models import Q\nfrom django.db.models.expressions import RawSQL\n\nfrom bookmarks.models import Bookmark\n\n\ndef forwards(apps, schema_editor):\n    Bookmark.objects.filter(\n        Q(title__isnull=True) | Q(title__exact=\"\"),\n    ).extra(where=[\"website_title IS NOT NULL\"]).update(\n        title=RawSQL(\"website_title\", ())\n    )\n\n    Bookmark.objects.filter(\n        Q(description__isnull=True) | Q(description__exact=\"\"),\n    ).extra(where=[\"website_description IS NOT NULL\"]).update(\n        description=RawSQL(\"website_description\", ())\n    )\n\n\ndef reverse(apps, schema_editor):\n    pass\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0040_userprofile_items_per_page_and_more\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0042_userprofile_custom_css_hash.py",
    "content": "# Generated by Django 5.1.1 on 2024-09-28 08:03\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0041_merge_metadata\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"custom_css_hash\",\n            field=models.CharField(blank=True, max_length=32),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0043_userprofile_collapse_side_panel.py",
    "content": "# Generated by Django 5.1.5 on 2025-02-02 09:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0042_userprofile_custom_css_hash\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"collapse_side_panel\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0044_bookmark_latest_snapshot.py",
    "content": "# Generated by Django 5.1.7 on 2025-03-22 12:28\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\nfrom django.db.models import OuterRef, Subquery\n\n\ndef forwards(apps, schema_editor):\n    # Update the latest snapshot for each bookmark\n    Bookmark = apps.get_model(\"bookmarks\", \"bookmark\")\n    BookmarkAsset = apps.get_model(\"bookmarks\", \"bookmarkasset\")\n\n    latest_snapshots = (\n        BookmarkAsset.objects.filter(\n            bookmark=OuterRef(\"pk\"), asset_type=\"snapshot\", status=\"complete\"\n        )\n        .order_by(\"-date_created\")\n        .values(\"id\")[:1]\n    )\n    Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))\n\n\ndef reverse(apps, schema_editor):\n    pass\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0043_userprofile_collapse_side_panel\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"latest_snapshot\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"latest_snapshot\",\n                to=\"bookmarks.bookmarkasset\",\n            ),\n        ),\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py",
    "content": "# Generated by Django 5.1.9 on 2025-06-19 08:48\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0044_bookmark_latest_snapshot\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"hide_bundles\",\n            field=models.BooleanField(default=False),\n        ),\n        migrations.CreateModel(\n            name=\"BookmarkBundle\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"name\", models.CharField(max_length=256)),\n                (\"search\", models.CharField(blank=True, max_length=256)),\n                (\"any_tags\", models.CharField(blank=True, max_length=1024)),\n                (\"all_tags\", models.CharField(blank=True, max_length=1024)),\n                (\"excluded_tags\", models.CharField(blank=True, max_length=1024)),\n                (\"order\", models.IntegerField(default=0)),\n                (\"date_created\", models.DateTimeField(auto_now_add=True)),\n                (\"date_modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"owner\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0046_add_url_normalized_field.py",
    "content": "# Generated by Django 5.2.3 on 2025-08-22 08:26\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0045_userprofile_hide_bundles_bookmarkbundle\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmark\",\n            name=\"url_normalized\",\n            field=models.CharField(blank=True, db_index=True, max_length=2048),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0047_populate_url_normalized_field.py",
    "content": "# Generated by Django 5.2.3 on 2025-08-22 08:28\n\nfrom django.db import migrations, transaction\n\nfrom bookmarks.utils import normalize_url\n\n\ndef populate_url_normalized(apps, schema_editor):\n    Bookmark = apps.get_model(\"bookmarks\", \"Bookmark\")\n\n    batch_size = 500\n    with transaction.atomic():\n        qs = Bookmark.objects.all()\n        for start in range(0, qs.count(), batch_size):\n            batch = list(qs[start : start + batch_size])\n            for bookmark in batch:\n                bookmark.url_normalized = normalize_url(bookmark.url)\n            Bookmark.objects.bulk_update(\n                batch, [\"url_normalized\"], batch_size=batch_size\n            )\n\n\ndef reverse_populate_url_normalized(apps, schema_editor):\n    Bookmark = apps.get_model(\"bookmarks\", \"Bookmark\")\n    Bookmark.objects.all().update(url_normalized=\"\")\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0046_add_url_normalized_field\"),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            populate_url_normalized,\n            reverse_populate_url_normalized,\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0048_userprofile_default_mark_shared.py",
    "content": "# Generated by Django 5.2.3 on 2025-08-22 17:38\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0047_populate_url_normalized_field\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"default_mark_shared\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0049_userprofile_legacy_search.py",
    "content": "# Generated by Django 5.2.5 on 2025-10-05 09:10\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0048_userprofile_default_mark_shared\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"legacy_search\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0050_new_search_toast.py",
    "content": "# Generated by Django 5.2.5 on 2025-10-05 10:01\n\nfrom django.contrib.auth import get_user_model\nfrom django.db import migrations\n\nfrom bookmarks.models import Toast\n\nUser = get_user_model()\n\n\ndef forwards(apps, schema_editor):\n    for user in User.objects.all():\n        toast = Toast(\n            key=\"new_search_toast\",\n            message=\"This version replaces the search engine with a new implementation that supports logical operators (and, or, not). If you run into any issues with the new search, you can switch back to the old one by enabling legacy search in the settings.\",\n            owner=user,\n        )\n        toast.save()\n\n\ndef reverse(apps, schema_editor):\n    Toast.objects.filter(key=\"new_search_toast\").delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0049_userprofile_legacy_search\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards, reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0051_fix_normalized_url.py",
    "content": "# Generated by Django 5.2.5 on 2025-10-11 08:46\n\nfrom django.db import migrations\n\nfrom bookmarks.utils import normalize_url\n\n\ndef fix_url_normalized(apps, schema_editor):\n    Bookmark = apps.get_model(\"bookmarks\", \"Bookmark\")\n\n    batch_size = 200\n    qs = Bookmark.objects.filter(url_normalized=\"\").all()\n    for start in range(0, qs.count(), batch_size):\n        batch = list(qs[start : start + batch_size])\n        for bookmark in batch:\n            bookmark.url_normalized = normalize_url(bookmark.url)\n        Bookmark.objects.bulk_update(batch, [\"url_normalized\"])\n\n\ndef reverse_fix_url_normalized(apps, schema_editor):\n    pass\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0050_new_search_toast\"),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            fix_url_normalized,\n            reverse_fix_url_normalized,\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0052_apitoken.py",
    "content": "# Generated by Django 5.2.5 on 2025-12-14 16:33\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0051_fix_normalized_url\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"ApiToken\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"key\", models.CharField(max_length=40, unique=True)),\n                (\"name\", models.CharField(max_length=128)),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        related_name=\"api_tokens\",\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0053_migrate_api_tokens.py",
    "content": "# Generated by Django 5.2.5 on 2025-12-14 16:34\nfrom django.db import migrations\n\n\ndef migrate_tokens_forward(apps, schema_editor):\n    Token = apps.get_model(\"authtoken\", \"Token\")\n    ApiToken = apps.get_model(\"bookmarks\", \"ApiToken\")\n\n    for old_token in Token.objects.all():\n        ApiToken.objects.create(\n            key=old_token.key,\n            user=old_token.user,\n            name=\"Default Token\",\n            created=old_token.created,\n        )\n\n\ndef migrate_tokens_reverse(apps, schema_editor):\n    ApiToken = apps.get_model(\"bookmarks\", \"ApiToken\")\n    ApiToken.objects.filter(name=\"Default Token\").delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0052_apitoken\"),\n        (\"authtoken\", \"0004_alter_tokenproxy_options\"),\n    ]\n\n    operations = [\n        migrations.RunPython(migrate_tokens_forward, migrate_tokens_reverse),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/0054_bookmarkbundle_filter_shared_and_more.py",
    "content": "# Generated by Django 6.0 on 2026-02-28 09:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"bookmarks\", \"0053_migrate_api_tokens\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"bookmarkbundle\",\n            name=\"filter_shared\",\n            field=models.CharField(\n                choices=[(\"off\", \"All\"), (\"yes\", \"Shared\"), (\"no\", \"Unshared\")],\n                default=\"off\",\n                max_length=3,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"bookmarkbundle\",\n            name=\"filter_unread\",\n            field=models.CharField(\n                choices=[(\"off\", \"All\"), (\"yes\", \"Unread\"), (\"no\", \"Read\")],\n                default=\"off\",\n                max_length=3,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "bookmarks/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/models.py",
    "content": "import binascii\nimport hashlib\nimport logging\nimport os\n\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\nfrom django.core.validators import MinValueValidator\nfrom django.db import models\nfrom django.db.models import Q\nfrom django.db.models.signals import post_delete, post_save\nfrom django.dispatch import receiver\nfrom django.http import QueryDict\n\nfrom bookmarks.utils import normalize_url, unique\nfrom bookmarks.validators import BookmarkURLValidator\n\nlogger = logging.getLogger(__name__)\n\n\nclass Tag(models.Model):\n    name = models.CharField(max_length=64)\n    date_added = models.DateTimeField()\n    owner = models.ForeignKey(User, on_delete=models.CASCADE)\n\n    def __str__(self):\n        return self.name\n\n\ndef sanitize_tag_name(tag_name: str):\n    # strip leading/trailing spaces\n    # replace inner spaces with replacement char\n    return tag_name.strip().replace(\" \", \"-\")\n\n\ndef parse_tag_string(tag_string: str, delimiter: str = \",\"):\n    if not tag_string:\n        return []\n    names = tag_string.strip().split(delimiter)\n    # remove empty names, sanitize remaining names\n    names = [sanitize_tag_name(name) for name in names if name.strip()]\n    # remove duplicates\n    names = unique(names, str.lower)\n    names.sort(key=str.lower)\n\n    return names\n\n\ndef build_tag_string(tag_names: list[str], delimiter: str = \",\"):\n    return delimiter.join(tag_names)\n\n\nclass Bookmark(models.Model):\n    url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])\n    url_normalized = models.CharField(max_length=2048, blank=True, db_index=True)\n    title = models.CharField(max_length=512, blank=True)\n    description = models.TextField(blank=True)\n    notes = models.TextField(blank=True)\n    # Obsolete field, kept to not remove column when generating migrations\n    website_title = models.CharField(max_length=512, blank=True, null=True)\n    # Obsolete field, kept to not remove column when generating migrations\n    website_description = models.TextField(blank=True, null=True)\n    web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)\n    favicon_file = models.CharField(max_length=512, blank=True)\n    preview_image_file = models.CharField(max_length=512, blank=True)\n    unread = models.BooleanField(default=False)\n    is_archived = models.BooleanField(default=False)\n    shared = models.BooleanField(default=False)\n    date_added = models.DateTimeField()\n    date_modified = models.DateTimeField()\n    date_accessed = models.DateTimeField(blank=True, null=True)\n    owner = models.ForeignKey(User, on_delete=models.CASCADE)\n    tags = models.ManyToManyField(Tag)\n    latest_snapshot = models.ForeignKey(\n        \"BookmarkAsset\",\n        on_delete=models.SET_NULL,\n        null=True,\n        blank=True,\n        related_name=\"latest_snapshot\",\n    )\n\n    @property\n    def resolved_title(self):\n        if self.title:\n            return self.title\n        else:\n            return self.url\n\n    @property\n    def resolved_description(self):\n        return self.description\n\n    @property\n    def tag_names(self):\n        names = [tag.name for tag in self.tags.all()]\n        return sorted(names)\n\n    def save(self, *args, **kwargs):\n        self.url_normalized = normalize_url(self.url)\n        super().save(*args, **kwargs)\n\n    def __str__(self):\n        return self.resolved_title + \" (\" + self.url[:30] + \"...)\"\n\n    @staticmethod\n    def query_existing(owner: User, url: str) -> models.QuerySet:\n        # Find existing bookmark by normalized URL, or fall back to exact URL if\n        # normalized URL was not generated for whatever reason\n        normalized_url = normalize_url(url)\n        q = Q(owner=owner) & (\n            Q(url_normalized=normalized_url) | Q(url_normalized=\"\", url=url)\n        )\n        return Bookmark.objects.filter(q)\n\n\n@receiver(post_delete, sender=Bookmark)\ndef bookmark_deleted(sender, instance, **kwargs):\n    if instance.preview_image_file:\n        filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)\n        if os.path.isfile(filepath):\n            try:\n                os.remove(filepath)\n            except Exception as error:\n                logger.error(\n                    f\"Failed to delete preview image: {filepath}\", exc_info=error\n                )\n\n\nclass BookmarkAsset(models.Model):\n    TYPE_SNAPSHOT = \"snapshot\"\n    TYPE_UPLOAD = \"upload\"\n\n    CONTENT_TYPE_HTML = \"text/html\"\n    CONTENT_TYPE_PDF = \"application/pdf\"\n\n    STATUS_PENDING = \"pending\"\n    STATUS_COMPLETE = \"complete\"\n    STATUS_FAILURE = \"failure\"\n\n    bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE)\n    date_created = models.DateTimeField(auto_now_add=True, null=False)\n    file = models.CharField(max_length=2048, blank=True, null=False)\n    file_size = models.IntegerField(null=True)\n    asset_type = models.CharField(max_length=64, blank=False, null=False)\n    content_type = models.CharField(max_length=128, blank=False, null=False)\n    display_name = models.CharField(max_length=2048, blank=True, null=False)\n    status = models.CharField(max_length=64, blank=False, null=False)\n    gzip = models.BooleanField(default=False, null=False)\n\n    @property\n    def download_name(self):\n        if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT:\n            if self.content_type == BookmarkAsset.CONTENT_TYPE_PDF:\n                return f\"{self.display_name}.pdf\"\n            return f\"{self.display_name}.html\"\n        return self.display_name\n\n    def save(self, *args, **kwargs):\n        if self.file:\n            try:\n                file_path = os.path.join(settings.LD_ASSET_FOLDER, self.file)\n                if os.path.isfile(file_path):\n                    self.file_size = os.path.getsize(file_path)\n            except Exception:\n                pass\n        super().save(*args, **kwargs)\n\n    def __str__(self):\n        return self.display_name or f\"Bookmark Asset #{self.pk}\"\n\n\n@receiver(post_delete, sender=BookmarkAsset)\ndef bookmark_asset_deleted(sender, instance, **kwargs):\n    if instance.file:\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, instance.file)\n        if os.path.isfile(filepath):\n            try:\n                os.remove(filepath)\n            except Exception as error:\n                logger.error(f\"Failed to delete asset file: {filepath}\", exc_info=error)\n\n\nclass BookmarkBundle(models.Model):\n    FILTER_STATE_OFF = \"off\"\n    FILTER_STATE_YES = \"yes\"\n    FILTER_STATE_NO = \"no\"\n    FILTER_UNREAD_CHOICES = [\n        (FILTER_STATE_OFF, \"All\"),\n        (FILTER_STATE_YES, \"Unread\"),\n        (FILTER_STATE_NO, \"Read\"),\n    ]\n    FILTER_SHARED_CHOICES = [\n        (FILTER_STATE_OFF, \"All\"),\n        (FILTER_STATE_YES, \"Shared\"),\n        (FILTER_STATE_NO, \"Unshared\"),\n    ]\n\n    name = models.CharField(max_length=256, blank=False)\n    search = models.CharField(max_length=256, blank=True)\n    any_tags = models.CharField(max_length=1024, blank=True)\n    all_tags = models.CharField(max_length=1024, blank=True)\n    excluded_tags = models.CharField(max_length=1024, blank=True)\n    filter_unread = models.CharField(\n        max_length=3,\n        choices=FILTER_UNREAD_CHOICES,\n        blank=False,\n        default=FILTER_STATE_OFF,\n    )\n    filter_shared = models.CharField(\n        max_length=3,\n        choices=FILTER_SHARED_CHOICES,\n        blank=False,\n        default=FILTER_STATE_OFF,\n    )\n    order = models.IntegerField(null=False, default=0)\n    date_created = models.DateTimeField(auto_now_add=True, null=False)\n    date_modified = models.DateTimeField(auto_now=True, null=False)\n    owner = models.ForeignKey(User, on_delete=models.CASCADE)\n\n    def __str__(self):\n        return self.name\n\n\nclass BookmarkSearch:\n    SORT_ADDED_ASC = \"added_asc\"\n    SORT_ADDED_DESC = \"added_desc\"\n    SORT_TITLE_ASC = \"title_asc\"\n    SORT_TITLE_DESC = \"title_desc\"\n\n    FILTER_SHARED_OFF = \"off\"\n    FILTER_SHARED_SHARED = \"yes\"\n    FILTER_SHARED_UNSHARED = \"no\"\n\n    FILTER_UNREAD_OFF = \"off\"\n    FILTER_UNREAD_YES = \"yes\"\n    FILTER_UNREAD_NO = \"no\"\n\n    params = [\n        \"q\",\n        \"user\",\n        \"bundle\",\n        \"sort\",\n        \"shared\",\n        \"unread\",\n        \"modified_since\",\n        \"added_since\",\n    ]\n    preferences = [\"sort\", \"shared\", \"unread\"]\n    defaults = {\n        \"q\": \"\",\n        \"user\": \"\",\n        \"bundle\": None,\n        \"sort\": SORT_ADDED_DESC,\n        \"shared\": FILTER_SHARED_OFF,\n        \"unread\": FILTER_UNREAD_OFF,\n        \"modified_since\": None,\n        \"added_since\": None,\n    }\n\n    def __init__(\n        self,\n        q: str = None,\n        user: str = None,\n        bundle: BookmarkBundle = None,\n        sort: str = None,\n        shared: str = None,\n        unread: str = None,\n        modified_since: str = None,\n        added_since: str = None,\n        preferences: dict = None,\n        request: any = None,\n    ):\n        if not preferences:\n            preferences = {}\n        self.defaults = {**BookmarkSearch.defaults, **preferences}\n        self.request = request\n\n        self.q = q or self.defaults[\"q\"]\n        self.user = user or self.defaults[\"user\"]\n        self.bundle = bundle or self.defaults[\"bundle\"]\n        self.sort = sort or self.defaults[\"sort\"]\n        self.shared = shared or self.defaults[\"shared\"]\n        self.unread = unread or self.defaults[\"unread\"]\n        self.modified_since = modified_since or self.defaults[\"modified_since\"]\n        self.added_since = added_since or self.defaults[\"added_since\"]\n\n    def is_modified(self, param):\n        value = self.__dict__[param]\n        return value != self.defaults[param]\n\n    @property\n    def modified_params(self):\n        return [field for field in self.params if self.is_modified(field)]\n\n    @property\n    def modified_preferences(self):\n        return [\n            preference\n            for preference in self.preferences\n            if self.is_modified(preference)\n        ]\n\n    @property\n    def has_modifications(self):\n        return len(self.modified_params) > 0\n\n    @property\n    def has_modified_preferences(self):\n        return len(self.modified_preferences) > 0\n\n    @property\n    def query_params(self):\n        query_params = {}\n        for param in self.modified_params:\n            value = self.__dict__[param]\n            if isinstance(value, models.Model):\n                query_params[param] = value.id\n            else:\n                query_params[param] = value\n        return query_params\n\n    @property\n    def preferences_dict(self):\n        return {\n            preference: self.__dict__[preference] for preference in self.preferences\n        }\n\n    @staticmethod\n    def from_request(request: any, query_dict: QueryDict, preferences: dict = None):\n        initial_values = {}\n        for param in BookmarkSearch.params:\n            value = query_dict.get(param)\n            if value:\n                if param == \"bundle\":\n                    initial_values[param] = BookmarkBundle.objects.filter(\n                        owner=request.user, pk=value\n                    ).first()\n                else:\n                    initial_values[param] = value\n\n        return BookmarkSearch(\n            **initial_values, preferences=preferences, request=request\n        )\n\n\nclass UserProfile(models.Model):\n    THEME_AUTO = \"auto\"\n    THEME_LIGHT = \"light\"\n    THEME_DARK = \"dark\"\n    THEME_CHOICES = [\n        (THEME_AUTO, \"Auto\"),\n        (THEME_LIGHT, \"Light\"),\n        (THEME_DARK, \"Dark\"),\n    ]\n    BOOKMARK_DATE_DISPLAY_RELATIVE = \"relative\"\n    BOOKMARK_DATE_DISPLAY_ABSOLUTE = \"absolute\"\n    BOOKMARK_DATE_DISPLAY_HIDDEN = \"hidden\"\n    BOOKMARK_DATE_DISPLAY_CHOICES = [\n        (BOOKMARK_DATE_DISPLAY_RELATIVE, \"Relative\"),\n        (BOOKMARK_DATE_DISPLAY_ABSOLUTE, \"Absolute\"),\n        (BOOKMARK_DATE_DISPLAY_HIDDEN, \"Hidden\"),\n    ]\n    BOOKMARK_DESCRIPTION_DISPLAY_INLINE = \"inline\"\n    BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = \"separate\"\n    BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [\n        (BOOKMARK_DESCRIPTION_DISPLAY_INLINE, \"Inline\"),\n        (BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, \"Separate\"),\n    ]\n    BOOKMARK_LINK_TARGET_BLANK = \"_blank\"\n    BOOKMARK_LINK_TARGET_SELF = \"_self\"\n    BOOKMARK_LINK_TARGET_CHOICES = [\n        (BOOKMARK_LINK_TARGET_BLANK, \"New page\"),\n        (BOOKMARK_LINK_TARGET_SELF, \"Same page\"),\n    ]\n    WEB_ARCHIVE_INTEGRATION_DISABLED = \"disabled\"\n    WEB_ARCHIVE_INTEGRATION_ENABLED = \"enabled\"\n    WEB_ARCHIVE_INTEGRATION_CHOICES = [\n        (WEB_ARCHIVE_INTEGRATION_DISABLED, \"Disabled\"),\n        (WEB_ARCHIVE_INTEGRATION_ENABLED, \"Enabled\"),\n    ]\n    TAG_SEARCH_STRICT = \"strict\"\n    TAG_SEARCH_LAX = \"lax\"\n    TAG_SEARCH_CHOICES = [\n        (TAG_SEARCH_STRICT, \"Strict\"),\n        (TAG_SEARCH_LAX, \"Lax\"),\n    ]\n    TAG_GROUPING_ALPHABETICAL = \"alphabetical\"\n    TAG_GROUPING_DISABLED = \"disabled\"\n    TAG_GROUPING_CHOICES = [\n        (TAG_GROUPING_ALPHABETICAL, \"Alphabetical\"),\n        (TAG_GROUPING_DISABLED, \"Disabled\"),\n    ]\n    user = models.OneToOneField(User, related_name=\"profile\", on_delete=models.CASCADE)\n    theme = models.CharField(\n        max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO\n    )\n    bookmark_date_display = models.CharField(\n        max_length=10,\n        choices=BOOKMARK_DATE_DISPLAY_CHOICES,\n        blank=False,\n        default=BOOKMARK_DATE_DISPLAY_RELATIVE,\n    )\n    bookmark_description_display = models.CharField(\n        max_length=10,\n        choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,\n        blank=False,\n        default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,\n    )\n    bookmark_description_max_lines = models.IntegerField(\n        null=False,\n        default=1,\n    )\n    bookmark_link_target = models.CharField(\n        max_length=10,\n        choices=BOOKMARK_LINK_TARGET_CHOICES,\n        blank=False,\n        default=BOOKMARK_LINK_TARGET_BLANK,\n    )\n    web_archive_integration = models.CharField(\n        max_length=10,\n        choices=WEB_ARCHIVE_INTEGRATION_CHOICES,\n        blank=False,\n        default=WEB_ARCHIVE_INTEGRATION_DISABLED,\n    )\n    tag_search = models.CharField(\n        max_length=10,\n        choices=TAG_SEARCH_CHOICES,\n        blank=False,\n        default=TAG_SEARCH_STRICT,\n    )\n    tag_grouping = models.CharField(\n        max_length=12,\n        choices=TAG_GROUPING_CHOICES,\n        blank=False,\n        default=TAG_GROUPING_ALPHABETICAL,\n    )\n    enable_sharing = models.BooleanField(default=False, null=False)\n    enable_public_sharing = models.BooleanField(default=False, null=False)\n    enable_favicons = models.BooleanField(default=False, null=False)\n    enable_preview_images = models.BooleanField(default=False, null=False)\n    display_url = models.BooleanField(default=False, null=False)\n    display_view_bookmark_action = models.BooleanField(default=True, null=False)\n    display_edit_bookmark_action = models.BooleanField(default=True, null=False)\n    display_archive_bookmark_action = models.BooleanField(default=True, null=False)\n    display_remove_bookmark_action = models.BooleanField(default=True, null=False)\n    permanent_notes = models.BooleanField(default=False, null=False)\n    custom_css = models.TextField(blank=True, null=False)\n    custom_css_hash = models.CharField(blank=True, null=False, max_length=32)\n    auto_tagging_rules = models.TextField(blank=True, null=False)\n    search_preferences = models.JSONField(default=dict, null=False)\n    enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)\n    default_mark_unread = models.BooleanField(default=False, null=False)\n    default_mark_shared = models.BooleanField(default=False, null=False)\n    items_per_page = models.IntegerField(\n        null=False, default=30, validators=[MinValueValidator(10)]\n    )\n    sticky_pagination = models.BooleanField(default=False, null=False)\n    collapse_side_panel = models.BooleanField(default=False, null=False)\n    hide_bundles = models.BooleanField(default=False, null=False)\n    legacy_search = models.BooleanField(default=False, null=False)\n\n    def save(self, *args, **kwargs):\n        if self.custom_css:\n            self.custom_css_hash = hashlib.md5(\n                self.custom_css.encode(\"utf-8\")\n            ).hexdigest()\n        else:\n            self.custom_css_hash = \"\"\n        super().save(*args, **kwargs)\n\n\n@receiver(post_save, sender=User)\ndef create_user_profile(sender, instance, created, **kwargs):\n    if created:\n        UserProfile.objects.create(user=instance)\n\n\n@receiver(post_save, sender=User)\ndef save_user_profile(sender, instance, **kwargs):\n    instance.profile.save()\n\n\nclass Toast(models.Model):\n    key = models.CharField(max_length=50)\n    message = models.TextField()\n    acknowledged = models.BooleanField(default=False)\n    owner = models.ForeignKey(User, on_delete=models.CASCADE)\n\n\nclass FeedToken(models.Model):\n    \"\"\"\n    Adapted from authtoken.models.Token\n    \"\"\"\n\n    key = models.CharField(max_length=40, primary_key=True)\n    user = models.OneToOneField(\n        User,\n        related_name=\"feed_token\",\n        on_delete=models.CASCADE,\n    )\n    created = models.DateTimeField(auto_now_add=True)\n\n    def save(self, *args, **kwargs):\n        if not self.key:\n            self.key = self.generate_key()\n        return super().save(*args, **kwargs)\n\n    @classmethod\n    def generate_key(cls):\n        return binascii.hexlify(os.urandom(20)).decode()\n\n    def __str__(self):\n        return self.key\n\n\nclass ApiToken(models.Model):\n    key = models.CharField(max_length=40, unique=True)\n    user = models.ForeignKey(\n        User,\n        related_name=\"api_tokens\",\n        on_delete=models.CASCADE,\n    )\n    name = models.CharField(max_length=128, blank=False)\n    created = models.DateTimeField(auto_now_add=True)\n\n    def save(self, *args, **kwargs):\n        if not self.key:\n            self.key = self.generate_key()\n        return super().save(*args, **kwargs)\n\n    @classmethod\n    def generate_key(cls):\n        return binascii.hexlify(os.urandom(20)).decode()\n\n    def __str__(self):\n        return f\"{self.name} ({self.user.username})\"\n\n\nclass GlobalSettings(models.Model):\n    LANDING_PAGE_LOGIN = \"login\"\n    LANDING_PAGE_SHARED_BOOKMARKS = \"shared_bookmarks\"\n    LANDING_PAGE_CHOICES = [\n        (LANDING_PAGE_LOGIN, \"Login\"),\n        (LANDING_PAGE_SHARED_BOOKMARKS, \"Shared Bookmarks\"),\n    ]\n\n    landing_page = models.CharField(\n        max_length=50,\n        choices=LANDING_PAGE_CHOICES,\n        blank=False,\n        default=LANDING_PAGE_LOGIN,\n    )\n    guest_profile_user = models.ForeignKey(\n        User, on_delete=models.SET_NULL, null=True, blank=True\n    )\n    enable_link_prefetch = models.BooleanField(default=False, null=False)\n\n    @classmethod\n    def get(cls):\n        instance = GlobalSettings.objects.first()\n        if not instance:\n            instance = GlobalSettings()\n            instance.save()\n        return instance\n\n    def save(self, *args, **kwargs):\n        if not self.pk and GlobalSettings.objects.exists():\n            raise Exception(\"There is already one instance of GlobalSettings\")\n        return super().save(*args, **kwargs)\n"
  },
  {
    "path": "bookmarks/queries.py",
    "content": "import contextlib\n\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\nfrom django.core.exceptions import ValidationError\nfrom django.db.models import Case, CharField, Exists, OuterRef, Q, QuerySet, When\nfrom django.db.models.expressions import RawSQL\nfrom django.db.models.functions import Lower\n\nfrom bookmarks.models import (\n    Bookmark,\n    BookmarkBundle,\n    BookmarkSearch,\n    Tag,\n    UserProfile,\n    parse_tag_string,\n)\nfrom bookmarks.services.search_query_parser import (\n    AndExpression,\n    NotExpression,\n    OrExpression,\n    SearchExpression,\n    SearchQueryParseError,\n    SpecialKeywordExpression,\n    TagExpression,\n    TermExpression,\n    extract_tag_names_from_query,\n    parse_search_query,\n)\nfrom bookmarks.utils import unique\n\n\ndef query_bookmarks(\n    user: User,\n    profile: UserProfile,\n    search: BookmarkSearch,\n) -> QuerySet:\n    return _base_bookmarks_query(user, profile, search).filter(is_archived=False)\n\n\ndef query_archived_bookmarks(\n    user: User, profile: UserProfile, search: BookmarkSearch\n) -> QuerySet:\n    return _base_bookmarks_query(user, profile, search).filter(is_archived=True)\n\n\ndef query_shared_bookmarks(\n    user: User | None,\n    profile: UserProfile,\n    search: BookmarkSearch,\n    public_only: bool,\n) -> QuerySet:\n    conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)\n    if public_only:\n        conditions = conditions & Q(owner__profile__enable_public_sharing=True)\n\n    return _base_bookmarks_query(user, profile, search).filter(conditions)\n\n\ndef _convert_ast_to_q_object(ast_node: SearchExpression, profile: UserProfile) -> Q:\n    if isinstance(ast_node, TermExpression):\n        # Search across title, description, notes, URL\n        conditions = (\n            Q(title__icontains=ast_node.term)\n            | Q(description__icontains=ast_node.term)\n            | Q(notes__icontains=ast_node.term)\n            | Q(url__icontains=ast_node.term)\n        )\n\n        # In lax mode, also search in tag names\n        if profile.tag_search == UserProfile.TAG_SEARCH_LAX:\n            conditions = conditions | Exists(\n                Bookmark.objects.filter(\n                    id=OuterRef(\"id\"), tags__name__iexact=ast_node.term\n                )\n            )\n\n        return conditions\n\n    elif isinstance(ast_node, TagExpression):\n        # Use Exists() to avoid reusing the same join when combining multiple tag expressions with and\n        return Q(\n            Exists(\n                Bookmark.objects.filter(\n                    id=OuterRef(\"id\"), tags__name__iexact=ast_node.tag\n                )\n            )\n        )\n\n    elif isinstance(ast_node, SpecialKeywordExpression):\n        # Handle special keywords\n        if ast_node.keyword.lower() == \"unread\":\n            return Q(unread=True)\n        elif ast_node.keyword.lower() == \"untagged\":\n            return Q(tags=None)\n        else:\n            # Unknown keyword, return empty Q object (matches all)\n            return Q()\n\n    elif isinstance(ast_node, AndExpression):\n        # Combine left and right with AND\n        left_q = _convert_ast_to_q_object(ast_node.left, profile)\n        right_q = _convert_ast_to_q_object(ast_node.right, profile)\n        return left_q & right_q\n\n    elif isinstance(ast_node, OrExpression):\n        # Combine left and right with OR\n        left_q = _convert_ast_to_q_object(ast_node.left, profile)\n        right_q = _convert_ast_to_q_object(ast_node.right, profile)\n        return left_q | right_q\n\n    elif isinstance(ast_node, NotExpression):\n        # Negate the operand\n        operand_q = _convert_ast_to_q_object(ast_node.operand, profile)\n        return ~operand_q\n\n    else:\n        # Fallback for unknown node types\n        return Q()\n\n\ndef _filter_search_query(\n    query_set: QuerySet, query_string: str, profile: UserProfile\n) -> QuerySet:\n    \"\"\"New search filtering logic using logical expressions.\"\"\"\n\n    try:\n        ast = parse_search_query(query_string)\n        if ast:\n            search_query = _convert_ast_to_q_object(ast, profile)\n            query_set = query_set.filter(search_query)\n    except SearchQueryParseError:\n        # If the query cannot be parsed, return zero results\n        return query_set.none()\n\n    return query_set\n\n\ndef _filter_search_query_legacy(\n    query_set: QuerySet, query_string: str, profile: UserProfile\n) -> QuerySet:\n    \"\"\"Legacy search filtering logic where everything is just combined with AND.\"\"\"\n\n    # Split query into search terms and tags\n    query = parse_query_string(query_string)\n\n    # Filter for search terms and tags\n    for term in query[\"search_terms\"]:\n        conditions = (\n            Q(title__icontains=term)\n            | Q(description__icontains=term)\n            | Q(notes__icontains=term)\n            | Q(url__icontains=term)\n        )\n\n        if profile.tag_search == UserProfile.TAG_SEARCH_LAX:\n            conditions = conditions | Exists(\n                Bookmark.objects.filter(id=OuterRef(\"id\"), tags__name__iexact=term)\n            )\n\n        query_set = query_set.filter(conditions)\n\n    for tag_name in query[\"tag_names\"]:\n        query_set = query_set.filter(tags__name__iexact=tag_name)\n\n    # Untagged bookmarks\n    if query[\"untagged\"]:\n        query_set = query_set.filter(tags=None)\n    # Legacy unread bookmarks filter from query\n    if query[\"unread\"]:\n        query_set = query_set.filter(unread=True)\n\n    return query_set\n\n\ndef _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:\n    # Search terms\n    search_terms = parse_query_string(bundle.search)[\"search_terms\"]\n    for term in search_terms:\n        conditions = (\n            Q(title__icontains=term)\n            | Q(description__icontains=term)\n            | Q(notes__icontains=term)\n            | Q(url__icontains=term)\n        )\n        query_set = query_set.filter(conditions)\n\n    # Any tags - at least one tag must match\n    any_tags = parse_tag_string(bundle.any_tags, \" \")\n    if len(any_tags) > 0:\n        tag_conditions = Q()\n        for tag in any_tags:\n            tag_conditions |= Q(tags__name__iexact=tag)\n\n        query_set = query_set.filter(\n            Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef(\"id\")))\n        )\n\n    # All tags - all tags must match\n    all_tags = parse_tag_string(bundle.all_tags, \" \")\n    for tag in all_tags:\n        query_set = query_set.filter(tags__name__iexact=tag)\n\n    # Excluded tags - no tags must match\n    exclude_tags = parse_tag_string(bundle.excluded_tags, \" \")\n    if len(exclude_tags) > 0:\n        tag_conditions = Q()\n        for tag in exclude_tags:\n            tag_conditions |= Q(tags__name__iexact=tag)\n        query_set = query_set.exclude(\n            Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef(\"id\")))\n        )\n\n    if bundle.filter_unread == BookmarkBundle.FILTER_STATE_YES:\n        query_set = query_set.filter(unread=True)\n    elif bundle.filter_unread == BookmarkBundle.FILTER_STATE_NO:\n        query_set = query_set.filter(unread=False)\n\n    if bundle.filter_shared == BookmarkBundle.FILTER_STATE_YES:\n        query_set = query_set.filter(shared=True)\n    elif bundle.filter_shared == BookmarkBundle.FILTER_STATE_NO:\n        query_set = query_set.filter(shared=False)\n\n    return query_set\n\n\ndef _base_bookmarks_query(\n    user: User | None,\n    profile: UserProfile,\n    search: BookmarkSearch,\n) -> QuerySet:\n    query_set = Bookmark.objects\n\n    # Filter for user\n    if user:\n        query_set = query_set.filter(owner=user)\n\n    # Filter by modified_since if provided\n    if search.modified_since:\n        # If the date format is invalid, ignore the filter\n        with contextlib.suppress(ValidationError):\n            query_set = query_set.filter(date_modified__gt=search.modified_since)\n\n    # Filter by added_since if provided\n    if search.added_since:\n        # If the date format is invalid, ignore the filter\n        with contextlib.suppress(ValidationError):\n            query_set = query_set.filter(date_added__gt=search.added_since)\n\n    # Filter by search query\n    if profile.legacy_search:\n        query_set = _filter_search_query_legacy(query_set, search.q, profile)\n    else:\n        query_set = _filter_search_query(query_set, search.q, profile)\n\n    # Unread filter from bookmark search\n    if search.unread == BookmarkSearch.FILTER_UNREAD_YES:\n        query_set = query_set.filter(unread=True)\n    elif search.unread == BookmarkSearch.FILTER_UNREAD_NO:\n        query_set = query_set.filter(unread=False)\n\n    # Shared filter\n    if search.shared == BookmarkSearch.FILTER_SHARED_SHARED:\n        query_set = query_set.filter(shared=True)\n    elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:\n        query_set = query_set.filter(shared=False)\n\n    # Filter by bundle\n    if search.bundle:\n        query_set = _filter_bundle(query_set, search.bundle)\n\n    # Sort\n    if (\n        search.sort == BookmarkSearch.SORT_TITLE_ASC\n        or search.sort == BookmarkSearch.SORT_TITLE_DESC\n    ):\n        # For the title, the resolved_title logic from the Bookmark entity needs\n        # to be replicated as there is no corresponding database field\n        query_set = query_set.annotate(\n            effective_title=Case(\n                When(Q(title__isnull=False) & ~Q(title__exact=\"\"), then=Lower(\"title\")),\n                default=Lower(\"url\"),\n                output_field=CharField(),\n            )\n        )\n\n        # For SQLite, if the ICU extension is loaded, use the custom collation\n        # loaded into the connection. This results in an improved sort order for\n        # unicode characters (umlauts, etc.)\n        if settings.USE_SQLITE and settings.USE_SQLITE_ICU_EXTENSION:\n            order_field = RawSQL(\"effective_title COLLATE ICU\", ())\n        else:\n            order_field = \"effective_title\"\n\n        if search.sort == BookmarkSearch.SORT_TITLE_ASC:\n            query_set = query_set.order_by(order_field)\n        elif search.sort == BookmarkSearch.SORT_TITLE_DESC:\n            query_set = query_set.order_by(order_field).reverse()\n    elif search.sort == BookmarkSearch.SORT_ADDED_ASC:\n        query_set = query_set.order_by(\"date_added\")\n    else:\n        # Sort by date added, descending by default\n        query_set = query_set.order_by(\"-date_added\")\n\n    return query_set\n\n\ndef query_bookmark_tags(\n    user: User, profile: UserProfile, search: BookmarkSearch\n) -> QuerySet:\n    bookmarks_query = query_bookmarks(user, profile, search)\n\n    query_set = Tag.objects.filter(bookmark__in=bookmarks_query)\n\n    return query_set.distinct()\n\n\ndef query_archived_bookmark_tags(\n    user: User, profile: UserProfile, search: BookmarkSearch\n) -> QuerySet:\n    bookmarks_query = query_archived_bookmarks(user, profile, search)\n\n    query_set = Tag.objects.filter(bookmark__in=bookmarks_query)\n\n    return query_set.distinct()\n\n\ndef query_shared_bookmark_tags(\n    user: User | None,\n    profile: UserProfile,\n    search: BookmarkSearch,\n    public_only: bool,\n) -> QuerySet:\n    bookmarks_query = query_shared_bookmarks(user, profile, search, public_only)\n\n    query_set = Tag.objects.filter(bookmark__in=bookmarks_query)\n\n    return query_set.distinct()\n\n\ndef query_shared_bookmark_users(\n    profile: UserProfile, search: BookmarkSearch, public_only: bool\n) -> QuerySet:\n    bookmarks_query = query_shared_bookmarks(None, profile, search, public_only)\n\n    query_set = User.objects.filter(bookmark__in=bookmarks_query)\n\n    return query_set.distinct()\n\n\ndef get_user_tags(user: User):\n    return Tag.objects.filter(owner=user).all()\n\n\ndef get_tags_for_query(user: User, profile: UserProfile, query: str) -> QuerySet:\n    tag_names = extract_tag_names_from_query(query, profile)\n\n    if not tag_names:\n        return Tag.objects.none()\n\n    tag_conditions = Q()\n    for tag_name in tag_names:\n        tag_conditions |= Q(name__iexact=tag_name)\n\n    return Tag.objects.filter(owner=user).filter(tag_conditions).distinct()\n\n\ndef get_shared_tags_for_query(\n    user: User | None, profile: UserProfile, query: str, public_only: bool\n) -> QuerySet:\n    tag_names = extract_tag_names_from_query(query, profile)\n\n    if not tag_names:\n        return Tag.objects.none()\n\n    # Build conditions similar to query_shared_bookmarks\n    conditions = Q(bookmark__shared=True) & Q(\n        bookmark__owner__profile__enable_sharing=True\n    )\n    if public_only:\n        conditions = conditions & Q(\n            bookmark__owner__profile__enable_public_sharing=True\n        )\n    if user is not None:\n        conditions = conditions & Q(bookmark__owner=user)\n\n    tag_conditions = Q()\n    for tag_name in tag_names:\n        tag_conditions |= Q(name__iexact=tag_name)\n\n    return Tag.objects.filter(conditions).filter(tag_conditions).distinct()\n\n\ndef parse_query_string(query_string):\n    # Sanitize query params\n    if not query_string:\n        query_string = \"\"\n\n    # Split query into search terms and tags\n    keywords = query_string.strip().split(\" \")\n    keywords = [word for word in keywords if word]\n\n    search_terms = [word for word in keywords if word[0] != \"#\" and word[0] != \"!\"]\n    tag_names = [word[1:] for word in keywords if word[0] == \"#\"]\n    tag_names = unique(tag_names, str.lower)\n\n    # Special search commands\n    untagged = \"!untagged\" in keywords\n    unread = \"!unread\" in keywords\n\n    return {\n        \"search_terms\": search_terms,\n        \"tag_names\": tag_names,\n        \"untagged\": untagged,\n        \"unread\": unread,\n    }\n"
  },
  {
    "path": "bookmarks/services/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/services/assets.py",
    "content": "import gzip\nimport logging\nimport os\nimport shutil\n\nimport requests\nfrom django.conf import settings\nfrom django.core.files.uploadedfile import UploadedFile\nfrom django.utils import formats, timezone\n\nfrom bookmarks.models import Bookmark, BookmarkAsset\nfrom bookmarks.services import singlefile\nfrom bookmarks.services.website_loader import (\n    detect_content_type,\n    fake_request_headers,\n    is_pdf_content_type,\n)\n\nMAX_ASSET_FILENAME_LENGTH = 192\n\nlogger = logging.getLogger(__name__)\n\n\nclass PdfTooLargeError(Exception):\n    pass\n\n\ndef create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:\n    asset = BookmarkAsset(\n        bookmark=bookmark,\n        asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n        date_created=timezone.now(),\n        content_type=\"\",\n        display_name=\"New snapshot\",\n        status=BookmarkAsset.STATUS_PENDING,\n    )\n    return asset\n\n\ndef create_snapshot(asset: BookmarkAsset):\n    try:\n        url = asset.bookmark.url\n        content_type = detect_content_type(url)\n\n        if is_pdf_content_type(content_type):\n            _create_pdf_snapshot(asset)\n        else:\n            _create_html_snapshot(asset)\n    except Exception as error:\n        asset.status = BookmarkAsset.STATUS_FAILURE\n        asset.save()\n        raise error\n\n\ndef _create_html_snapshot(asset: BookmarkAsset):\n    # Create snapshot into temporary file\n    temp_filename = _generate_asset_filename(asset, asset.bookmark.url, \"tmp\")\n    temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)\n    singlefile.create_snapshot(asset.bookmark.url, temp_filepath)\n\n    # Store as gzip in asset folder\n    filename = _generate_asset_filename(asset, asset.bookmark.url, \"html.gz\")\n    filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n    with (\n        open(temp_filepath, \"rb\") as temp_file,\n        gzip.open(filepath, \"wb\") as gz_file,\n    ):\n        shutil.copyfileobj(temp_file, gz_file)\n\n    # Remove temporary file\n    os.remove(temp_filepath)\n\n    # Update display name for HTML\n    timestamp = formats.date_format(asset.date_created, \"SHORT_DATE_FORMAT\")\n\n    asset.status = BookmarkAsset.STATUS_COMPLETE\n    asset.content_type = BookmarkAsset.CONTENT_TYPE_HTML\n    asset.display_name = f\"HTML snapshot from {timestamp}\"\n    asset.file = filename\n    asset.gzip = True\n    asset.save()\n\n    asset.bookmark.latest_snapshot = asset\n    asset.bookmark.date_modified = timezone.now()\n    asset.bookmark.save()\n\n\ndef _create_pdf_snapshot(asset: BookmarkAsset):\n    url = asset.bookmark.url\n    max_size = settings.LD_SNAPSHOT_PDF_MAX_SIZE\n\n    # Download PDF to temporary file\n    temp_filename = _generate_asset_filename(asset, url, \"tmp\")\n    temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)\n\n    headers = fake_request_headers()\n    timeout = 60\n\n    with requests.get(url, headers=headers, stream=True, timeout=timeout) as response:\n        response.raise_for_status()\n\n        # Check Content-Length header if available\n        content_length = response.headers.get(\"Content-Length\")\n        if content_length and int(content_length) > max_size:\n            raise PdfTooLargeError(\n                f\"PDF size ({content_length} bytes) exceeds limit ({max_size} bytes)\"\n            )\n\n        # Download in chunks, tracking size\n        downloaded_size = 0\n        with open(temp_filepath, \"wb\") as f:\n            for chunk in response.iter_content(chunk_size=8192):\n                downloaded_size += len(chunk)\n                if downloaded_size > max_size:\n                    raise PdfTooLargeError(f\"PDF size exceeds limit ({max_size} bytes)\")\n                f.write(chunk)\n\n    # Store as gzip in asset folder\n    filename = _generate_asset_filename(asset, url, \"pdf.gz\")\n    filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n    with (\n        open(temp_filepath, \"rb\") as temp_file,\n        gzip.open(filepath, \"wb\") as gz_file,\n    ):\n        shutil.copyfileobj(temp_file, gz_file)\n\n    # Remove temporary file\n    os.remove(temp_filepath)\n\n    # Update display name for PDF\n    timestamp = formats.date_format(asset.date_created, \"SHORT_DATE_FORMAT\")\n\n    asset.status = BookmarkAsset.STATUS_COMPLETE\n    asset.content_type = BookmarkAsset.CONTENT_TYPE_PDF\n    asset.display_name = f\"PDF download from {timestamp}\"\n    asset.file = filename\n    asset.gzip = True\n    asset.save()\n\n    asset.bookmark.latest_snapshot = asset\n    asset.bookmark.date_modified = timezone.now()\n    asset.bookmark.save()\n\n\ndef upload_snapshot(bookmark: Bookmark, html: bytes):\n    asset = create_snapshot_asset(bookmark)\n    filename = _generate_asset_filename(asset, asset.bookmark.url, \"html.gz\")\n    filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n\n    with gzip.open(filepath, \"wb\") as gz_file:\n        gz_file.write(html)\n\n    # Only save the asset if the file was written successfully\n    timestamp = formats.date_format(asset.date_created, \"SHORT_DATE_FORMAT\")\n\n    asset.status = BookmarkAsset.STATUS_COMPLETE\n    asset.content_type = BookmarkAsset.CONTENT_TYPE_HTML\n    asset.display_name = f\"HTML snapshot from {timestamp}\"\n    asset.file = filename\n    asset.gzip = True\n    asset.save()\n\n    asset.bookmark.latest_snapshot = asset\n    asset.bookmark.date_modified = timezone.now()\n    asset.bookmark.save()\n\n    return asset\n\n\ndef upload_asset(bookmark: Bookmark, upload_file: UploadedFile):\n    try:\n        asset = BookmarkAsset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_UPLOAD,\n            date_created=timezone.now(),\n            content_type=upload_file.content_type,\n            display_name=upload_file.name,\n            status=BookmarkAsset.STATUS_COMPLETE,\n            gzip=False,\n        )\n        name, extension = os.path.splitext(upload_file.name)\n\n        # automatically gzip the file if it is not already gzipped\n        if upload_file.content_type != \"application/gzip\":\n            filename = _generate_asset_filename(\n                asset, name, extension.lstrip(\".\") + \".gz\"\n            )\n            filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n            with gzip.open(filepath, \"wb\", compresslevel=9) as f:\n                for chunk in upload_file.chunks():\n                    f.write(chunk)\n            asset.gzip = True\n            asset.file = filename\n            asset.file_size = os.path.getsize(filepath)\n        else:\n            filename = _generate_asset_filename(asset, name, extension.lstrip(\".\"))\n            filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n            with open(filepath, \"wb\") as f:\n                for chunk in upload_file.chunks():\n                    f.write(chunk)\n            asset.file = filename\n            asset.file_size = upload_file.size\n\n        asset.save()\n\n        asset.bookmark.date_modified = timezone.now()\n        asset.bookmark.save()\n\n        logger.info(\n            f\"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}\"\n        )\n        return asset\n    except Exception as e:\n        logger.error(\n            f\"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}\",\n            exc_info=e,\n        )\n        raise e\n\n\ndef remove_asset(asset: BookmarkAsset):\n    # If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot\n    bookmark = asset.bookmark\n    if bookmark and bookmark.latest_snapshot == asset:\n        latest = (\n            BookmarkAsset.objects.filter(\n                bookmark=bookmark,\n                asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n                status=BookmarkAsset.STATUS_COMPLETE,\n            )\n            .exclude(pk=asset.pk)\n            .order_by(\"-date_created\")\n            .first()\n        )\n\n        bookmark.latest_snapshot = latest\n\n    asset.delete()\n    bookmark.date_modified = timezone.now()\n    bookmark.save()\n\n\ndef _generate_asset_filename(\n    asset: BookmarkAsset, filename: str, extension: str\n) -> str:\n    def sanitize_char(char):\n        if char.isalnum() or char in (\"-\", \"_\", \".\"):\n            return char\n        else:\n            return \"_\"\n\n    formatted_datetime = asset.date_created.strftime(\"%Y-%m-%d_%H%M%S\")\n    sanitized_filename = \"\".join(sanitize_char(char) for char in filename)\n\n    # Calculate the length of fixed parts of the final filename\n    non_filename_length = len(f\"{asset.asset_type}_{formatted_datetime}_.{extension}\")\n    # Calculate the maximum length for the dynamic part of the filename\n    max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length\n    # Truncate the filename if necessary\n    sanitized_filename = sanitized_filename[:max_filename_length]\n\n    return f\"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}\"\n"
  },
  {
    "path": "bookmarks/services/auto_tagging.py",
    "content": "import re\nfrom urllib.parse import parse_qs, urlparse\n\nimport idna\n\n\ndef get_tags(script: str, url: str):\n    parsed_url = urlparse(url.lower())\n    result = set()\n\n    if not parsed_url.hostname:\n        return result\n\n    for line in script.lower().split(\"\\n\"):\n        line = line.strip()\n\n        # Skip empty lines or lines that start with a comment\n        if not line or line.startswith(\"#\"):\n            continue\n\n        # Remove trailing comment - only if # is preceded by whitespace\n        comment_match = re.search(r\"\\s+#\", line)\n        if comment_match:\n            line = line[: comment_match.start()]\n\n        # Ignore lines that don't contain a URL and a tag\n        parts = line.split()\n        if len(parts) < 2:\n            continue\n\n        # to parse a host name from the pattern URL, ensure it has a scheme\n        pattern_url = \"//\" + re.sub(\"^https?://\", \"\", parts[0])\n        parsed_pattern = urlparse(pattern_url)\n\n        if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):\n            continue\n\n        if parsed_pattern.path and not _path_matches(\n            parsed_pattern.path, parsed_url.path\n        ):\n            continue\n\n        if parsed_pattern.query and not _qs_matches(\n            parsed_pattern.query, parsed_url.query\n        ):\n            continue\n\n        if parsed_pattern.fragment and not _fragment_matches(\n            parsed_pattern.fragment, parsed_url.fragment\n        ):\n            continue\n\n        for tag in parts[1:]:\n            result.add(tag)\n\n    return result\n\n\ndef _path_matches(expected_path: str, actual_path: str) -> bool:\n    return actual_path.startswith(expected_path)\n\n\ndef _domains_matches(expected_domain: str, actual_domain: str) -> bool:\n    expected_domain = idna.encode(expected_domain)\n    actual_domain = idna.encode(actual_domain)\n\n    return actual_domain.endswith(expected_domain)\n\n\ndef _qs_matches(expected_qs: str, actual_qs: str) -> bool:\n    expected_qs = parse_qs(expected_qs, keep_blank_values=True)\n    actual_qs = parse_qs(actual_qs, keep_blank_values=True)\n\n    for key in expected_qs:\n        if key not in actual_qs:\n            return False\n        for value in expected_qs[key]:\n            if value != \"\" and value not in actual_qs[key]:\n                return False\n\n    return True\n\n\ndef _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:\n    return actual_fragment.startswith(expected_fragment)\n"
  },
  {
    "path": "bookmarks/services/bookmarks.py",
    "content": "import logging\n\nfrom django.utils import timezone\n\nfrom bookmarks.models import Bookmark, User, parse_tag_string\nfrom bookmarks.services import auto_tagging, tasks, website_loader\nfrom bookmarks.services.tags import get_or_create_tags\n\nlogger = logging.getLogger(__name__)\n\n\ndef create_bookmark(\n    bookmark: Bookmark,\n    tag_string: str,\n    current_user: User,\n    disable_html_snapshot: bool = False,\n):\n    # If URL is already bookmarked, then update it\n    existing_bookmark: Bookmark = Bookmark.query_existing(\n        current_user, bookmark.url\n    ).first()\n\n    if existing_bookmark is not None:\n        _merge_bookmark_data(bookmark, existing_bookmark)\n        return update_bookmark(existing_bookmark, tag_string, current_user)\n\n    # Set currently logged in user as owner\n    bookmark.owner = current_user\n    # Set dates only if not already provided\n    # This allows to sync existing dates through the REST API for example\n    if not bookmark.date_added:\n        bookmark.date_added = timezone.now()\n    if not bookmark.date_modified:\n        bookmark.date_modified = timezone.now()\n    bookmark.save()\n    # Update tag list\n    _update_bookmark_tags(bookmark, tag_string, current_user)\n    bookmark.save()\n    # Create snapshot on web archive\n    tasks.create_web_archive_snapshot(current_user, bookmark, False)\n    # Load favicon\n    tasks.load_favicon(current_user, bookmark)\n    # Load preview image\n    tasks.load_preview_image(current_user, bookmark)\n    # Create HTML snapshot\n    if (\n        current_user.profile.enable_automatic_html_snapshots\n        and not disable_html_snapshot\n    ):\n        tasks.create_html_snapshot(bookmark)\n\n    return bookmark\n\n\ndef update_bookmark(bookmark: Bookmark, tag_string, current_user: User):\n    # Detect URL change\n    original_bookmark = Bookmark.objects.get(id=bookmark.id)\n    has_url_changed = original_bookmark.url != bookmark.url\n    # Update tag list\n    _update_bookmark_tags(bookmark, tag_string, current_user)\n    # Update dates\n    bookmark.date_modified = timezone.now()\n    bookmark.save()\n    # Update favicon\n    tasks.load_favicon(current_user, bookmark)\n    # Update preview image\n    tasks.load_preview_image(current_user, bookmark)\n\n    if has_url_changed:\n        # Update web archive snapshot, if URL changed\n        tasks.create_web_archive_snapshot(current_user, bookmark, True)\n\n    return bookmark\n\n\ndef enhance_with_website_metadata(bookmark: Bookmark):\n    metadata = website_loader.load_website_metadata(bookmark.url)\n    if not bookmark.title:\n        bookmark.title = metadata.title or \"\"\n\n    if not bookmark.description:\n        bookmark.description = metadata.description or \"\"\n\n    bookmark.save()\n\n\ndef archive_bookmark(bookmark: Bookmark):\n    bookmark.is_archived = True\n    bookmark.date_modified = timezone.now()\n    bookmark.save()\n    return bookmark\n\n\ndef archive_bookmarks(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(\n        is_archived=True, date_modified=timezone.now()\n    )\n\n\ndef unarchive_bookmark(bookmark: Bookmark):\n    bookmark.is_archived = False\n    bookmark.date_modified = timezone.now()\n    bookmark.save()\n    return bookmark\n\n\ndef unarchive_bookmarks(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(\n        is_archived=False, date_modified=timezone.now()\n    )\n\n\ndef delete_bookmarks(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).delete()\n\n\ndef tag_bookmarks(bookmark_ids: [int | str], tag_string: str, current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n    owned_bookmark_ids = Bookmark.objects.filter(\n        owner=current_user, id__in=sanitized_bookmark_ids\n    ).values_list(\"id\", flat=True)\n    tag_names = parse_tag_string(tag_string)\n    tags = get_or_create_tags(tag_names, current_user)\n\n    BookmarkToTagRelationShip = Bookmark.tags.through\n    relationships = []\n    for tag in tags:\n        for bookmark_id in owned_bookmark_ids:\n            relationships.append(\n                BookmarkToTagRelationShip(bookmark_id=bookmark_id, tag=tag)\n            )\n\n    # Insert all bookmark -> tag associations at once, should ignore errors if association already exists\n    BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)\n    Bookmark.objects.filter(id__in=owned_bookmark_ids).update(\n        date_modified=timezone.now()\n    )\n\n\ndef untag_bookmarks(bookmark_ids: [int | str], tag_string: str, current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n    owned_bookmark_ids = Bookmark.objects.filter(\n        owner=current_user, id__in=sanitized_bookmark_ids\n    ).values_list(\"id\", flat=True)\n    tag_names = parse_tag_string(tag_string)\n    tags = get_or_create_tags(tag_names, current_user)\n\n    BookmarkToTagRelationShip = Bookmark.tags.through\n    for tag in tags:\n        # Remove all bookmark -> tag associations for the owned bookmarks and the current tag\n        BookmarkToTagRelationShip.objects.filter(\n            bookmark_id__in=owned_bookmark_ids, tag=tag\n        ).delete()\n\n    Bookmark.objects.filter(id__in=owned_bookmark_ids).update(\n        date_modified=timezone.now()\n    )\n\n\ndef mark_bookmarks_as_read(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(\n        unread=False, date_modified=timezone.now()\n    )\n\n\ndef mark_bookmarks_as_unread(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(\n        unread=True, date_modified=timezone.now()\n    )\n\n\ndef share_bookmarks(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(\n        shared=True, date_modified=timezone.now()\n    )\n\n\ndef unshare_bookmarks(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n\n    Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(\n        shared=False, date_modified=timezone.now()\n    )\n\n\ndef refresh_bookmarks_metadata(bookmark_ids: [int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n    owned_bookmarks = Bookmark.objects.filter(\n        owner=current_user, id__in=sanitized_bookmark_ids\n    )\n\n    for bookmark in owned_bookmarks:\n        tasks.refresh_metadata(bookmark)\n        tasks.load_preview_image(current_user, bookmark)\n\n\ndef create_html_snapshots(bookmark_ids: list[int | str], current_user: User):\n    sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)\n    owned_bookmarks = Bookmark.objects.filter(\n        owner=current_user, id__in=sanitized_bookmark_ids\n    )\n\n    tasks.create_html_snapshots(owned_bookmarks)\n\n\ndef _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):\n    to_bookmark.title = from_bookmark.title\n    to_bookmark.description = from_bookmark.description\n    to_bookmark.notes = from_bookmark.notes\n    to_bookmark.unread = from_bookmark.unread\n    to_bookmark.shared = from_bookmark.shared\n\n\ndef _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):\n    tag_names = parse_tag_string(tag_string)\n\n    if user.profile.auto_tagging_rules:\n        try:\n            auto_tag_names = auto_tagging.get_tags(\n                user.profile.auto_tagging_rules, bookmark.url\n            )\n            for auto_tag_name in auto_tag_names:\n                if auto_tag_name not in tag_names:\n                    tag_names.append(auto_tag_name)\n        except Exception as e:\n            logger.error(\n                f\"Failed to auto-tag bookmark. url={bookmark.url}\",\n                exc_info=e,\n            )\n\n    tags = get_or_create_tags(tag_names, user)\n    bookmark.tags.set(tags)\n\n\ndef _sanitize_id_list(bookmark_ids: [int | str]) -> [int]:\n    # Convert string ids to int if necessary\n    return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids]\n"
  },
  {
    "path": "bookmarks/services/bundles.py",
    "content": "from django.db.models import Max\n\nfrom bookmarks.models import BookmarkBundle, User\n\n\ndef create_bundle(bundle: BookmarkBundle, current_user: User):\n    bundle.owner = current_user\n    if bundle.order is None:\n        max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(\n            Max(\"order\", default=-1)\n        )\n        bundle.order = max_order_result[\"order__max\"] + 1\n    bundle.save()\n    return bundle\n\n\ndef move_bundle(bundle_to_move: BookmarkBundle, new_order: int):\n    user_bundles = list(\n        BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by(\"order\")\n    )\n\n    if new_order != user_bundles.index(bundle_to_move):\n        user_bundles.remove(bundle_to_move)\n        user_bundles.insert(new_order, bundle_to_move)\n        for bundle_index, bundle in enumerate(user_bundles):\n            bundle.order = bundle_index\n\n        BookmarkBundle.objects.bulk_update(user_bundles, [\"order\"])\n\n\ndef delete_bundle(bundle: BookmarkBundle):\n    bundle.delete()\n\n    user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by(\"order\")\n    for index, user_bundle in enumerate(user_bundles):\n        user_bundle.order = index\n    BookmarkBundle.objects.bulk_update(user_bundles, [\"order\"])\n"
  },
  {
    "path": "bookmarks/services/exporter.py",
    "content": "import html\n\nfrom bookmarks.models import Bookmark\n\nBookmarkDocument = list[str]\n\n\ndef export_netscape_html(bookmarks: list[Bookmark]):\n    doc = []\n    append_header(doc)\n    append_list_start(doc)\n    [append_bookmark(doc, bookmark) for bookmark in bookmarks]\n    append_list_end(doc)\n\n    return \"\\n\\r\".join(doc)\n\n\ndef append_header(doc: BookmarkDocument):\n    doc.append(\"<!DOCTYPE NETSCAPE-Bookmark-file-1>\")\n    doc.append('<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">')\n    doc.append(\"<TITLE>Bookmarks</TITLE>\")\n    doc.append(\"<H1>Bookmarks</H1>\")\n\n\ndef append_list_start(doc: BookmarkDocument):\n    doc.append(\"<DL><p>\")\n\n\ndef append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):\n    url = bookmark.url\n    title = html.escape(bookmark.resolved_title or \"\")\n    desc = html.escape(bookmark.resolved_description or \"\")\n    if bookmark.notes:\n        desc += f\"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]\"\n    tag_names = bookmark.tag_names\n    if bookmark.is_archived:\n        tag_names.append(\"linkding:bookmarks.archived\")\n    tags = \",\".join(tag_names)\n    toread = \"1\" if bookmark.unread else \"0\"\n    private = \"0\" if bookmark.shared else \"1\"\n    added = int(bookmark.date_added.timestamp())\n    modified = int(bookmark.date_modified.timestamp())\n\n    doc.append(\n        f'<DT><A HREF=\"{url}\" ADD_DATE=\"{added}\" LAST_MODIFIED=\"{modified}\" PRIVATE=\"{private}\" TOREAD=\"{toread}\" TAGS=\"{tags}\">{title}</A>'\n    )\n\n    if desc:\n        doc.append(f\"<DD>{desc}\")\n\n\ndef append_list_end(doc: BookmarkDocument):\n    doc.append(\"</DL><p>\")\n"
  },
  {
    "path": "bookmarks/services/favicon_loader.py",
    "content": "import logging\nimport mimetypes\nimport os.path\nimport re\nimport time\nfrom pathlib import Path\nfrom urllib.parse import urlparse\n\nimport requests\nfrom django.conf import settings\n\nmax_file_age = 60 * 60 * 24  # 1 day\n\nlogger = logging.getLogger(__name__)\n\n# register mime type for .ico files, which is not included in the default\n# mimetypes of the Docker image\nmimetypes.add_type(\"image/x-icon\", \".ico\")\n\n\ndef _ensure_favicon_folder():\n    Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)\n\n\ndef _url_to_filename(url: str) -> str:\n    return re.sub(r\"\\W+\", \"_\", url)\n\n\ndef _get_url_parameters(url: str) -> dict:\n    parsed_uri = urlparse(url)\n    return {\n        # https://example.com/foo?bar -> https://example.com\n        \"url\": f\"{parsed_uri.scheme}://{parsed_uri.hostname}\",\n        # https://example.com/foo?bar -> example.com\n        \"domain\": parsed_uri.hostname,\n    }\n\n\ndef _get_favicon_path(favicon_file: str) -> Path:\n    return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))\n\n\ndef _check_existing_favicon(favicon_name: str):\n    # return existing file if a file with the same name, ignoring extension,\n    # exists and is not stale\n    for filename in os.listdir(settings.LD_FAVICON_FOLDER):\n        file_base_name, _ = os.path.splitext(filename)\n        if file_base_name == favicon_name:\n            favicon_path = _get_favicon_path(filename)\n            return filename if not _is_stale(favicon_path) else None\n    return None\n\n\ndef _is_stale(path: Path) -> bool:\n    stat = path.stat()\n    file_age = time.time() - stat.st_mtime\n    return file_age >= max_file_age\n\n\ndef load_favicon(url: str) -> str:\n    url_parameters = _get_url_parameters(url)\n\n    # Create favicon folder if not exists\n    _ensure_favicon_folder()\n    # Use scheme+hostname as favicon filename to reuse icon for all pages on the same domain\n    favicon_name = _url_to_filename(url_parameters[\"url\"])\n    favicon_file = _check_existing_favicon(favicon_name)\n\n    if not favicon_file:\n        # Load favicon from provider, save to file\n        favicon_url = settings.LD_FAVICON_PROVIDER.format(**url_parameters)\n        logger.debug(f\"Loading favicon from: {favicon_url}\")\n        with requests.get(favicon_url, stream=True) as response:\n            content_type = response.headers[\"Content-Type\"]\n            file_extension = mimetypes.guess_extension(content_type)\n            favicon_file = f\"{favicon_name}{file_extension}\"\n            favicon_path = _get_favicon_path(favicon_file)\n            with open(favicon_path, \"wb\") as file:\n                for chunk in response.iter_content(chunk_size=8192):\n                    file.write(chunk)\n        logger.debug(f\"Saved favicon as: {favicon_path}\")\n\n    return favicon_file\n"
  },
  {
    "path": "bookmarks/services/importer.py",
    "content": "import logging\nfrom dataclasses import dataclass\n\nfrom django.contrib.auth.models import User\nfrom django.utils import timezone\n\nfrom bookmarks.models import Bookmark, Tag\nfrom bookmarks.services import tasks\nfrom bookmarks.services.parser import NetscapeBookmark, parse\nfrom bookmarks.utils import normalize_url, parse_timestamp\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass ImportResult:\n    total: int = 0\n    success: int = 0\n    failed: int = 0\n\n\n@dataclass\nclass ImportOptions:\n    map_private_flag: bool = False\n\n\nclass TagCache:\n    def __init__(self, user: User):\n        self.user = user\n        self.cache = dict()\n        # Init cache with all existing tags for that user\n        tags = Tag.objects.filter(owner=user)\n        for tag in tags:\n            self.put(tag)\n\n    def get(self, tag_name: str):\n        tag_name_lowercase = tag_name.lower()\n        if tag_name_lowercase in self.cache:\n            return self.cache[tag_name_lowercase]\n        else:\n            return None\n\n    def get_all(self, tag_names: list[str]):\n        result = []\n        for tag_name in tag_names:\n            tag = self.get(tag_name)\n            # Tag may not have been created if tag name exceeded maximum length\n            # Prevent returning duplicates\n            if tag and tag not in result:\n                result.append(tag)\n\n        return result\n\n    def put(self, tag: Tag):\n        self.cache[tag.name.lower()] = tag\n\n\ndef import_netscape_html(\n    html: str, user: User, options: ImportOptions | None = None\n) -> ImportResult:\n    if options is None:\n        options = ImportOptions()\n    result = ImportResult()\n    import_start = timezone.now()\n\n    try:\n        netscape_bookmarks = parse(html)\n    except Exception:\n        logging.exception(\"Could not read bookmarks file.\")\n        raise\n\n    parse_end = timezone.now()\n    logger.debug(f\"Parse duration: {parse_end - import_start}\")\n\n    # Create and cache all tags beforehand\n    _create_missing_tags(netscape_bookmarks, user)\n    tag_cache = TagCache(user)\n\n    # Split bookmarks to import into batches, to keep memory usage for bulk operations manageable\n    batches = _get_batches(netscape_bookmarks, 200)\n    for batch in batches:\n        _import_batch(batch, user, options, tag_cache, result)\n\n    # Load favicons for newly imported bookmarks\n    tasks.schedule_bookmarks_without_favicons(user)\n    # Load previews for newly imported bookmarks\n    tasks.schedule_bookmarks_without_previews(user)\n\n    end = timezone.now()\n    logger.debug(f\"Import duration: {end - import_start}\")\n\n    return result\n\n\ndef _create_missing_tags(netscape_bookmarks: list[NetscapeBookmark], user: User):\n    tag_cache = TagCache(user)\n    tags_to_create = []\n\n    for netscape_bookmark in netscape_bookmarks:\n        for tag_name in netscape_bookmark.tag_names:\n            # Skip tag names that exceed the maximum allowed length\n            if len(tag_name) > 64:\n                logger.warning(\n                    f\"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters\"\n                )\n                continue\n\n            tag = tag_cache.get(tag_name)\n            if not tag:\n                tag = Tag(name=tag_name, owner=user)\n                tag.date_added = timezone.now()\n                tags_to_create.append(tag)\n                tag_cache.put(tag)\n\n    Tag.objects.bulk_create(tags_to_create)\n\n\ndef _get_batches(items: list, batch_size: int):\n    batches = []\n    offset = 0\n    num_items = len(items)\n\n    while offset < num_items:\n        batch = items[offset : min(offset + batch_size, num_items)]\n        if len(batch) > 0:\n            batches.append(batch)\n        offset = offset + batch_size\n\n    return batches\n\n\ndef _import_batch(\n    netscape_bookmarks: list[NetscapeBookmark],\n    user: User,\n    options: ImportOptions,\n    tag_cache: TagCache,\n    result: ImportResult,\n):\n    # Query existing bookmarks\n    batch_urls = [bookmark.href for bookmark in netscape_bookmarks]\n    existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)\n\n    # Create or update bookmarks from parsed Netscape bookmarks\n    bookmarks_to_create = []\n    bookmarks_to_update = []\n\n    for netscape_bookmark in netscape_bookmarks:\n        result.total = result.total + 1\n        try:\n            # Lookup existing bookmark by URL, or create new bookmark if there is no bookmark for that URL yet\n            bookmark = next(\n                (\n                    bookmark\n                    for bookmark in existing_bookmarks\n                    if bookmark.url == netscape_bookmark.href\n                ),\n                None,\n            )\n            if not bookmark:\n                bookmark = Bookmark(owner=user)\n                is_update = False\n            else:\n                is_update = True\n            # Copy data from parsed bookmark\n            _copy_bookmark_data(netscape_bookmark, bookmark, options)\n            # Validate bookmark fields, exclude owner to prevent n+1 database query,\n            # also there is no specific validation on owner\n            bookmark.clean_fields(exclude=[\"owner\"])\n            # Schedule for update or insert\n            if is_update:\n                bookmarks_to_update.append(bookmark)\n            else:\n                bookmarks_to_create.append(bookmark)\n\n            result.success = result.success + 1\n        except Exception:\n            shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + \"...\"\n            logging.exception(\"Error importing bookmark: \" + shortened_bookmark_tag_str)\n            result.failed = result.failed + 1\n\n    # Bulk update bookmarks in DB\n    Bookmark.objects.bulk_update(\n        bookmarks_to_update,\n        [\n            \"url\",\n            \"url_normalized\",\n            \"date_added\",\n            \"date_modified\",\n            \"unread\",\n            \"shared\",\n            \"title\",\n            \"description\",\n            \"notes\",\n            \"owner\",\n        ],\n    )\n    # Bulk insert new bookmarks into DB\n    Bookmark.objects.bulk_create(bookmarks_to_create)\n\n    # Bulk assign tags\n    # In Django 3, bulk_create does not return the auto-generated IDs when bulk inserting,\n    # so we have to reload the inserted bookmarks, and match them to the parsed bookmarks by URL\n    existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)\n\n    BookmarkToTagRelationShip = Bookmark.tags.through\n    relationships = []\n\n    for netscape_bookmark in netscape_bookmarks:\n        # Lookup bookmark by URL again\n        bookmark = next(\n            (\n                bookmark\n                for bookmark in existing_bookmarks\n                if bookmark.url == netscape_bookmark.href\n            ),\n            None,\n        )\n\n        if not bookmark:\n            # Something is wrong, we should have just created this bookmark\n            shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + \"...\"\n            logging.warning(\n                f\"Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL.\"\n            )\n            continue\n\n        # Get tag models by string, schedule inserts for bookmark -> tag associations\n        tags = tag_cache.get_all(netscape_bookmark.tag_names)\n        for tag in tags:\n            relationships.append(BookmarkToTagRelationShip(bookmark=bookmark, tag=tag))\n\n    # Insert all bookmark -> tag associations at once, should ignore errors if association already exists\n    BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)\n\n\ndef _copy_bookmark_data(\n    netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions\n):\n    bookmark.url = netscape_bookmark.href\n    bookmark.url_normalized = normalize_url(bookmark.url)\n    if netscape_bookmark.date_added:\n        bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)\n    else:\n        bookmark.date_added = timezone.now()\n    if netscape_bookmark.date_modified:\n        bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)\n    else:\n        bookmark.date_modified = bookmark.date_added\n    bookmark.unread = netscape_bookmark.to_read\n    if netscape_bookmark.title:\n        bookmark.title = netscape_bookmark.title\n    if netscape_bookmark.description:\n        bookmark.description = netscape_bookmark.description\n    if netscape_bookmark.notes:\n        bookmark.notes = netscape_bookmark.notes\n    if options.map_private_flag and not netscape_bookmark.private:\n        bookmark.shared = True\n    if netscape_bookmark.archived:\n        bookmark.is_archived = True\n"
  },
  {
    "path": "bookmarks/services/monolith.py",
    "content": "import gzip\nimport os\nimport shutil\nimport subprocess\n\nfrom django.conf import settings\n\n\nclass MonolithError(Exception):\n    pass\n\n\n# Monolith isn't used at the moment, as the local snapshot implementation\n# switched to single-file after the prototype. Keeping this around in case\n# it turns out to be useful in the future.\ndef create_snapshot(url: str, filepath: str):\n    monolith_path = settings.LD_MONOLITH_PATH\n    monolith_options = settings.LD_MONOLITH_OPTIONS\n    temp_filepath = filepath + \".tmp\"\n\n    try:\n        command = f\"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}\"\n        subprocess.run(command, check=True, shell=True)\n\n        with (\n            open(temp_filepath, \"rb\") as raw_file,\n            gzip.open(filepath, \"wb\") as gz_file,\n        ):\n            shutil.copyfileobj(raw_file, gz_file)\n\n        os.remove(temp_filepath)\n    except subprocess.CalledProcessError as error:\n        raise MonolithError(f\"Failed to create snapshot: {error.stderr}\") from error\n"
  },
  {
    "path": "bookmarks/services/parser.py",
    "content": "import contextlib\nfrom dataclasses import dataclass\nfrom html.parser import HTMLParser\n\nfrom bookmarks.models import parse_tag_string\n\n\n@dataclass\nclass NetscapeBookmark:\n    href: str\n    title: str\n    description: str\n    notes: str\n    date_added: str\n    date_modified: str\n    tag_names: list[str]\n    to_read: bool\n    private: bool\n    archived: bool\n\n\nclass BookmarkParser(HTMLParser):\n    def __init__(self):\n        super().__init__()\n        self.bookmarks = []\n\n        self.current_tag = None\n        self.bookmark = None\n        self.href = \"\"\n        self.add_date = \"\"\n        self.last_modified = \"\"\n        self.tags = \"\"\n        self.title = \"\"\n        self.description = \"\"\n        self.notes = \"\"\n        self.toread = \"\"\n        self.private = \"\"\n\n    def handle_starttag(self, tag: str, attrs: list):\n        name = \"handle_start_\" + tag.lower()\n        if name in dir(self):\n            getattr(self, name)({k.lower(): v for k, v in attrs})\n        self.current_tag = tag\n\n    def handle_endtag(self, tag: str):\n        name = \"handle_end_\" + tag.lower()\n        if name in dir(self):\n            getattr(self, name)()\n        self.current_tag = None\n\n    def handle_data(self, data):\n        name = f\"handle_{self.current_tag}_data\"\n        if name in dir(self):\n            getattr(self, name)(data)\n\n    def handle_end_dl(self):\n        self.add_bookmark()\n\n    def handle_start_dt(self, attrs: dict[str, str]):\n        self.add_bookmark()\n\n    def handle_start_a(self, attrs: dict[str, str]):\n        vars(self).update(attrs)\n        tag_names = parse_tag_string(self.tags)\n        archived = \"linkding:bookmarks.archived\" in self.tags\n        with contextlib.suppress(ValueError):\n            tag_names.remove(\"linkding:bookmarks.archived\")\n\n        self.bookmark = NetscapeBookmark(\n            href=self.href,\n            title=\"\",\n            description=\"\",\n            notes=\"\",\n            date_added=self.add_date,\n            date_modified=self.last_modified,\n            tag_names=tag_names,\n            to_read=self.toread == \"1\",\n            # Mark as private by default, also when attribute is not specified\n            private=self.private != \"0\",\n            archived=archived,\n        )\n\n    def handle_a_data(self, data):\n        self.title = data.strip()\n\n    def handle_dd_data(self, data):\n        desc = data.strip()\n        if \"[linkding-notes]\" in desc:\n            self.notes = desc.split(\"[linkding-notes]\")[1].split(\"[/linkding-notes]\")[0]\n        self.description = desc.split(\"[linkding-notes]\")[0]\n\n    def add_bookmark(self):\n        if self.bookmark:\n            self.bookmark.title = self.title\n            self.bookmark.description = self.description\n            self.bookmark.notes = self.notes\n            self.bookmarks.append(self.bookmark)\n        self.bookmark = None\n        self.href = \"\"\n        self.add_date = \"\"\n        self.last_modified = \"\"\n        self.tags = \"\"\n        self.title = \"\"\n        self.description = \"\"\n        self.notes = \"\"\n        self.toread = \"\"\n        self.private = \"\"\n\n\ndef parse(html: str) -> list[NetscapeBookmark]:\n    parser = BookmarkParser()\n    parser.feed(html)\n    return parser.bookmarks\n"
  },
  {
    "path": "bookmarks/services/preview_image_loader.py",
    "content": "import hashlib\nimport logging\nimport mimetypes\nimport os.path\nfrom pathlib import Path\n\nimport requests\nfrom django.conf import settings\n\nfrom bookmarks.services import website_loader\n\nlogger = logging.getLogger(__name__)\n\n\ndef _ensure_preview_folder():\n    Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True)\n\n\ndef _url_to_filename(preview_image: str) -> str:\n    return hashlib.md5(preview_image.encode()).hexdigest()\n\n\ndef _get_image_path(preview_image_file: str) -> Path:\n    return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file))\n\n\ndef load_preview_image(url: str) -> str | None:\n    _ensure_preview_folder()\n\n    metadata = website_loader.load_website_metadata(url)\n    if not metadata.preview_image:\n        logger.debug(f\"Could not find preview image in metadata: {url}\")\n        return None\n\n    image_url = metadata.preview_image\n\n    logger.debug(f\"Loading preview image: {image_url}\")\n    with requests.get(image_url, stream=True) as response:\n        if response.status_code < 200 or response.status_code >= 300:\n            logger.debug(\n                f\"Bad response status code for preview image: {image_url} status_code={response.status_code}\"\n            )\n            return None\n\n        if \"Content-Length\" not in response.headers:\n            logger.debug(f\"Empty Content-Length for preview image: {image_url}\")\n            return None\n\n        content_length = int(response.headers[\"Content-Length\"])\n        if content_length > settings.LD_PREVIEW_MAX_SIZE:\n            logger.debug(\n                f\"Content-Length exceeds LD_PREVIEW_MAX_SIZE: {image_url} length={content_length}\"\n            )\n            return None\n\n        if \"Content-Type\" not in response.headers:\n            logger.debug(f\"Empty Content-Type for preview image: {image_url}\")\n            return None\n\n        content_type = response.headers[\"Content-Type\"].split(\";\", 1)[0]\n        file_extension = mimetypes.guess_extension(content_type)\n\n        if file_extension not in settings.LD_PREVIEW_ALLOWED_EXTENSIONS:\n            logger.debug(\n                f\"Unsupported Content-Type for preview image: {image_url} content_type={content_type}\"\n            )\n            return None\n\n        preview_image_hash = _url_to_filename(url)\n        preview_image_file = f\"{preview_image_hash}{file_extension}\"\n        preview_image_path = _get_image_path(preview_image_file)\n\n        with open(preview_image_path, \"wb\") as file:\n            downloaded = 0\n            for chunk in response.iter_content(chunk_size=8192):\n                downloaded += len(chunk)\n                if downloaded > content_length:\n                    logger.debug(\n                        f\"Content-Length mismatch for preview image: {image_url} length={content_length} downloaded={downloaded}\"\n                    )\n                    file.close()\n                    preview_image_path.unlink()\n                    return None\n\n                file.write(chunk)\n\n    logger.debug(f\"Saved preview image as: {preview_image_path}\")\n\n    return preview_image_file\n"
  },
  {
    "path": "bookmarks/services/search_query_parser.py",
    "content": "from dataclasses import dataclass\nfrom enum import Enum\n\nfrom bookmarks.models import UserProfile\n\n\nclass TokenType(Enum):\n    TERM = \"TERM\"\n    TAG = \"TAG\"\n    SPECIAL_KEYWORD = \"SPECIAL_KEYWORD\"\n    AND = \"AND\"\n    OR = \"OR\"\n    NOT = \"NOT\"\n    LPAREN = \"LPAREN\"\n    RPAREN = \"RPAREN\"\n    EOF = \"EOF\"\n\n\n@dataclass\nclass Token:\n    type: TokenType\n    value: str\n    position: int\n\n\nclass SearchQueryTokenizer:\n    def __init__(self, query: str):\n        self.query = query.strip()\n        self.position = 0\n        self.current_char = self.query[0] if self.query else None\n\n    def advance(self):\n        \"\"\"Move to the next character in the query.\"\"\"\n        self.position += 1\n        if self.position >= len(self.query):\n            self.current_char = None\n        else:\n            self.current_char = self.query[self.position]\n\n    def skip_whitespace(self):\n        \"\"\"Skip whitespace characters.\"\"\"\n        while self.current_char and self.current_char.isspace():\n            self.advance()\n\n    def read_term(self) -> str:\n        \"\"\"Read a search term (sequence of non-whitespace, non-special characters).\"\"\"\n        term = \"\"\n\n        while (\n            self.current_char\n            and not self.current_char.isspace()\n            and self.current_char not in \"()\\\"'#!\"\n        ):\n            term += self.current_char\n            self.advance()\n\n        return term\n\n    def read_quoted_string(self, quote_char: str) -> str:\n        \"\"\"Read a quoted string, handling escaped quotes.\"\"\"\n        content = \"\"\n        self.advance()  # skip opening quote\n\n        while self.current_char and self.current_char != quote_char:\n            if self.current_char == \"\\\\\":\n                # Handle escaped characters\n                self.advance()\n                if self.current_char:\n                    if self.current_char == \"n\":\n                        content += \"\\n\"\n                    elif self.current_char == \"t\":\n                        content += \"\\t\"\n                    elif self.current_char == \"r\":\n                        content += \"\\r\"\n                    elif self.current_char == \"\\\\\":\n                        content += \"\\\\\"\n                    elif self.current_char == quote_char:\n                        content += quote_char\n                    else:\n                        # For any other escaped character, just include it as-is\n                        content += self.current_char\n                    self.advance()\n            else:\n                content += self.current_char\n                self.advance()\n\n        if self.current_char == quote_char:\n            self.advance()  # skip closing quote\n        else:\n            # Unclosed quote - we could raise an error here, but let's be lenient\n            # and treat it as if the quote was closed at the end\n            pass\n\n        return content\n\n    def read_tag(self) -> str:\n        \"\"\"Read a tag (starts with # and continues until whitespace or special chars).\"\"\"\n        tag = \"\"\n        self.advance()  # skip the # character\n\n        while (\n            self.current_char\n            and not self.current_char.isspace()\n            and self.current_char not in \"()\\\"'\"\n        ):\n            tag += self.current_char\n            self.advance()\n\n        return tag\n\n    def read_special_keyword(self) -> str:\n        \"\"\"Read a special keyword (starts with ! and continues until whitespace or special chars).\"\"\"\n        keyword = \"\"\n        self.advance()  # skip the ! character\n\n        while (\n            self.current_char\n            and not self.current_char.isspace()\n            and self.current_char not in \"()\\\"'\"\n        ):\n            keyword += self.current_char\n            self.advance()\n\n        return keyword\n\n    def tokenize(self) -> list[Token]:\n        \"\"\"Convert the query string into a list of tokens.\"\"\"\n        tokens = []\n\n        while self.current_char:\n            self.skip_whitespace()\n\n            if not self.current_char:\n                break\n\n            start_pos = self.position\n\n            if self.current_char == \"(\":\n                tokens.append(Token(TokenType.LPAREN, \"(\", start_pos))\n                self.advance()\n            elif self.current_char == \")\":\n                tokens.append(Token(TokenType.RPAREN, \")\", start_pos))\n                self.advance()\n            elif self.current_char in \"\\\"'\":\n                # Read a quoted string - always treated as a term\n                quote_char = self.current_char\n                term = self.read_quoted_string(quote_char)\n                tokens.append(Token(TokenType.TERM, term, start_pos))\n            elif self.current_char == \"#\":\n                # Read a tag\n                tag = self.read_tag()\n                # Only add the tag token if it has content\n                if tag:\n                    tokens.append(Token(TokenType.TAG, tag, start_pos))\n            elif self.current_char == \"!\":\n                # Read a special keyword\n                keyword = self.read_special_keyword()\n                # Only add the keyword token if it has content\n                if keyword:\n                    tokens.append(Token(TokenType.SPECIAL_KEYWORD, keyword, start_pos))\n            else:\n                # Read a term and check if it's an operator\n                term = self.read_term()\n                term_lower = term.lower()\n\n                if term_lower == \"and\":\n                    tokens.append(Token(TokenType.AND, term, start_pos))\n                elif term_lower == \"or\":\n                    tokens.append(Token(TokenType.OR, term, start_pos))\n                elif term_lower == \"not\":\n                    tokens.append(Token(TokenType.NOT, term, start_pos))\n                else:\n                    tokens.append(Token(TokenType.TERM, term, start_pos))\n\n        tokens.append(Token(TokenType.EOF, \"\", len(self.query)))\n        return tokens\n\n\nclass SearchExpression:\n    pass\n\n\n@dataclass\nclass TermExpression(SearchExpression):\n    term: str\n\n\n@dataclass\nclass TagExpression(SearchExpression):\n    tag: str\n\n\n@dataclass\nclass SpecialKeywordExpression(SearchExpression):\n    keyword: str\n\n\n@dataclass\nclass AndExpression(SearchExpression):\n    left: SearchExpression\n    right: SearchExpression\n\n\n@dataclass\nclass OrExpression(SearchExpression):\n    left: SearchExpression\n    right: SearchExpression\n\n\n@dataclass\nclass NotExpression(SearchExpression):\n    operand: SearchExpression\n\n\nclass SearchQueryParseError(Exception):\n    def __init__(self, message: str, position: int):\n        self.message = message\n        self.position = position\n        super().__init__(f\"{message} at position {position}\")\n\n\nclass SearchQueryParser:\n    def __init__(self, tokens: list[Token]):\n        self.tokens = tokens\n        self.position = 0\n        self.current_token = tokens[0] if tokens else Token(TokenType.EOF, \"\", 0)\n\n    def advance(self):\n        \"\"\"Move to the next token.\"\"\"\n        if self.position < len(self.tokens) - 1:\n            self.position += 1\n            self.current_token = self.tokens[self.position]\n\n    def consume(self, expected_type: TokenType) -> Token:\n        \"\"\"Consume a token of the expected type or raise an error.\"\"\"\n        if self.current_token.type == expected_type:\n            token = self.current_token\n            self.advance()\n            return token\n        else:\n            raise SearchQueryParseError(\n                f\"Expected {expected_type.value}, got {self.current_token.type.value}\",\n                self.current_token.position,\n            )\n\n    def parse(self) -> SearchExpression | None:\n        \"\"\"Parse the tokens into an AST.\"\"\"\n        if not self.tokens or (\n            len(self.tokens) == 1 and self.tokens[0].type == TokenType.EOF\n        ):\n            return None\n\n        expr = self.parse_or_expression()\n\n        if self.current_token.type != TokenType.EOF:\n            raise SearchQueryParseError(\n                f\"Unexpected token {self.current_token.type.value}\",\n                self.current_token.position,\n            )\n\n        return expr\n\n    def parse_or_expression(self) -> SearchExpression:\n        \"\"\"Parse OR expressions (lowest precedence).\"\"\"\n        left = self.parse_and_expression()\n\n        while self.current_token.type == TokenType.OR:\n            self.advance()  # consume OR\n            right = self.parse_and_expression()\n            left = OrExpression(left, right)\n\n        return left\n\n    def parse_and_expression(self) -> SearchExpression:\n        \"\"\"Parse AND expressions (medium precedence), including implicit AND.\"\"\"\n        left = self.parse_not_expression()\n\n        while self.current_token.type == TokenType.AND or self.current_token.type in [\n            TokenType.TERM,\n            TokenType.TAG,\n            TokenType.SPECIAL_KEYWORD,\n            TokenType.LPAREN,\n            TokenType.NOT,\n        ]:\n            if self.current_token.type == TokenType.AND:\n                self.advance()  # consume explicit AND\n            # else: implicit AND (don't advance token)\n\n            right = self.parse_not_expression()\n            left = AndExpression(left, right)\n\n        return left\n\n    def parse_not_expression(self) -> SearchExpression:\n        \"\"\"Parse NOT expressions (high precedence).\"\"\"\n        if self.current_token.type == TokenType.NOT:\n            self.advance()  # consume NOT\n            operand = self.parse_not_expression()  # right associative\n            return NotExpression(operand)\n\n        return self.parse_primary_expression()\n\n    def parse_primary_expression(self) -> SearchExpression:\n        \"\"\"Parse primary expressions (terms, tags, special keywords, and parenthesized expressions).\"\"\"\n        if self.current_token.type == TokenType.TERM:\n            term = self.current_token.value\n            self.advance()\n            return TermExpression(term)\n        elif self.current_token.type == TokenType.TAG:\n            tag = self.current_token.value\n            self.advance()\n            return TagExpression(tag)\n        elif self.current_token.type == TokenType.SPECIAL_KEYWORD:\n            keyword = self.current_token.value\n            self.advance()\n            return SpecialKeywordExpression(keyword)\n        elif self.current_token.type == TokenType.LPAREN:\n            self.advance()  # consume (\n            expr = self.parse_or_expression()\n            self.consume(TokenType.RPAREN)  # consume )\n            return expr\n        else:\n            raise SearchQueryParseError(\n                f\"Unexpected token {self.current_token.type.value}\",\n                self.current_token.position,\n            )\n\n\ndef parse_search_query(query: str) -> SearchExpression | None:\n    if not query or not query.strip():\n        return None\n\n    tokenizer = SearchQueryTokenizer(query)\n    tokens = tokenizer.tokenize()\n    parser = SearchQueryParser(tokens)\n    return parser.parse()\n\n\ndef _needs_parentheses(expr: SearchExpression, parent_type: type) -> bool:\n    if isinstance(expr, OrExpression) and parent_type == AndExpression:\n        return True\n    # AndExpression or OrExpression needs parentheses when inside NotExpression\n    return (\n        isinstance(expr, (AndExpression, OrExpression)) and parent_type == NotExpression\n    )\n\n\ndef _is_simple_expression(expr: SearchExpression) -> bool:\n    \"\"\"Check if an expression is simple (term, tag, or keyword).\"\"\"\n    return isinstance(expr, (TermExpression, TagExpression, SpecialKeywordExpression))\n\n\ndef _expression_to_string(expr: SearchExpression, parent_type: type = None) -> str:\n    if isinstance(expr, TermExpression):\n        # Quote terms if they contain spaces or special characters\n        if \" \" in expr.term or any(c in expr.term for c in [\"(\", \")\", '\"', \"'\"]):\n            # Escape any quotes in the term\n            escaped = expr.term.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n            return f'\"{escaped}\"'\n        return expr.term\n\n    elif isinstance(expr, TagExpression):\n        return f\"#{expr.tag}\"\n\n    elif isinstance(expr, SpecialKeywordExpression):\n        return f\"!{expr.keyword}\"\n\n    elif isinstance(expr, NotExpression):\n        # Don't pass parent type to children\n        operand_str = _expression_to_string(expr.operand, None)\n        # Add parentheses if the operand is a binary operation\n        if isinstance(expr.operand, (AndExpression, OrExpression)):\n            return f\"not ({operand_str})\"\n        return f\"not {operand_str}\"\n\n    elif isinstance(expr, AndExpression):\n        # Don't pass parent type to children - they'll add their own parens only if needed\n        left_str = _expression_to_string(expr.left, None)\n        right_str = _expression_to_string(expr.right, None)\n\n        # Add parentheses to children if needed for precedence\n        if _needs_parentheses(expr.left, AndExpression):\n            left_str = f\"({left_str})\"\n        if _needs_parentheses(expr.right, AndExpression):\n            right_str = f\"({right_str})\"\n\n        result = f\"{left_str} {right_str}\"\n\n        # Add outer parentheses if needed based on parent context\n        if parent_type and _needs_parentheses(expr, parent_type):\n            result = f\"({result})\"\n\n        return result\n\n    elif isinstance(expr, OrExpression):\n        # Don't pass parent type to children\n        left_str = _expression_to_string(expr.left, None)\n        right_str = _expression_to_string(expr.right, None)\n\n        # OrExpression children don't need parentheses unless they're also OR (handled by recursion)\n        result = f\"{left_str} or {right_str}\"\n\n        # Add outer parentheses if needed based on parent context\n        if parent_type and _needs_parentheses(expr, parent_type):\n            result = f\"({result})\"\n\n        return result\n\n    else:\n        raise ValueError(f\"Unknown expression type: {type(expr)}\")\n\n\ndef expression_to_string(expr: SearchExpression | None) -> str:\n    if expr is None:\n        return \"\"\n    return _expression_to_string(expr)\n\n\ndef _strip_tag_from_expression(\n    expr: SearchExpression | None, tag_name: str, enable_lax_search: bool = False\n) -> SearchExpression | None:\n    if expr is None:\n        return None\n\n    if isinstance(expr, TagExpression):\n        # Remove this tag if it matches\n        if expr.tag.lower() == tag_name.lower():\n            return None\n        return expr\n\n    elif isinstance(expr, TermExpression):\n        # In lax search mode, also remove terms that match the tag name\n        if enable_lax_search and expr.term.lower() == tag_name.lower():\n            return None\n        return expr\n\n    elif isinstance(expr, SpecialKeywordExpression):\n        # Keep special keywords as-is\n        return expr\n\n    elif isinstance(expr, NotExpression):\n        # Recursively filter the operand\n        filtered_operand = _strip_tag_from_expression(\n            expr.operand, tag_name, enable_lax_search\n        )\n        if filtered_operand is None:\n            # If the operand is removed, the whole NOT expression should be removed\n            return None\n        return NotExpression(filtered_operand)\n\n    elif isinstance(expr, AndExpression):\n        # Recursively filter both sides\n        left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)\n        right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)\n\n        # If both sides are removed, remove the AND expression\n        if left is None and right is None:\n            return None\n        # If one side is removed, return the other side\n        elif left is None:\n            return right\n        elif right is None:\n            return left\n        else:\n            return AndExpression(left, right)\n\n    elif isinstance(expr, OrExpression):\n        # Recursively filter both sides\n        left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)\n        right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)\n\n        # If both sides are removed, remove the OR expression\n        if left is None and right is None:\n            return None\n        # If one side is removed, return the other side\n        elif left is None:\n            return right\n        elif right is None:\n            return left\n        else:\n            return OrExpression(left, right)\n\n    else:\n        # Unknown expression type, return as-is\n        return expr\n\n\ndef strip_tag_from_query(\n    query: str, tag_name: str, user_profile: UserProfile | None = None\n) -> str:\n    try:\n        ast = parse_search_query(query)\n    except SearchQueryParseError:\n        return query\n\n    if ast is None:\n        return \"\"\n\n    # Determine if lax search is enabled\n    enable_lax_search = False\n    if user_profile is not None:\n        enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX\n\n    # Strip the tag from the AST\n    filtered_ast = _strip_tag_from_expression(ast, tag_name, enable_lax_search)\n\n    # Convert back to a query string\n    return expression_to_string(filtered_ast)\n\n\ndef _extract_tag_names_from_expression(\n    expr: SearchExpression | None, enable_lax_search: bool = False\n) -> list[str]:\n    if expr is None:\n        return []\n\n    if isinstance(expr, TagExpression):\n        return [expr.tag]\n\n    elif isinstance(expr, TermExpression):\n        # In lax search mode, terms are also considered tags\n        if enable_lax_search:\n            return [expr.term]\n        return []\n\n    elif isinstance(expr, SpecialKeywordExpression):\n        # Special keywords are not tags\n        return []\n\n    elif isinstance(expr, NotExpression):\n        # Recursively extract from the operand\n        return _extract_tag_names_from_expression(expr.operand, enable_lax_search)\n\n    elif isinstance(expr, (AndExpression, OrExpression)):\n        # Recursively extract from both sides and combine\n        left_tags = _extract_tag_names_from_expression(expr.left, enable_lax_search)\n        right_tags = _extract_tag_names_from_expression(expr.right, enable_lax_search)\n        return left_tags + right_tags\n\n    else:\n        # Unknown expression type\n        return []\n\n\ndef extract_tag_names_from_query(\n    query: str, user_profile: UserProfile | None = None\n) -> list[str]:\n    try:\n        ast = parse_search_query(query)\n    except SearchQueryParseError:\n        return []\n\n    if ast is None:\n        return []\n\n    # Determine if lax search is enabled\n    enable_lax_search = False\n    if user_profile is not None:\n        enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX\n\n    # Extract tag names from the AST\n    tag_names = _extract_tag_names_from_expression(ast, enable_lax_search)\n\n    # Deduplicate (case-insensitive) and sort\n    seen = set()\n    unique_tags = []\n    for tag in tag_names:\n        tag_lower = tag.lower()\n        if tag_lower not in seen:\n            seen.add(tag_lower)\n            unique_tags.append(tag_lower)\n\n    return sorted(unique_tags)\n"
  },
  {
    "path": "bookmarks/services/singlefile.py",
    "content": "import logging\nimport os\nimport shlex\nimport signal\nimport subprocess\n\nfrom django.conf import settings\n\n\nclass SingleFileError(Exception):\n    pass\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef create_snapshot(url: str, filepath: str):\n    singlefile_path = settings.LD_SINGLEFILE_PATH\n\n    # parse options to list of arguments\n    ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)\n    custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)\n    # concat lists\n    args = [singlefile_path] + ublock_options + custom_options + [url, filepath]\n    try:\n        # Use start_new_session=True to create a new process group\n        process = subprocess.Popen(args, start_new_session=True)\n        process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)\n\n        # check if the file was created\n        if not os.path.exists(filepath):\n            raise SingleFileError(\"Failed to create snapshot\")\n    except subprocess.TimeoutExpired:\n        # First try to terminate properly\n        try:\n            logger.error(\n                \"Timeout expired while creating snapshot. Terminating process...\"\n            )\n            process.terminate()\n            process.wait(timeout=20)\n            raise SingleFileError(\"Timeout expired while creating snapshot\") from None\n        except subprocess.TimeoutExpired:\n            # Kill the whole process group, which should also clean up any chromium\n            # processes spawned by single-file\n            logger.error(\"Timeout expired while terminating. Killing process...\")\n            os.killpg(os.getpgid(process.pid), signal.SIGTERM)\n            raise SingleFileError(\"Timeout expired while creating snapshot\") from None\n    except subprocess.CalledProcessError as error:\n        raise SingleFileError(f\"Failed to create snapshot: {error.stderr}\") from error\n"
  },
  {
    "path": "bookmarks/services/tags.py",
    "content": "import logging\nimport operator\n\nfrom django.contrib.auth.models import User\nfrom django.utils import timezone\n\nfrom bookmarks.models import Tag\nfrom bookmarks.utils import unique\n\nlogger = logging.getLogger(__name__)\n\n\ndef get_or_create_tags(tag_names: list[str], user: User):\n    tags = [get_or_create_tag(tag_name, user) for tag_name in tag_names]\n    return unique(tags, operator.attrgetter(\"id\"))\n\n\ndef get_or_create_tag(name: str, user: User):\n    try:\n        return Tag.objects.get(name__iexact=name, owner=user)\n    except Tag.DoesNotExist:\n        tag = Tag(name=name, owner=user)\n        tag.date_added = timezone.now()\n        tag.save()\n        return tag\n    except Tag.MultipleObjectsReturned:\n        # Legacy databases might contain duplicate tags with different capitalization\n        first_tag = Tag.objects.filter(name__iexact=name, owner=user).first()\n        message = (\n            f\"Found multiple tags for the name '{name}' with different capitalization. \"\n            f\"Using the first tag with the name '{first_tag.name}'. \"\n            \"Since v.1.2 tags work case-insensitive, which means duplicates of the same name are not allowed anymore. \"\n            \"To solve this error remove the duplicate tag in admin.\"\n        )\n        logger.error(message)\n        return first_tag\n"
  },
  {
    "path": "bookmarks/services/tasks.py",
    "content": "import functools\nimport logging\n\nimport waybackpy\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\nfrom django.db.models import Q\nfrom django.utils import timezone\nfrom huey import crontab\nfrom huey.contrib.djhuey import HUEY as huey\nfrom huey.exceptions import TaskLockedException\nfrom waybackpy.exceptions import TooManyRequestsError, WaybackError\n\nfrom bookmarks.models import Bookmark, BookmarkAsset, UserProfile\nfrom bookmarks.services import assets, favicon_loader, preview_image_loader\nfrom bookmarks.services.website_loader import DEFAULT_USER_AGENT, load_website_metadata\n\nlogger = logging.getLogger(__name__)\n\n\n# Create custom decorator for Huey tasks that implements exponential backoff\n# Taken from: https://huey.readthedocs.io/en/latest/guide.html#tips-and-tricks\n# Retry 1: 60\n# Retry 2: 240\n# Retry 3: 960\n# Retry 4: 3840\n# Retry 5: 15360\ndef task(retries=5, retry_delay=15, retry_backoff=4):\n    def deco(fn):\n        @functools.wraps(fn)\n        def inner(*args, **kwargs):\n            task = kwargs.pop(\"task\")\n            try:\n                return fn(*args, **kwargs)\n            except TaskLockedException as exc:\n                # Task locks are currently only used as workaround to enforce\n                # running specific types of tasks (e.g. singlefile snapshots)\n                # sequentially. In that case don't reduce the number of retries.\n                task.retries = retries\n                raise exc\n            except Exception as exc:\n                task.retry_delay *= retry_backoff\n                raise exc\n\n        return huey.task(retries=retries, retry_delay=retry_delay, context=True)(inner)\n\n    return deco\n\n\ndef is_web_archive_integration_active(user: User) -> bool:\n    background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS\n    web_archive_integration_enabled = (\n        user.profile.web_archive_integration\n        == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED\n    )\n\n    return background_tasks_enabled and web_archive_integration_enabled\n\n\ndef create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):\n    if is_web_archive_integration_active(user):\n        _create_web_archive_snapshot_task(bookmark.id, force_update)\n\n\ndef _create_snapshot(bookmark: Bookmark):\n    logger.info(f\"Create new snapshot for bookmark. url={bookmark.url}...\")\n    archive = waybackpy.WaybackMachineSaveAPI(\n        bookmark.url, DEFAULT_USER_AGENT, max_tries=1\n    )\n    archive.save()\n    bookmark.web_archive_snapshot_url = archive.archive_url\n    bookmark.save(update_fields=[\"web_archive_snapshot_url\"])\n    logger.info(f\"Successfully created new snapshot for bookmark:. url={bookmark.url}\")\n\n\n@task()\ndef _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):\n    try:\n        bookmark = Bookmark.objects.get(id=bookmark_id)\n    except Bookmark.DoesNotExist:\n        return\n\n    # Skip if snapshot exists and update is not explicitly requested\n    if bookmark.web_archive_snapshot_url and not force_update:\n        return\n\n    # Create new snapshot\n    try:\n        _create_snapshot(bookmark)\n        return\n    except TooManyRequestsError:\n        logger.error(\n            f\"Failed to create snapshot due to rate limiting. url={bookmark.url}\"\n        )\n    except WaybackError as error:\n        logger.error(\n            f\"Failed to create snapshot. url={bookmark.url}\",\n            exc_info=error,\n        )\n\n\n@task()\ndef _load_web_archive_snapshot_task(bookmark_id: int):\n    # Loading snapshots from CDX API has been removed, keeping the task function\n    # for now to prevent errors when huey tries to run the task\n    pass\n\n\n@task()\ndef _schedule_bookmarks_without_snapshots_task(user_id: int):\n    # Loading snapshots from CDX API has been removed, keeping the task function\n    # for now to prevent errors when huey tries to run the task\n    pass\n\n\ndef is_favicon_feature_active(user: User) -> bool:\n    background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS\n\n    return background_tasks_enabled and user.profile.enable_favicons\n\n\ndef is_preview_feature_active(user: User) -> bool:\n    return (\n        user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS\n    )\n\n\ndef load_favicon(user: User, bookmark: Bookmark):\n    if is_favicon_feature_active(user):\n        _load_favicon_task(bookmark.id)\n\n\n@task()\ndef _load_favicon_task(bookmark_id: int):\n    try:\n        bookmark = Bookmark.objects.get(id=bookmark_id)\n    except Bookmark.DoesNotExist:\n        return\n\n    logger.info(f\"Load favicon for bookmark. url={bookmark.url}\")\n\n    new_favicon_file = favicon_loader.load_favicon(bookmark.url)\n\n    if new_favicon_file != bookmark.favicon_file:\n        bookmark.favicon_file = new_favicon_file\n        bookmark.save(update_fields=[\"favicon_file\"])\n        logger.info(\n            f\"Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon_file}\"\n        )\n\n\ndef schedule_bookmarks_without_favicons(user: User):\n    if is_favicon_feature_active(user):\n        _schedule_bookmarks_without_favicons_task(user.id)\n\n\n@task()\ndef _schedule_bookmarks_without_favicons_task(user_id: int):\n    user = User.objects.get(id=user_id)\n    bookmarks = Bookmark.objects.filter(favicon_file__exact=\"\", owner=user)\n\n    # TODO: Implement bulk task creation\n    for bookmark in bookmarks:\n        _load_favicon_task(bookmark.id)\n        pass\n\n\ndef schedule_refresh_favicons(user: User):\n    if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS:\n        _schedule_refresh_favicons_task(user.id)\n\n\n@task()\ndef _schedule_refresh_favicons_task(user_id: int):\n    user = User.objects.get(id=user_id)\n    bookmarks = Bookmark.objects.filter(owner=user)\n\n    # TODO: Implement bulk task creation\n    for bookmark in bookmarks:\n        _load_favicon_task(bookmark.id)\n\n\ndef load_preview_image(user: User, bookmark: Bookmark):\n    if is_preview_feature_active(user):\n        _load_preview_image_task(bookmark.id)\n\n\n@task()\ndef _load_preview_image_task(bookmark_id: int):\n    try:\n        bookmark = Bookmark.objects.get(id=bookmark_id)\n    except Bookmark.DoesNotExist:\n        return\n\n    logger.info(f\"Load preview image for bookmark. url={bookmark.url}\")\n\n    new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url)\n\n    if new_preview_image_file != bookmark.preview_image_file:\n        bookmark.preview_image_file = new_preview_image_file or \"\"\n        bookmark.save(update_fields=[\"preview_image_file\"])\n        logger.info(\n            f\"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}\"\n        )\n\n\ndef schedule_bookmarks_without_previews(user: User):\n    if is_preview_feature_active(user):\n        _schedule_bookmarks_without_previews_task(user.id)\n\n\n@task()\ndef _schedule_bookmarks_without_previews_task(user_id: int):\n    user = User.objects.get(id=user_id)\n    bookmarks = Bookmark.objects.filter(\n        Q(preview_image_file__exact=\"\"),\n        owner=user,\n    )\n\n    # TODO: Implement bulk task creation\n    for bookmark in bookmarks:\n        try:\n            _load_preview_image_task(bookmark.id)\n        except Exception as exc:\n            logging.exception(exc)\n\n\ndef refresh_metadata(bookmark: Bookmark):\n    if not settings.LD_DISABLE_BACKGROUND_TASKS:\n        _refresh_metadata_task(bookmark.id)\n\n\n@task()\ndef _refresh_metadata_task(bookmark_id: int):\n    try:\n        bookmark = Bookmark.objects.get(id=bookmark_id)\n    except Bookmark.DoesNotExist:\n        return\n\n    logger.info(f\"Refresh metadata for bookmark. url={bookmark.url}\")\n\n    metadata = load_website_metadata(bookmark.url)\n    if metadata.title:\n        bookmark.title = metadata.title\n    if metadata.description:\n        bookmark.description = metadata.description\n    bookmark.date_modified = timezone.now()\n\n    bookmark.save()\n    logger.info(f\"Successfully refreshed metadata for bookmark. url={bookmark.url}\")\n\n\ndef is_html_snapshot_feature_active() -> bool:\n    return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS\n\n\ndef create_html_snapshot(bookmark: Bookmark):\n    if not is_html_snapshot_feature_active():\n        return\n\n    asset = assets.create_snapshot_asset(bookmark)\n    asset.save()\n\n\ndef create_html_snapshots(bookmark_list: list[Bookmark]):\n    if not is_html_snapshot_feature_active():\n        return\n\n    assets_to_create = []\n    for bookmark in bookmark_list:\n        asset = assets.create_snapshot_asset(bookmark)\n        assets_to_create.append(asset)\n\n    BookmarkAsset.objects.bulk_create(assets_to_create)\n\n\n# singe-file does not support running multiple instances in parallel, so we can\n# not queue up multiple snapshot tasks at once. Instead, schedule a periodic\n# task that grabs a number of pending assets and creates snapshots for them in\n# sequence. The task uses a lock to ensure that a new task isn't scheduled\n# before the previous one has finished.\n@huey.periodic_task(crontab(minute=\"*\"))\n@huey.lock_task(\"schedule-html-snapshots-lock\")\ndef _schedule_html_snapshots_task():\n    # Get five pending assets\n    assets = BookmarkAsset.objects.filter(status=BookmarkAsset.STATUS_PENDING).order_by(\n        \"date_created\"\n    )[:5]\n\n    for asset in assets:\n        _create_html_snapshot_task(asset.id)\n\n\ndef _create_html_snapshot_task(asset_id: int):\n    try:\n        asset = BookmarkAsset.objects.get(id=asset_id)\n    except BookmarkAsset.DoesNotExist:\n        return\n\n    logger.info(f\"Create HTML snapshot for bookmark. url={asset.bookmark.url}\")\n\n    try:\n        assets.create_snapshot(asset)\n\n        logger.info(\n            f\"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}\"\n        )\n    except Exception as error:\n        logger.error(\n            f\"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}\",\n            exc_info=error,\n        )\n\n\ndef create_missing_html_snapshots(user: User) -> int:\n    if not is_html_snapshot_feature_active():\n        return 0\n\n    bookmarks_without_snapshots = Bookmark.objects.filter(owner=user).exclude(\n        bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n        bookmarkasset__status__in=[\n            BookmarkAsset.STATUS_PENDING,\n            BookmarkAsset.STATUS_COMPLETE,\n        ],\n    )\n    bookmarks_without_snapshots |= Bookmark.objects.filter(owner=user).exclude(\n        bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT\n    )\n\n    create_html_snapshots(list(bookmarks_without_snapshots))\n\n    return bookmarks_without_snapshots.count()\n"
  },
  {
    "path": "bookmarks/services/wayback.py",
    "content": "import datetime\n\nfrom django.utils import timezone\n\n\ndef generate_fallback_webarchive_url(\n    url: str, timestamp: datetime.datetime\n) -> str | None:\n    \"\"\"\n    Generate a URL to the web archive for the given URL and timestamp.\n    A snapshot for the specific timestamp might not exist, in which case the\n    web archive will show the closest snapshot to the given timestamp.\n    If there is no snapshot at all the URL will be invalid.\n    \"\"\"\n    if not url:\n        return None\n    if not timestamp:\n        timestamp = timezone.now()\n\n    return f\"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}\"\n"
  },
  {
    "path": "bookmarks/services/website_loader.py",
    "content": "import logging\nfrom dataclasses import dataclass\nfrom functools import lru_cache\nfrom urllib.parse import urljoin\n\nimport requests\nfrom bs4 import BeautifulSoup\nfrom charset_normalizer import from_bytes\nfrom django.utils import timezone\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass WebsiteMetadata:\n    url: str\n    title: str | None\n    description: str | None\n    preview_image: str | None\n\n    def to_dict(self):\n        return {\n            \"url\": self.url,\n            \"title\": self.title,\n            \"description\": self.description,\n            \"preview_image\": self.preview_image,\n        }\n\n\ndef load_website_metadata(url: str, ignore_cache: bool = False):\n    if ignore_cache:\n        return _load_website_metadata(url)\n    return _load_website_metadata_cached(url)\n\n\n# Caching metadata avoids scraping again when saving bookmarks, in case the\n# metadata was already scraped to show preview values in the bookmark form\n@lru_cache(maxsize=10)\ndef _load_website_metadata_cached(url: str):\n    return _load_website_metadata(url)\n\n\ndef _load_website_metadata(url: str):\n    title = None\n    description = None\n    preview_image = None\n    try:\n        start = timezone.now()\n        page_text = load_page(url)\n        end = timezone.now()\n        logger.debug(f\"Load duration: {end - start}\")\n\n        start = timezone.now()\n        soup = BeautifulSoup(page_text, \"html.parser\")\n\n        if soup.title and soup.title.string:\n            title = soup.title.string.strip()\n        description_tag = soup.find(\"meta\", attrs={\"name\": \"description\"})\n        description = (\n            description_tag[\"content\"].strip()\n            if description_tag and description_tag[\"content\"]\n            else None\n        )\n\n        if not description:\n            description_tag = soup.find(\"meta\", attrs={\"property\": \"og:description\"})\n            description = (\n                description_tag[\"content\"].strip()\n                if description_tag and description_tag[\"content\"]\n                else None\n            )\n\n        image_tag = soup.find(\"meta\", attrs={\"property\": \"og:image\"})\n        preview_image = image_tag[\"content\"].strip() if image_tag else None\n        if (\n            preview_image\n            and not preview_image.startswith(\"http://\")\n            and not preview_image.startswith(\"https://\")\n        ):\n            preview_image = urljoin(url, preview_image)\n\n        end = timezone.now()\n        logger.debug(f\"Parsing duration: {end - start}\")\n    except Exception:\n        pass\n\n    return WebsiteMetadata(\n        url=url, title=title, description=description, preview_image=preview_image\n    )\n\n\nCHUNK_SIZE = 50 * 1024\nMAX_CONTENT_LIMIT = 5000 * 1024\n\n\ndef load_page(url: str):\n    headers = fake_request_headers()\n    size = 0\n    content = None\n    iteration = 0\n    # Use with to ensure request gets closed even if it's only read partially\n    with requests.get(url, timeout=10, headers=headers, stream=True) as r:\n        for chunk in r.iter_content(chunk_size=CHUNK_SIZE):\n            size += len(chunk)\n            iteration = iteration + 1\n            content = chunk if content is None else content + chunk\n\n            logger.debug(f\"Loaded chunk (iteration={iteration}, total={size / 1024})\")\n\n            # Stop reading if we have parsed end of head tag\n            end_of_head = b\"</head>\"\n            if end_of_head in content:\n                logger.debug(f\"Found closing head tag after {size} bytes\")\n                content = content.split(end_of_head)[0] + end_of_head\n                break\n            # Stop reading if we exceed limit\n            if size > MAX_CONTENT_LIMIT:\n                logger.debug(f\"Cancel reading document after {size} bytes\")\n                break\n        if hasattr(r, \"_content_consumed\"):\n            logger.debug(f\"Request consumed: {r._content_consumed}\")\n\n    # Use charset_normalizer to determine encoding that best matches the response content\n    # Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead\n    # This is different from Response.text which does respect the encoding specified in the response first,\n    # before trying to determine one\n    results = from_bytes(content or \"\")\n    return str(results.best())\n\n\nDEFAULT_USER_AGENT = \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36\"\n\n\ndef fake_request_headers():\n    return {\n        \"Accept\": \"text/html,application/xhtml+xml,application/xml\",\n        \"Accept-Encoding\": \"gzip, deflate\",\n        \"Dnt\": \"1\",\n        \"Upgrade-Insecure-Requests\": \"1\",\n        \"User-Agent\": DEFAULT_USER_AGENT,\n    }\n\n\ndef detect_content_type(url: str, timeout: int = 10) -> str | None:\n    \"\"\"Make HEAD request to detect content type of URL. Returns None on failure.\"\"\"\n    headers = fake_request_headers()\n\n    try:\n        response = requests.head(\n            url, headers=headers, timeout=timeout, allow_redirects=True\n        )\n        if response.status_code == 200:\n            return (\n                response.headers.get(\"Content-Type\", \"\").split(\";\")[0].strip().lower()\n            )\n    except requests.RequestException:\n        pass\n\n    try:\n        with requests.get(\n            url, headers=headers, timeout=timeout, stream=True, allow_redirects=True\n        ) as response:\n            if response.status_code == 200:\n                return (\n                    response.headers.get(\"Content-Type\", \"\")\n                    .split(\";\")[0]\n                    .strip()\n                    .lower()\n                )\n    except requests.RequestException:\n        pass\n\n    return None\n\n\ndef is_pdf_content_type(content_type: str | None) -> bool:\n    \"\"\"Check if the content type indicates a PDF.\"\"\"\n    if not content_type:\n        return False\n    return content_type in (\"application/pdf\", \"application/x-pdf\")\n"
  },
  {
    "path": "bookmarks/settings/__init__.py",
    "content": "# Use dev settings as default, use production if dev settings do not exist\n# ruff: noqa\ntry:\n    from .dev import *\nexcept:\n    from .prod import *\n"
  },
  {
    "path": "bookmarks/settings/base.py",
    "content": "\"\"\"\nDjango settings for linkding webapp.\n\nGenerated by 'django-admin startproject' using Django 2.2.2.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/2.2/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/2.2/ref/settings/\n\"\"\"\n\nimport json\nimport os\nimport shlex\n\n# Build paths inside the project like this: os.path.join(BASE_DIR, ...)\nBASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n# BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = \"kgq$h3@!!vbb6*nzfz(dbze=*)zsroqa8gvc0#1gx$3cd8z99^\"\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = False\n\nALLOWED_HOSTS = [\"*\"]\n\nUSE_X_FORWARDED_HOST = os.getenv(\"LD_USE_X_FORWARDED_HOST\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\n\n# Application definition\n\nINSTALLED_APPS = [\n    \"bookmarks.apps.BookmarksConfig\",\n    \"django.contrib.admin\",\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.staticfiles\",\n    \"rest_framework\",\n    \"rest_framework.authtoken\",\n    \"huey.contrib.djhuey\",\n    \"mozilla_django_oidc\",\n]\n\nMIDDLEWARE = [\n    \"django.middleware.security.SecurityMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"bookmarks.middlewares.LinkdingMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n    \"django.middleware.locale.LocaleMiddleware\",\n]\n\nROOT_URLCONF = \"bookmarks.urls\"\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"APP_DIRS\": True,\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n                \"bookmarks.context_processors.toasts\",\n                \"bookmarks.context_processors.app_version\",\n            ],\n        },\n    },\n]\n\nDEFAULT_AUTO_FIELD = \"django.db.models.AutoField\"\n\nWSGI_APPLICATION = \"bookmarks.wsgi.application\"\n\n# Password validation\n# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n    },\n]\n\n# Website context path.\nLD_CONTEXT_PATH = os.getenv(\"LD_CONTEXT_PATH\", \"\")\n\nLOGIN_URL = \"/\" + LD_CONTEXT_PATH + \"login\"\nLOGIN_REDIRECT_URL = \"/\" + LD_CONTEXT_PATH + \"bookmarks\"\nLOGOUT_REDIRECT_URL = \"/\" + LD_CONTEXT_PATH + \"login\"\n\n# Internationalization\n# https://docs.djangoproject.com/en/2.2/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = os.getenv(\"TZ\", \"UTC\")\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/2.2/howto/static-files/\n\nSTATIC_URL = \"/\" + LD_CONTEXT_PATH + \"static/\"\n\n# Collect static files in static folder\nSTATIC_ROOT = os.path.join(BASE_DIR, \"static\")\n\n# REST framework\nREST_FRAMEWORK = {\n    \"DEFAULT_AUTHENTICATION_CLASSES\": [\n        \"bookmarks.api.auth.LinkdingTokenAuthentication\",\n        \"rest_framework.authentication.SessionAuthentication\",\n    ],\n    \"DEFAULT_PERMISSION_CLASSES\": [\"rest_framework.permissions.IsAuthenticated\"],\n    \"DEFAULT_PAGINATION_CLASS\": \"rest_framework.pagination.LimitOffsetPagination\",\n    \"PAGE_SIZE\": 100,\n}\n\n# URL validation flag\nLD_DISABLE_URL_VALIDATION = os.getenv(\"LD_DISABLE_URL_VALIDATION\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\n\n# Background task enabled setting\nLD_DISABLE_BACKGROUND_TASKS = os.getenv(\"LD_DISABLE_BACKGROUND_TASKS\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\n\n# Huey task queue\nHUEY = {\n    \"huey_class\": \"huey.SqliteHuey\",\n    \"filename\": os.path.join(BASE_DIR, \"data\", \"tasks.sqlite3\"),\n    \"immediate\": False,\n    \"results\": False,\n    \"store_none\": False,\n    \"utc\": True,\n    \"consumer\": {\n        \"workers\": 2,\n        \"worker_type\": \"thread\",\n        \"initial_delay\": 5,\n        \"backoff\": 1.15,\n        \"max_delay\": 10,\n        \"scheduler_interval\": 10,\n        \"periodic\": True,\n        \"check_worker_health\": True,\n        \"health_check_interval\": 10,\n    },\n}\n\n# Disable login form if configured\nLD_DISABLE_LOGIN_FORM = os.getenv(\"LD_DISABLE_LOGIN_FORM\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\n\n# Enable OICD support if configured\nLD_ENABLE_OIDC = os.getenv(\"LD_ENABLE_OIDC\", False) in (True, \"True\", \"true\", \"1\")\n\nAUTHENTICATION_BACKENDS = [\"django.contrib.auth.backends.ModelBackend\"]\n\nif LD_ENABLE_OIDC:\n    AUTHENTICATION_BACKENDS.append(\"mozilla_django_oidc.auth.OIDCAuthenticationBackend\")\n\n    OIDC_USERNAME_ALGO = \"bookmarks.utils.generate_username\"\n    OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv(\"OIDC_OP_AUTHORIZATION_ENDPOINT\")\n    OIDC_OP_TOKEN_ENDPOINT = os.getenv(\"OIDC_OP_TOKEN_ENDPOINT\")\n    OIDC_OP_USER_ENDPOINT = os.getenv(\"OIDC_OP_USER_ENDPOINT\")\n    OIDC_OP_JWKS_ENDPOINT = os.getenv(\"OIDC_OP_JWKS_ENDPOINT\")\n    OIDC_RP_CLIENT_ID = os.getenv(\"OIDC_RP_CLIENT_ID\")\n    OIDC_RP_CLIENT_SECRET = os.getenv(\"OIDC_RP_CLIENT_SECRET\")\n    OIDC_RP_SIGN_ALGO = os.getenv(\"OIDC_RP_SIGN_ALGO\", \"RS256\")\n    OIDC_RP_SCOPES = os.getenv(\"OIDC_RP_SCOPES\", \"openid email profile\")\n    OIDC_USE_PKCE = os.getenv(\"OIDC_USE_PKCE\", True) in (True, \"True\", \"true\", \"1\")\n    OIDC_VERIFY_SSL = os.getenv(\"OIDC_VERIFY_SSL\", True) in (True, \"True\", \"true\", \"1\")\n    OIDC_USERNAME_CLAIM = os.getenv(\"OIDC_USERNAME_CLAIM\", \"email\")\n\n# Enable authentication proxy support if configured\nLD_ENABLE_AUTH_PROXY = os.getenv(\"LD_ENABLE_AUTH_PROXY\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\nLD_AUTH_PROXY_USERNAME_HEADER = os.getenv(\n    \"LD_AUTH_PROXY_USERNAME_HEADER\", \"REMOTE_USER\"\n)\nLD_AUTH_PROXY_LOGOUT_URL = os.getenv(\"LD_AUTH_PROXY_LOGOUT_URL\", None)\n\nif LD_ENABLE_AUTH_PROXY:\n    # Add middleware that automatically authenticates requests that have a known username\n    # in the LD_AUTH_PROXY_USERNAME_HEADER request header\n    MIDDLEWARE.append(\"bookmarks.middlewares.CustomRemoteUserMiddleware\")\n    # Configure auth backend that does not require a password credential\n    AUTHENTICATION_BACKENDS = [\"django.contrib.auth.backends.RemoteUserBackend\"]\n    # Configure logout URL\n    if LD_AUTH_PROXY_LOGOUT_URL:\n        LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL\n\n# CSRF trusted origins\ntrusted_origins = os.getenv(\"LD_CSRF_TRUSTED_ORIGINS\", \"\")\nif trusted_origins:\n    CSRF_TRUSTED_ORIGINS = trusted_origins.split(\",\")\n\n# Database\n# https://docs.djangoproject.com/en/2.2/ref/settings/#databases\n\nLD_DB_ENGINE = os.getenv(\"LD_DB_ENGINE\", \"sqlite\")\nLD_DB_HOST = os.getenv(\"LD_DB_HOST\", \"localhost\")\nLD_DB_DATABASE = os.getenv(\"LD_DB_DATABASE\", \"linkding\")\nLD_DB_USER = os.getenv(\"LD_DB_USER\", \"linkding\")\nLD_DB_PASSWORD = os.getenv(\"LD_DB_PASSWORD\", None)\nLD_DB_PORT = os.getenv(\"LD_DB_PORT\", None)\nLD_DB_OPTIONS = json.loads(os.getenv(\"LD_DB_OPTIONS\") or \"{}\")\n\nif LD_DB_ENGINE == \"postgres\":\n    default_database = {\n        \"ENGINE\": \"django.db.backends.postgresql_psycopg2\",\n        \"NAME\": LD_DB_DATABASE,\n        \"USER\": LD_DB_USER,\n        \"PASSWORD\": LD_DB_PASSWORD,\n        \"HOST\": LD_DB_HOST,\n        \"PORT\": LD_DB_PORT,\n        \"OPTIONS\": LD_DB_OPTIONS,\n    }\nelse:\n    default_database = {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": os.path.join(BASE_DIR, \"data\", \"db.sqlite3\"),\n        \"OPTIONS\": LD_DB_OPTIONS,\n        # Creating a connection loads the ICU extension into the SQLite\n        # connection, and also loads an ICU collation. The latter causes a\n        # memory leak, so try to counter that by making connections indefinitely\n        # persistent.\n        \"CONN_MAX_AGE\": None,\n    }\n\nDATABASES = {\"default\": default_database}\n\nSQLITE_ICU_EXTENSION_PATH = \"./libicu.so\"\nUSE_SQLITE = default_database[\"ENGINE\"] == \"django.db.backends.sqlite3\"\nUSE_SQLITE_ICU_EXTENSION = USE_SQLITE and os.path.exists(SQLITE_ICU_EXTENSION_PATH)\n\n# Favicons\nLD_DEFAULT_FAVICON_PROVIDER = \"https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32\"\nLD_FAVICON_PROVIDER = os.getenv(\"LD_FAVICON_PROVIDER\", LD_DEFAULT_FAVICON_PROVIDER)\nLD_FAVICON_FOLDER = os.path.join(BASE_DIR, \"data\", \"favicons\")\nLD_ENABLE_REFRESH_FAVICONS = os.getenv(\"LD_ENABLE_REFRESH_FAVICONS\", True) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\n\n# Previews settings\nLD_PREVIEW_FOLDER = os.path.join(BASE_DIR, \"data\", \"previews\")\nLD_PREVIEW_MAX_SIZE = int(os.getenv(\"LD_PREVIEW_MAX_SIZE\", 5242880))\nLD_PREVIEW_ALLOWED_EXTENSIONS = [\n    \".jpg\",\n    \".jpeg\",\n    \".png\",\n    \".gif\",\n    \".svg\",\n    \".webp\",\n]\n\n# Asset / snapshot settings\nLD_ASSET_FOLDER = os.path.join(BASE_DIR, \"data\", \"assets\")\n\nLD_ENABLE_SNAPSHOTS = os.getenv(\"LD_ENABLE_SNAPSHOTS\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\nLD_DISABLE_ASSET_UPLOAD = os.getenv(\"LD_DISABLE_ASSET_UPLOAD\", False) in (\n    True,\n    \"True\",\n    \"true\",\n    \"1\",\n)\nLD_SINGLEFILE_PATH = os.getenv(\"LD_SINGLEFILE_PATH\", \"single-file\")\nLD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(\n    \"LD_SINGLEFILE_UBLOCK_OPTIONS\",\n    shlex.join(\n        [\n            '--browser-arg=\"--headless=new\"',\n            '--browser-arg=\"--user-data-dir=./chromium-profile\"',\n            '--browser-arg=\"--no-sandbox\"',\n            '--browser-arg=\"--load-extension=uBOLite.chromium.mv3\"',\n        ]\n    ),\n)\nLD_SINGLEFILE_OPTIONS = os.getenv(\"LD_SINGLEFILE_OPTIONS\", \"\")\nLD_SINGLEFILE_TIMEOUT_SEC = float(os.getenv(\"LD_SINGLEFILE_TIMEOUT_SEC\", 120))\nLD_SNAPSHOT_PDF_MAX_SIZE = int(os.getenv(\"LD_SNAPSHOT_PDF_MAX_SIZE\", 15728640))  # 15MB\n\n# Monolith isn't used at the moment, as the local snapshot implementation\n# switched to single-file after the prototype. Keeping this around in case\n# it turns out to be useful in the future.\nLD_MONOLITH_PATH = os.getenv(\"LD_MONOLITH_PATH\", \"monolith\")\nLD_MONOLITH_OPTIONS = os.getenv(\"LD_MONOLITH_OPTIONS\", \"-a -v -s\")\n"
  },
  {
    "path": "bookmarks/settings/custom.py",
    "content": "# Placeholder, can be mounted in a Docker container with a custom settings\n"
  },
  {
    "path": "bookmarks/settings/dev.py",
    "content": "\"\"\"\nDevelopment settings for linkding webapp\n\"\"\"\n\n# ruff: noqa\n\n# Start from development settings\n# noinspection PyUnresolvedReferences\nfrom .base import *\n\n# Turn on debug mode\nDEBUG = True\n\n# Enable debug toolbar\n# INSTALLED_APPS.append(\"debug_toolbar\")\n# MIDDLEWARE.append(\"debug_toolbar.middleware.DebugToolbarMiddleware\")\n\nINTERNAL_IPS = [\n    \"127.0.0.1\",\n]\n\n# Allow access through ngrok\nCSRF_TRUSTED_ORIGINS = [\"https://*.ngrok-free.app\"]\n\nSTATICFILES_DIRS = [\n    # Resolve theme files from style source folder\n    os.path.join(BASE_DIR, \"bookmarks\", \"styles\"),\n    # Resolve downloaded files in dev environment\n    os.path.join(BASE_DIR, \"data\", \"favicons\"),\n    os.path.join(BASE_DIR, \"data\", \"previews\"),\n]\n\n# Enable debug logging\nLOGGING = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"formatters\": {\n        \"simple\": {\n            \"format\": \"{levelname} {asctime} {module}: {message}\",\n            \"style\": \"{\",\n        },\n    },\n    \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\", \"formatter\": \"simple\"}},\n    \"root\": {\n        \"handlers\": [\"console\"],\n        \"level\": \"WARNING\",\n    },\n    \"loggers\": {\n        \"django.db.backends\": {\n            \"level\": \"ERROR\",  # Set to DEBUG to log all SQL calls\n            \"handlers\": [\"console\"],\n        },\n        \"bookmarks\": {  # Log importer debug output\n            \"level\": \"DEBUG\",\n            \"handlers\": [\"console\"],\n            \"propagate\": False,\n        },\n        \"huey\": {  # Huey\n            \"level\": \"INFO\",\n            \"handlers\": [\"console\"],\n            \"propagate\": False,\n        },\n    },\n}\n\n# Import custom settings\n# noinspection PyUnresolvedReferences\nfrom .custom import *\n"
  },
  {
    "path": "bookmarks/settings/prod.py",
    "content": "\"\"\"\nProduction settings for linkding webapp\n\"\"\"\n\n# ruff: noqa\n\n# Start from development settings\n# noinspection PyUnresolvedReferences\nimport os\n\nfrom django.core.management.utils import get_random_secret_key\nfrom .base import *\n\n# Turn of debug mode\nDEBUG = False\n\n# Try read secret key from file\ntry:\n    with open(os.path.join(BASE_DIR, \"data\", \"secretkey.txt\")) as f:\n        SECRET_KEY = f.read().strip()\nexcept:\n    SECRET_KEY = get_random_secret_key()\n\n# Set ALLOWED_HOSTS\n# By default look in the HOST_NAME environment variable, if that is not set then allow all hosts\nhost_name = os.environ.get(\"HOST_NAME\")\nif host_name:\n    ALLOWED_HOSTS = [host_name]\nelse:\n    ALLOWED_HOSTS = [\"*\"]\n\n# Logging\nLOGGING = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"formatters\": {\n        \"simple\": {\n            \"format\": \"{asctime} {levelname} {message}\",\n            \"style\": \"{\",\n        },\n    },\n    \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\", \"formatter\": \"simple\"}},\n    \"root\": {\n        \"handlers\": [\"console\"],\n        \"level\": \"WARN\",\n    },\n    \"loggers\": {\n        \"bookmarks\": {\n            \"level\": \"INFO\",\n            \"handlers\": [\"console\"],\n            \"propagate\": False,\n        },\n        \"huey\": {\n            \"level\": \"INFO\",\n            \"handlers\": [\"console\"],\n            \"propagate\": False,\n        },\n    },\n}\n\n# Import custom settings\n# noinspection PyUnresolvedReferences\nfrom .custom import *\n"
  },
  {
    "path": "bookmarks/signals.py",
    "content": "from django.conf import settings\nfrom django.db.backends.signals import connection_created\nfrom django.dispatch import receiver\n\n\n@receiver(connection_created)\ndef extend_sqlite(connection=None, **kwargs):\n    # Load ICU extension into Sqlite connection to support case-insensitive\n    # comparisons with unicode characters\n    if connection.vendor == \"sqlite\" and settings.USE_SQLITE_ICU_EXTENSION:\n        connection.connection.enable_load_extension(True)\n        connection.connection.load_extension(\n            settings.SQLITE_ICU_EXTENSION_PATH.rstrip(\".so\")\n        )\n\n        with connection.cursor() as cursor:\n            # Load an ICU collation for case-insensitive ordering.\n            # The first param can be a specific locale, it seems that not\n            # providing one will use a default collation from the ICU project\n            # that works reasonably for multiple languages\n            cursor.execute(\"SELECT icu_load_collation('', 'ICU');\")\n"
  },
  {
    "path": "bookmarks/static/live-reload.js",
    "content": "const RELOAD_URL = \"/live_reload\";\n\nlet eventSource = null;\nlet serverId = null;\n\nfunction connect() {\n  console.debug(\"[live-reload] Connecting to\", RELOAD_URL);\n\n  eventSource = new EventSource(RELOAD_URL);\n\n  eventSource.addEventListener(\"connected\", (event) => {\n    const data = JSON.parse(event.data);\n\n    if (serverId && serverId !== data.server_id) {\n      console.log(\"[live-reload] Server restarted, reloading page\");\n      window.location.reload();\n      return;\n    }\n\n    console.debug(\"[live-reload] Connected, server ID:\", data.server_id);\n    serverId = data.server_id;\n  });\n\n  eventSource.addEventListener(\"file_change\", (event) => {\n    const data = JSON.parse(event.data);\n    console.log(\"[live-reload] File changed:\", data);\n\n    if (data.file_path.endsWith(\".html\") || data.file_path.endsWith(\".css\") || data.file_path.endsWith(\".js\")) {\n      console.log(\"[live-reload] Asset changed, reloading page\");\n      window.location.reload();\n    }\n  });\n\n  eventSource.onerror = (error) => {\n    console.debug(\"[live-reload] Disconnected\", error);\n    eventSource.close();\n    eventSource = null;\n\n    // Reconnect after a delay\n    setTimeout(connect, 1000);\n  };\n}\n\nconnect();\n"
  },
  {
    "path": "bookmarks/static/robots.txt",
    "content": "User-agent: *\nDisallow: /\n"
  },
  {
    "path": "bookmarks/static/vendor/Readability.js",
    "content": "/*\n * Copyright (c) 2010 Arc90 Inc\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * This code is heavily based on Arc90's readability.js (1.7.1) script\n * available at: http://code.google.com/p/arc90labs-readability\n */\n\n/**\n * Public constructor.\n * @param {HTMLDocument} doc     The document to parse.\n * @param {Object}       options The options object.\n */\nfunction Readability(doc, options) {\n  // In some older versions, people passed a URI as the first argument. Cope:\n  if (options && options.documentElement) {\n    doc = options;\n    options = arguments[2];\n  } else if (!doc || !doc.documentElement) {\n    throw new Error(\"First argument to Readability constructor should be a document object.\");\n  }\n  options = options || {};\n\n  this._doc = doc;\n  this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__;\n  this._articleTitle = null;\n  this._articleByline = null;\n  this._articleDir = null;\n  this._articleSiteName = null;\n  this._attempts = [];\n\n  // Configurable options\n  this._debug = !!options.debug;\n  this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE;\n  this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES;\n  this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD;\n  this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat(options.classesToPreserve || []);\n  this._keepClasses = !!options.keepClasses;\n  this._serializer = options.serializer || function(el) {\n    return el.innerHTML;\n  };\n  this._disableJSONLD = !!options.disableJSONLD;\n  this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos;\n\n  // Start with all flags set\n  this._flags = this.FLAG_STRIP_UNLIKELYS |\n                this.FLAG_WEIGHT_CLASSES |\n                this.FLAG_CLEAN_CONDITIONALLY;\n\n\n  // Control whether log messages are sent to the console\n  if (this._debug) {\n    let logNode = function(node) {\n      if (node.nodeType == node.TEXT_NODE) {\n        return `${node.nodeName} (\"${node.textContent}\")`;\n      }\n      let attrPairs = Array.from(node.attributes || [], function(attr) {\n        return `${attr.name}=\"${attr.value}\"`;\n      }).join(\" \");\n      return `<${node.localName} ${attrPairs}>`;\n    };\n    this.log = function () {\n      if (typeof console !== \"undefined\") {\n        let args = Array.from(arguments, arg => {\n          if (arg && arg.nodeType == this.ELEMENT_NODE) {\n            return logNode(arg);\n          }\n          return arg;\n        });\n        args.unshift(\"Reader: (Readability)\");\n        console.log.apply(console, args);\n      } else if (typeof dump !== \"undefined\") {\n        /* global dump */\n        var msg = Array.prototype.map.call(arguments, function(x) {\n          return (x && x.nodeName) ? logNode(x) : x;\n        }).join(\" \");\n        dump(\"Reader: (Readability) \" + msg + \"\\n\");\n      }\n    };\n  } else {\n    this.log = function () {};\n  }\n}\n\nReadability.prototype = {\n  FLAG_STRIP_UNLIKELYS: 0x1,\n  FLAG_WEIGHT_CLASSES: 0x2,\n  FLAG_CLEAN_CONDITIONALLY: 0x4,\n\n  // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\n  ELEMENT_NODE: 1,\n  TEXT_NODE: 3,\n\n  // Max number of nodes supported by this parser. Default: 0 (no limit)\n  DEFAULT_MAX_ELEMS_TO_PARSE: 0,\n\n  // The number of top candidates to consider when analysing how\n  // tight the competition is among candidates.\n  DEFAULT_N_TOP_CANDIDATES: 5,\n\n  // Element tags to score by default.\n  DEFAULT_TAGS_TO_SCORE: \"section,h2,h3,h4,h5,h6,p,td,pre\".toUpperCase().split(\",\"),\n\n  // The default number of chars an article must have in order to return a result\n  DEFAULT_CHAR_THRESHOLD: 500,\n\n  // All of the regular expressions in use within readability.\n  // Defined up here so we don't instantiate them repeatedly in loops.\n  REGEXPS: {\n    // NOTE: These two regular expressions are duplicated in\n    // Readability-readerable.js. Please keep both copies in sync.\n    unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,\n    okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i,\n\n    positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i,\n    negative: /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget/i,\n    extraneous: /print|archive|comment|discuss|e[\\-]?mail|share|reply|all|login|sign|single|utility/i,\n    byline: /byline|author|dateline|writtenby|p-author/i,\n    replaceFonts: /<(\\/?)font[^>]*>/gi,\n    normalize: /\\s{2,}/g,\n    videos: /\\/\\/(www\\.)?((dailymotion|youtube|youtube-nocookie|player\\.vimeo|v\\.qq)\\.com|(archive|upload\\.wikimedia)\\.org|player\\.twitch\\.tv)/i,\n    shareElements: /(\\b|_)(share|sharedaddy)(\\b|_)/i,\n    nextLink: /(next|weiter|continue|>([^\\|]|$)|»([^\\|]|$))/i,\n    prevLink: /(prev|earl|old|new|<|«)/i,\n    tokenize: /\\W+/g,\n    whitespace: /^\\s*$/,\n    hasContent: /\\S$/,\n    hashUrl: /^#.+/,\n    srcsetUrl: /(\\S+)(\\s+[\\d.]+[xw])?(\\s*(?:,|$))/g,\n    b64DataUrl: /^data:\\s*([^\\s;,]+)\\s*;\\s*base64\\s*,/i,\n    // Commas as used in Latin, Sindhi, Chinese and various other scripts.\n    // see: https://en.wikipedia.org/wiki/Comma#Comma_variants\n    commas: /\\u002C|\\u060C|\\uFE50|\\uFE10|\\uFE11|\\u2E41|\\u2E34|\\u2E32|\\uFF0C/g,\n    // See: https://schema.org/Article\n    jsonLdArticleTypes: /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/\n  },\n\n  UNLIKELY_ROLES: [ \"menu\", \"menubar\", \"complementary\", \"navigation\", \"alert\", \"alertdialog\", \"dialog\" ],\n\n  DIV_TO_P_ELEMS: new Set([ \"BLOCKQUOTE\", \"DL\", \"DIV\", \"IMG\", \"OL\", \"P\", \"PRE\", \"TABLE\", \"UL\" ]),\n\n  ALTER_TO_DIV_EXCEPTIONS: [\"DIV\", \"ARTICLE\", \"SECTION\", \"P\"],\n\n  PRESENTATIONAL_ATTRIBUTES: [ \"align\", \"background\", \"bgcolor\", \"border\", \"cellpadding\", \"cellspacing\", \"frame\", \"hspace\", \"rules\", \"style\", \"valign\", \"vspace\" ],\n\n  DEPRECATED_SIZE_ATTRIBUTE_ELEMS: [ \"TABLE\", \"TH\", \"TD\", \"HR\", \"PRE\" ],\n\n  // The commented out elements qualify as phrasing content but tend to be\n  // removed by readability when put into paragraphs, so we ignore them here.\n  PHRASING_ELEMS: [\n    // \"CANVAS\", \"IFRAME\", \"SVG\", \"VIDEO\",\n    \"ABBR\", \"AUDIO\", \"B\", \"BDO\", \"BR\", \"BUTTON\", \"CITE\", \"CODE\", \"DATA\",\n    \"DATALIST\", \"DFN\", \"EM\", \"EMBED\", \"I\", \"IMG\", \"INPUT\", \"KBD\", \"LABEL\",\n    \"MARK\", \"MATH\", \"METER\", \"NOSCRIPT\", \"OBJECT\", \"OUTPUT\", \"PROGRESS\", \"Q\",\n    \"RUBY\", \"SAMP\", \"SCRIPT\", \"SELECT\", \"SMALL\", \"SPAN\", \"STRONG\", \"SUB\",\n    \"SUP\", \"TEXTAREA\", \"TIME\", \"VAR\", \"WBR\"\n  ],\n\n  // These are the classes that readability sets itself.\n  CLASSES_TO_PRESERVE: [ \"page\" ],\n\n  // These are the list of HTML entities that need to be escaped.\n  HTML_ESCAPE_MAP: {\n    \"lt\": \"<\",\n    \"gt\": \">\",\n    \"amp\": \"&\",\n    \"quot\": '\"',\n    \"apos\": \"'\",\n  },\n\n  /**\n   * Run any post-process modifications to article content as necessary.\n   *\n   * @param Element\n   * @return void\n  **/\n  _postProcessContent: function(articleContent) {\n    // Readability cannot open relative uris so we convert them to absolute uris.\n    this._fixRelativeUris(articleContent);\n\n    this._simplifyNestedElements(articleContent);\n\n    if (!this._keepClasses) {\n      // Remove classes.\n      this._cleanClasses(articleContent);\n    }\n  },\n\n  /**\n   * Iterates over a NodeList, calls `filterFn` for each node and removes node\n   * if function returned `true`.\n   *\n   * If function is not passed, removes all the nodes in node list.\n   *\n   * @param NodeList nodeList The nodes to operate on\n   * @param Function filterFn the function to use as a filter\n   * @return void\n   */\n  _removeNodes: function(nodeList, filterFn) {\n    // Avoid ever operating on live node lists.\n    if (this._docJSDOMParser && nodeList._isLiveNodeList) {\n      throw new Error(\"Do not pass live node lists to _removeNodes\");\n    }\n    for (var i = nodeList.length - 1; i >= 0; i--) {\n      var node = nodeList[i];\n      var parentNode = node.parentNode;\n      if (parentNode) {\n        if (!filterFn || filterFn.call(this, node, i, nodeList)) {\n          parentNode.removeChild(node);\n        }\n      }\n    }\n  },\n\n  /**\n   * Iterates over a NodeList, and calls _setNodeTag for each node.\n   *\n   * @param NodeList nodeList The nodes to operate on\n   * @param String newTagName the new tag name to use\n   * @return void\n   */\n  _replaceNodeTags: function(nodeList, newTagName) {\n    // Avoid ever operating on live node lists.\n    if (this._docJSDOMParser && nodeList._isLiveNodeList) {\n      throw new Error(\"Do not pass live node lists to _replaceNodeTags\");\n    }\n    for (const node of nodeList) {\n      this._setNodeTag(node, newTagName);\n    }\n  },\n\n  /**\n   * Iterate over a NodeList, which doesn't natively fully implement the Array\n   * interface.\n   *\n   * For convenience, the current object context is applied to the provided\n   * iterate function.\n   *\n   * @param  NodeList nodeList The NodeList.\n   * @param  Function fn       The iterate function.\n   * @return void\n   */\n  _forEachNode: function(nodeList, fn) {\n    Array.prototype.forEach.call(nodeList, fn, this);\n  },\n\n  /**\n   * Iterate over a NodeList, and return the first node that passes\n   * the supplied test function\n   *\n   * For convenience, the current object context is applied to the provided\n   * test function.\n   *\n   * @param  NodeList nodeList The NodeList.\n   * @param  Function fn       The test function.\n   * @return void\n   */\n  _findNode: function(nodeList, fn) {\n    return Array.prototype.find.call(nodeList, fn, this);\n  },\n\n  /**\n   * Iterate over a NodeList, return true if any of the provided iterate\n   * function calls returns true, false otherwise.\n   *\n   * For convenience, the current object context is applied to the\n   * provided iterate function.\n   *\n   * @param  NodeList nodeList The NodeList.\n   * @param  Function fn       The iterate function.\n   * @return Boolean\n   */\n  _someNode: function(nodeList, fn) {\n    return Array.prototype.some.call(nodeList, fn, this);\n  },\n\n  /**\n   * Iterate over a NodeList, return true if all of the provided iterate\n   * function calls return true, false otherwise.\n   *\n   * For convenience, the current object context is applied to the\n   * provided iterate function.\n   *\n   * @param  NodeList nodeList The NodeList.\n   * @param  Function fn       The iterate function.\n   * @return Boolean\n   */\n  _everyNode: function(nodeList, fn) {\n    return Array.prototype.every.call(nodeList, fn, this);\n  },\n\n  /**\n   * Concat all nodelists passed as arguments.\n   *\n   * @return ...NodeList\n   * @return Array\n   */\n  _concatNodeLists: function() {\n    var slice = Array.prototype.slice;\n    var args = slice.call(arguments);\n    var nodeLists = args.map(function(list) {\n      return slice.call(list);\n    });\n    return Array.prototype.concat.apply([], nodeLists);\n  },\n\n  _getAllNodesWithTag: function(node, tagNames) {\n    if (node.querySelectorAll) {\n      return node.querySelectorAll(tagNames.join(\",\"));\n    }\n    return [].concat.apply([], tagNames.map(function(tag) {\n      var collection = node.getElementsByTagName(tag);\n      return Array.isArray(collection) ? collection : Array.from(collection);\n    }));\n  },\n\n  /**\n   * Removes the class=\"\" attribute from every element in the given\n   * subtree, except those that match CLASSES_TO_PRESERVE and\n   * the classesToPreserve array from the options object.\n   *\n   * @param Element\n   * @return void\n   */\n  _cleanClasses: function(node) {\n    var classesToPreserve = this._classesToPreserve;\n    var className = (node.getAttribute(\"class\") || \"\")\n      .split(/\\s+/)\n      .filter(function(cls) {\n        return classesToPreserve.indexOf(cls) != -1;\n      })\n      .join(\" \");\n\n    if (className) {\n      node.setAttribute(\"class\", className);\n    } else {\n      node.removeAttribute(\"class\");\n    }\n\n    for (node = node.firstElementChild; node; node = node.nextElementSibling) {\n      this._cleanClasses(node);\n    }\n  },\n\n  /**\n   * Converts each <a> and <img> uri in the given element to an absolute URI,\n   * ignoring #ref URIs.\n   *\n   * @param Element\n   * @return void\n   */\n  _fixRelativeUris: function(articleContent) {\n    var baseURI = this._doc.baseURI;\n    var documentURI = this._doc.documentURI;\n    function toAbsoluteURI(uri) {\n      // Leave hash links alone if the base URI matches the document URI:\n      if (baseURI == documentURI && uri.charAt(0) == \"#\") {\n        return uri;\n      }\n\n      // Otherwise, resolve against base URI:\n      try {\n        return new URL(uri, baseURI).href;\n      } catch (ex) {\n        // Something went wrong, just return the original:\n      }\n      return uri;\n    }\n\n    var links = this._getAllNodesWithTag(articleContent, [\"a\"]);\n    this._forEachNode(links, function(link) {\n      var href = link.getAttribute(\"href\");\n      if (href) {\n        // Remove links with javascript: URIs, since\n        // they won't work after scripts have been removed from the page.\n        if (href.indexOf(\"javascript:\") === 0) {\n          // if the link only contains simple text content, it can be converted to a text node\n          if (link.childNodes.length === 1 && link.childNodes[0].nodeType === this.TEXT_NODE) {\n            var text = this._doc.createTextNode(link.textContent);\n            link.parentNode.replaceChild(text, link);\n          } else {\n            // if the link has multiple children, they should all be preserved\n            var container = this._doc.createElement(\"span\");\n            while (link.firstChild) {\n              container.appendChild(link.firstChild);\n            }\n            link.parentNode.replaceChild(container, link);\n          }\n        } else {\n          link.setAttribute(\"href\", toAbsoluteURI(href));\n        }\n      }\n    });\n\n    var medias = this._getAllNodesWithTag(articleContent, [\n      \"img\", \"picture\", \"figure\", \"video\", \"audio\", \"source\"\n    ]);\n\n    this._forEachNode(medias, function(media) {\n      var src = media.getAttribute(\"src\");\n      var poster = media.getAttribute(\"poster\");\n      var srcset = media.getAttribute(\"srcset\");\n\n      if (src) {\n        media.setAttribute(\"src\", toAbsoluteURI(src));\n      }\n\n      if (poster) {\n        media.setAttribute(\"poster\", toAbsoluteURI(poster));\n      }\n\n      if (srcset) {\n        var newSrcset = srcset.replace(this.REGEXPS.srcsetUrl, function(_, p1, p2, p3) {\n          return toAbsoluteURI(p1) + (p2 || \"\") + p3;\n        });\n\n        media.setAttribute(\"srcset\", newSrcset);\n      }\n    });\n  },\n\n  _simplifyNestedElements: function(articleContent) {\n    var node = articleContent;\n\n    while (node) {\n      if (node.parentNode && [\"DIV\", \"SECTION\"].includes(node.tagName) && !(node.id && node.id.startsWith(\"readability\"))) {\n        if (this._isElementWithoutContent(node)) {\n          node = this._removeAndGetNext(node);\n          continue;\n        } else if (this._hasSingleTagInsideElement(node, \"DIV\") || this._hasSingleTagInsideElement(node, \"SECTION\")) {\n          var child = node.children[0];\n          for (var i = 0; i < node.attributes.length; i++) {\n            child.setAttribute(node.attributes[i].name, node.attributes[i].value);\n          }\n          node.parentNode.replaceChild(child, node);\n          node = child;\n          continue;\n        }\n      }\n\n      node = this._getNextNode(node);\n    }\n  },\n\n  /**\n   * Get the article title as an H1.\n   *\n   * @return string\n   **/\n  _getArticleTitle: function() {\n    var doc = this._doc;\n    var curTitle = \"\";\n    var origTitle = \"\";\n\n    try {\n      curTitle = origTitle = doc.title.trim();\n\n      // If they had an element with id \"title\" in their HTML\n      if (typeof curTitle !== \"string\")\n        curTitle = origTitle = this._getInnerText(doc.getElementsByTagName(\"title\")[0]);\n    } catch (e) {/* ignore exceptions setting the title. */}\n\n    var titleHadHierarchicalSeparators = false;\n    function wordCount(str) {\n      return str.split(/\\s+/).length;\n    }\n\n    // If there's a separator in the title, first remove the final part\n    if ((/ [\\|\\-\\\\\\/>»] /).test(curTitle)) {\n      titleHadHierarchicalSeparators = / [\\\\\\/>»] /.test(curTitle);\n      curTitle = origTitle.replace(/(.*)[\\|\\-\\\\\\/>»] .*/gi, \"$1\");\n\n      // If the resulting title is too short (3 words or fewer), remove\n      // the first part instead:\n      if (wordCount(curTitle) < 3)\n        curTitle = origTitle.replace(/[^\\|\\-\\\\\\/>»]*[\\|\\-\\\\\\/>»](.*)/gi, \"$1\");\n    } else if (curTitle.indexOf(\": \") !== -1) {\n      // Check if we have an heading containing this exact string, so we\n      // could assume it's the full title.\n      var headings = this._concatNodeLists(\n        doc.getElementsByTagName(\"h1\"),\n        doc.getElementsByTagName(\"h2\")\n      );\n      var trimmedTitle = curTitle.trim();\n      var match = this._someNode(headings, function(heading) {\n        return heading.textContent.trim() === trimmedTitle;\n      });\n\n      // If we don't, let's extract the title out of the original title string.\n      if (!match) {\n        curTitle = origTitle.substring(origTitle.lastIndexOf(\":\") + 1);\n\n        // If the title is now too short, try the first colon instead:\n        if (wordCount(curTitle) < 3) {\n          curTitle = origTitle.substring(origTitle.indexOf(\":\") + 1);\n          // But if we have too many words before the colon there's something weird\n          // with the titles and the H tags so let's just use the original title instead\n        } else if (wordCount(origTitle.substr(0, origTitle.indexOf(\":\"))) > 5) {\n          curTitle = origTitle;\n        }\n      }\n    } else if (curTitle.length > 150 || curTitle.length < 15) {\n      var hOnes = doc.getElementsByTagName(\"h1\");\n\n      if (hOnes.length === 1)\n        curTitle = this._getInnerText(hOnes[0]);\n    }\n\n    curTitle = curTitle.trim().replace(this.REGEXPS.normalize, \" \");\n    // If we now have 4 words or fewer as our title, and either no\n    // 'hierarchical' separators (\\, /, > or ») were found in the original\n    // title or we decreased the number of words by more than 1 word, use\n    // the original title.\n    var curTitleWordCount = wordCount(curTitle);\n    if (curTitleWordCount <= 4 &&\n        (!titleHadHierarchicalSeparators ||\n         curTitleWordCount != wordCount(origTitle.replace(/[\\|\\-\\\\\\/>»]+/g, \"\")) - 1)) {\n      curTitle = origTitle;\n    }\n\n    return curTitle;\n  },\n\n  /**\n   * Prepare the HTML document for readability to scrape it.\n   * This includes things like stripping javascript, CSS, and handling terrible markup.\n   *\n   * @return void\n   **/\n  _prepDocument: function() {\n    var doc = this._doc;\n\n    // Remove all style tags in head\n    this._removeNodes(this._getAllNodesWithTag(doc, [\"style\"]));\n\n    if (doc.body) {\n      this._replaceBrs(doc.body);\n    }\n\n    this._replaceNodeTags(this._getAllNodesWithTag(doc, [\"font\"]), \"SPAN\");\n  },\n\n  /**\n   * Finds the next node, starting from the given node, and ignoring\n   * whitespace in between. If the given node is an element, the same node is\n   * returned.\n   */\n  _nextNode: function (node) {\n    var next = node;\n    while (next\n        && (next.nodeType != this.ELEMENT_NODE)\n        && this.REGEXPS.whitespace.test(next.textContent)) {\n      next = next.nextSibling;\n    }\n    return next;\n  },\n\n  /**\n   * Replaces 2 or more successive <br> elements with a single <p>.\n   * Whitespace between <br> elements are ignored. For example:\n   *   <div>foo<br>bar<br> <br><br>abc</div>\n   * will become:\n   *   <div>foo<br>bar<p>abc</p></div>\n   */\n  _replaceBrs: function (elem) {\n    this._forEachNode(this._getAllNodesWithTag(elem, [\"br\"]), function(br) {\n      var next = br.nextSibling;\n\n      // Whether 2 or more <br> elements have been found and replaced with a\n      // <p> block.\n      var replaced = false;\n\n      // If we find a <br> chain, remove the <br>s until we hit another node\n      // or non-whitespace. This leaves behind the first <br> in the chain\n      // (which will be replaced with a <p> later).\n      while ((next = this._nextNode(next)) && (next.tagName == \"BR\")) {\n        replaced = true;\n        var brSibling = next.nextSibling;\n        next.parentNode.removeChild(next);\n        next = brSibling;\n      }\n\n      // If we removed a <br> chain, replace the remaining <br> with a <p>. Add\n      // all sibling nodes as children of the <p> until we hit another <br>\n      // chain.\n      if (replaced) {\n        var p = this._doc.createElement(\"p\");\n        br.parentNode.replaceChild(p, br);\n\n        next = p.nextSibling;\n        while (next) {\n          // If we've hit another <br><br>, we're done adding children to this <p>.\n          if (next.tagName == \"BR\") {\n            var nextElem = this._nextNode(next.nextSibling);\n            if (nextElem && nextElem.tagName == \"BR\")\n              break;\n          }\n\n          if (!this._isPhrasingContent(next))\n            break;\n\n          // Otherwise, make this node a child of the new <p>.\n          var sibling = next.nextSibling;\n          p.appendChild(next);\n          next = sibling;\n        }\n\n        while (p.lastChild && this._isWhitespace(p.lastChild)) {\n          p.removeChild(p.lastChild);\n        }\n\n        if (p.parentNode.tagName === \"P\")\n          this._setNodeTag(p.parentNode, \"DIV\");\n      }\n    });\n  },\n\n  _setNodeTag: function (node, tag) {\n    this.log(\"_setNodeTag\", node, tag);\n    if (this._docJSDOMParser) {\n      node.localName = tag.toLowerCase();\n      node.tagName = tag.toUpperCase();\n      return node;\n    }\n\n    var replacement = node.ownerDocument.createElement(tag);\n    while (node.firstChild) {\n      replacement.appendChild(node.firstChild);\n    }\n    node.parentNode.replaceChild(replacement, node);\n    if (node.readability)\n      replacement.readability = node.readability;\n\n    for (var i = 0; i < node.attributes.length; i++) {\n      try {\n        replacement.setAttribute(node.attributes[i].name, node.attributes[i].value);\n      } catch (ex) {\n        /* it's possible for setAttribute() to throw if the attribute name\n         * isn't a valid XML Name. Such attributes can however be parsed from\n         * source in HTML docs, see https://github.com/whatwg/html/issues/4275,\n         * so we can hit them here and then throw. We don't care about such\n         * attributes so we ignore them.\n         */\n      }\n    }\n    return replacement;\n  },\n\n  /**\n   * Prepare the article node for display. Clean out any inline styles,\n   * iframes, forms, strip extraneous <p> tags, etc.\n   *\n   * @param Element\n   * @return void\n   **/\n  _prepArticle: function(articleContent) {\n    this._cleanStyles(articleContent);\n\n    // Check for data tables before we continue, to avoid removing items in\n    // those tables, which will often be isolated even though they're\n    // visually linked to other content-ful elements (text, images, etc.).\n    this._markDataTables(articleContent);\n\n    this._fixLazyImages(articleContent);\n\n    // Clean out junk from the article content\n    this._cleanConditionally(articleContent, \"form\");\n    this._cleanConditionally(articleContent, \"fieldset\");\n    this._clean(articleContent, \"object\");\n    this._clean(articleContent, \"embed\");\n    this._clean(articleContent, \"footer\");\n    this._clean(articleContent, \"link\");\n    this._clean(articleContent, \"aside\");\n\n    // Clean out elements with little content that have \"share\" in their id/class combinations from final top candidates,\n    // which means we don't remove the top candidates even they have \"share\".\n\n    var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD;\n\n    this._forEachNode(articleContent.children, function (topCandidate) {\n      this._cleanMatchedNodes(topCandidate, function (node, matchString) {\n        return this.REGEXPS.shareElements.test(matchString) && node.textContent.length < shareElementThreshold;\n      });\n    });\n\n    this._clean(articleContent, \"iframe\");\n    this._clean(articleContent, \"input\");\n    this._clean(articleContent, \"textarea\");\n    this._clean(articleContent, \"select\");\n    this._clean(articleContent, \"button\");\n    this._cleanHeaders(articleContent);\n\n    // Do these last as the previous stuff may have removed junk\n    // that will affect these\n    this._cleanConditionally(articleContent, \"table\");\n    this._cleanConditionally(articleContent, \"ul\");\n    this._cleanConditionally(articleContent, \"div\");\n\n    // replace H1 with H2 as H1 should be only title that is displayed separately\n    this._replaceNodeTags(this._getAllNodesWithTag(articleContent, [\"h1\"]), \"h2\");\n\n    // Remove extra paragraphs\n    this._removeNodes(this._getAllNodesWithTag(articleContent, [\"p\"]), function (paragraph) {\n      var imgCount = paragraph.getElementsByTagName(\"img\").length;\n      var embedCount = paragraph.getElementsByTagName(\"embed\").length;\n      var objectCount = paragraph.getElementsByTagName(\"object\").length;\n      // At this point, nasty iframes have been removed, only remain embedded video ones.\n      var iframeCount = paragraph.getElementsByTagName(\"iframe\").length;\n      var totalCount = imgCount + embedCount + objectCount + iframeCount;\n\n      return totalCount === 0 && !this._getInnerText(paragraph, false);\n    });\n\n    this._forEachNode(this._getAllNodesWithTag(articleContent, [\"br\"]), function(br) {\n      var next = this._nextNode(br.nextSibling);\n      if (next && next.tagName == \"P\")\n        br.parentNode.removeChild(br);\n    });\n\n    // Remove single-cell tables\n    this._forEachNode(this._getAllNodesWithTag(articleContent, [\"table\"]), function(table) {\n      var tbody = this._hasSingleTagInsideElement(table, \"TBODY\") ? table.firstElementChild : table;\n      if (this._hasSingleTagInsideElement(tbody, \"TR\")) {\n        var row = tbody.firstElementChild;\n        if (this._hasSingleTagInsideElement(row, \"TD\")) {\n          var cell = row.firstElementChild;\n          cell = this._setNodeTag(cell, this._everyNode(cell.childNodes, this._isPhrasingContent) ? \"P\" : \"DIV\");\n          table.parentNode.replaceChild(cell, table);\n        }\n      }\n    });\n  },\n\n  /**\n   * Initialize a node with the readability object. Also checks the\n   * className/id for special names to add to its score.\n   *\n   * @param Element\n   * @return void\n  **/\n  _initializeNode: function(node) {\n    node.readability = {\"contentScore\": 0};\n\n    switch (node.tagName) {\n      case \"DIV\":\n        node.readability.contentScore += 5;\n        break;\n\n      case \"PRE\":\n      case \"TD\":\n      case \"BLOCKQUOTE\":\n        node.readability.contentScore += 3;\n        break;\n\n      case \"ADDRESS\":\n      case \"OL\":\n      case \"UL\":\n      case \"DL\":\n      case \"DD\":\n      case \"DT\":\n      case \"LI\":\n      case \"FORM\":\n        node.readability.contentScore -= 3;\n        break;\n\n      case \"H1\":\n      case \"H2\":\n      case \"H3\":\n      case \"H4\":\n      case \"H5\":\n      case \"H6\":\n      case \"TH\":\n        node.readability.contentScore -= 5;\n        break;\n    }\n\n    node.readability.contentScore += this._getClassWeight(node);\n  },\n\n  _removeAndGetNext: function(node) {\n    var nextNode = this._getNextNode(node, true);\n    node.parentNode.removeChild(node);\n    return nextNode;\n  },\n\n  /**\n   * Traverse the DOM from node to node, starting at the node passed in.\n   * Pass true for the second parameter to indicate this node itself\n   * (and its kids) are going away, and we want the next node over.\n   *\n   * Calling this in a loop will traverse the DOM depth-first.\n   */\n  _getNextNode: function(node, ignoreSelfAndKids) {\n    // First check for kids if those aren't being ignored\n    if (!ignoreSelfAndKids && node.firstElementChild) {\n      return node.firstElementChild;\n    }\n    // Then for siblings...\n    if (node.nextElementSibling) {\n      return node.nextElementSibling;\n    }\n    // And finally, move up the parent chain *and* find a sibling\n    // (because this is depth-first traversal, we will have already\n    // seen the parent nodes themselves).\n    do {\n      node = node.parentNode;\n    } while (node && !node.nextElementSibling);\n    return node && node.nextElementSibling;\n  },\n\n  // compares second text to first one\n  // 1 = same text, 0 = completely different text\n  // works the way that it splits both texts into words and then finds words that are unique in second text\n  // the result is given by the lower length of unique parts\n  _textSimilarity: function(textA, textB) {\n    var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean);\n    var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean);\n    if (!tokensA.length || !tokensB.length) {\n      return 0;\n    }\n    var uniqTokensB = tokensB.filter(token => !tokensA.includes(token));\n    var distanceB = uniqTokensB.join(\" \").length / tokensB.join(\" \").length;\n    return 1 - distanceB;\n  },\n\n  _checkByline: function(node, matchString) {\n    if (this._articleByline) {\n      return false;\n    }\n\n    if (node.getAttribute !== undefined) {\n      var rel = node.getAttribute(\"rel\");\n      var itemprop = node.getAttribute(\"itemprop\");\n    }\n\n    if ((rel === \"author\" || (itemprop && itemprop.indexOf(\"author\") !== -1) || this.REGEXPS.byline.test(matchString)) && this._isValidByline(node.textContent)) {\n      this._articleByline = node.textContent.trim();\n      return true;\n    }\n\n    return false;\n  },\n\n  _getNodeAncestors: function(node, maxDepth) {\n    maxDepth = maxDepth || 0;\n    var i = 0, ancestors = [];\n    while (node.parentNode) {\n      ancestors.push(node.parentNode);\n      if (maxDepth && ++i === maxDepth)\n        break;\n      node = node.parentNode;\n    }\n    return ancestors;\n  },\n\n  /***\n   * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is\n   *         most likely to be the stuff a user wants to read. Then return it wrapped up in a div.\n   *\n   * @param page a document to run upon. Needs to be a full document, complete with body.\n   * @return Element\n  **/\n  _grabArticle: function (page) {\n    this.log(\"**** grabArticle ****\");\n    var doc = this._doc;\n    var isPaging = page !== null;\n    page = page ? page : this._doc.body;\n\n    // We can't grab an article if we don't have a page!\n    if (!page) {\n      this.log(\"No body found in document. Abort.\");\n      return null;\n    }\n\n    var pageCacheHtml = page.innerHTML;\n\n    while (true) {\n      this.log(\"Starting grabArticle loop\");\n      var stripUnlikelyCandidates = this._flagIsActive(this.FLAG_STRIP_UNLIKELYS);\n\n      // First, node prepping. Trash nodes that look cruddy (like ones with the\n      // class name \"comment\", etc), and turn divs into P tags where they have been\n      // used inappropriately (as in, where they contain no other block level elements.)\n      var elementsToScore = [];\n      var node = this._doc.documentElement;\n\n      let shouldRemoveTitleHeader = true;\n\n      while (node) {\n\n        if (node.tagName === \"HTML\") {\n          this._articleLang = node.getAttribute(\"lang\");\n        }\n\n        var matchString = node.className + \" \" + node.id;\n\n        if (!this._isProbablyVisible(node)) {\n          this.log(\"Removing hidden node - \" + matchString);\n          node = this._removeAndGetNext(node);\n          continue;\n        }\n\n        // User is not able to see elements applied with both \"aria-modal = true\" and \"role = dialog\"\n        if (node.getAttribute(\"aria-modal\") == \"true\" && node.getAttribute(\"role\") == \"dialog\") {\n          node = this._removeAndGetNext(node);\n          continue;\n        }\n\n        // Check to see if this node is a byline, and remove it if it is.\n        if (this._checkByline(node, matchString)) {\n          node = this._removeAndGetNext(node);\n          continue;\n        }\n\n        if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) {\n          this.log(\"Removing header: \", node.textContent.trim(), this._articleTitle.trim());\n          shouldRemoveTitleHeader = false;\n          node = this._removeAndGetNext(node);\n          continue;\n        }\n\n        // Remove unlikely candidates\n        if (stripUnlikelyCandidates) {\n          if (this.REGEXPS.unlikelyCandidates.test(matchString) &&\n              !this.REGEXPS.okMaybeItsACandidate.test(matchString) &&\n              !this._hasAncestorTag(node, \"table\") &&\n              !this._hasAncestorTag(node, \"code\") &&\n              node.tagName !== \"BODY\" &&\n              node.tagName !== \"A\") {\n            this.log(\"Removing unlikely candidate - \" + matchString);\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n\n          if (this.UNLIKELY_ROLES.includes(node.getAttribute(\"role\"))) {\n            this.log(\"Removing content with role \" + node.getAttribute(\"role\") + \" - \" + matchString);\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n        }\n\n        // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe).\n        if ((node.tagName === \"DIV\" || node.tagName === \"SECTION\" || node.tagName === \"HEADER\" ||\n             node.tagName === \"H1\" || node.tagName === \"H2\" || node.tagName === \"H3\" ||\n             node.tagName === \"H4\" || node.tagName === \"H5\" || node.tagName === \"H6\") &&\n            this._isElementWithoutContent(node)) {\n          node = this._removeAndGetNext(node);\n          continue;\n        }\n\n        if (this.DEFAULT_TAGS_TO_SCORE.indexOf(node.tagName) !== -1) {\n          elementsToScore.push(node);\n        }\n\n        // Turn all divs that don't have children block level elements into p's\n        if (node.tagName === \"DIV\") {\n          // Put phrasing content into paragraphs.\n          var p = null;\n          var childNode = node.firstChild;\n          while (childNode) {\n            var nextSibling = childNode.nextSibling;\n            if (this._isPhrasingContent(childNode)) {\n              if (p !== null) {\n                p.appendChild(childNode);\n              } else if (!this._isWhitespace(childNode)) {\n                p = doc.createElement(\"p\");\n                node.replaceChild(p, childNode);\n                p.appendChild(childNode);\n              }\n            } else if (p !== null) {\n              while (p.lastChild && this._isWhitespace(p.lastChild)) {\n                p.removeChild(p.lastChild);\n              }\n              p = null;\n            }\n            childNode = nextSibling;\n          }\n\n          // Sites like http://mobile.slate.com encloses each paragraph with a DIV\n          // element. DIVs with only a P element inside and no text content can be\n          // safely converted into plain P elements to avoid confusing the scoring\n          // algorithm with DIVs with are, in practice, paragraphs.\n          if (this._hasSingleTagInsideElement(node, \"P\") && this._getLinkDensity(node) < 0.25) {\n            var newNode = node.children[0];\n            node.parentNode.replaceChild(newNode, node);\n            node = newNode;\n            elementsToScore.push(node);\n          } else if (!this._hasChildBlockElement(node)) {\n            node = this._setNodeTag(node, \"P\");\n            elementsToScore.push(node);\n          }\n        }\n        node = this._getNextNode(node);\n      }\n\n      /**\n       * Loop through all paragraphs, and assign a score to them based on how content-y they look.\n       * Then add their score to their parent node.\n       *\n       * A score is determined by things like number of commas, class names, etc. Maybe eventually link density.\n      **/\n      var candidates = [];\n      this._forEachNode(elementsToScore, function(elementToScore) {\n        if (!elementToScore.parentNode || typeof(elementToScore.parentNode.tagName) === \"undefined\")\n          return;\n\n        // If this paragraph is less than 25 characters, don't even count it.\n        var innerText = this._getInnerText(elementToScore);\n        if (innerText.length < 25)\n          return;\n\n        // Exclude nodes with no ancestor.\n        var ancestors = this._getNodeAncestors(elementToScore, 5);\n        if (ancestors.length === 0)\n          return;\n\n        var contentScore = 0;\n\n        // Add a point for the paragraph itself as a base.\n        contentScore += 1;\n\n        // Add points for any commas within this paragraph.\n        contentScore += innerText.split(this.REGEXPS.commas).length;\n\n        // For every 100 characters in this paragraph, add another point. Up to 3 points.\n        contentScore += Math.min(Math.floor(innerText.length / 100), 3);\n\n        // Initialize and score ancestors.\n        this._forEachNode(ancestors, function(ancestor, level) {\n          if (!ancestor.tagName || !ancestor.parentNode || typeof(ancestor.parentNode.tagName) === \"undefined\")\n            return;\n\n          if (typeof(ancestor.readability) === \"undefined\") {\n            this._initializeNode(ancestor);\n            candidates.push(ancestor);\n          }\n\n          // Node score divider:\n          // - parent:             1 (no division)\n          // - grandparent:        2\n          // - great grandparent+: ancestor level * 3\n          if (level === 0)\n            var scoreDivider = 1;\n          else if (level === 1)\n            scoreDivider = 2;\n          else\n            scoreDivider = level * 3;\n          ancestor.readability.contentScore += contentScore / scoreDivider;\n        });\n      });\n\n      // After we've calculated scores, loop through all of the possible\n      // candidate nodes we found and find the one with the highest score.\n      var topCandidates = [];\n      for (var c = 0, cl = candidates.length; c < cl; c += 1) {\n        var candidate = candidates[c];\n\n        // Scale the final candidates score based on link density. Good content\n        // should have a relatively small link density (5% or less) and be mostly\n        // unaffected by this operation.\n        var candidateScore = candidate.readability.contentScore * (1 - this._getLinkDensity(candidate));\n        candidate.readability.contentScore = candidateScore;\n\n        this.log(\"Candidate:\", candidate, \"with score \" + candidateScore);\n\n        for (var t = 0; t < this._nbTopCandidates; t++) {\n          var aTopCandidate = topCandidates[t];\n\n          if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) {\n            topCandidates.splice(t, 0, candidate);\n            if (topCandidates.length > this._nbTopCandidates)\n              topCandidates.pop();\n            break;\n          }\n        }\n      }\n\n      var topCandidate = topCandidates[0] || null;\n      var neededToCreateTopCandidate = false;\n      var parentOfTopCandidate;\n\n      // If we still have no top candidate, just use the body as a last resort.\n      // We also have to copy the body node so it is something we can modify.\n      if (topCandidate === null || topCandidate.tagName === \"BODY\") {\n        // Move all of the page's children into topCandidate\n        topCandidate = doc.createElement(\"DIV\");\n        neededToCreateTopCandidate = true;\n        // Move everything (not just elements, also text nodes etc.) into the container\n        // so we even include text directly in the body:\n        while (page.firstChild) {\n          this.log(\"Moving child out:\", page.firstChild);\n          topCandidate.appendChild(page.firstChild);\n        }\n\n        page.appendChild(topCandidate);\n\n        this._initializeNode(topCandidate);\n      } else if (topCandidate) {\n        // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array\n        // and whose scores are quite closed with current `topCandidate` node.\n        var alternativeCandidateAncestors = [];\n        for (var i = 1; i < topCandidates.length; i++) {\n          if (topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >= 0.75) {\n            alternativeCandidateAncestors.push(this._getNodeAncestors(topCandidates[i]));\n          }\n        }\n        var MINIMUM_TOPCANDIDATES = 3;\n        if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) {\n          parentOfTopCandidate = topCandidate.parentNode;\n          while (parentOfTopCandidate.tagName !== \"BODY\") {\n            var listsContainingThisAncestor = 0;\n            for (var ancestorIndex = 0; ancestorIndex < alternativeCandidateAncestors.length && listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; ancestorIndex++) {\n              listsContainingThisAncestor += Number(alternativeCandidateAncestors[ancestorIndex].includes(parentOfTopCandidate));\n            }\n            if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) {\n              topCandidate = parentOfTopCandidate;\n              break;\n            }\n            parentOfTopCandidate = parentOfTopCandidate.parentNode;\n          }\n        }\n        if (!topCandidate.readability) {\n          this._initializeNode(topCandidate);\n        }\n\n        // Because of our bonus system, parents of candidates might have scores\n        // themselves. They get half of the node. There won't be nodes with higher\n        // scores than our topCandidate, but if we see the score going *up* in the first\n        // few steps up the tree, that's a decent sign that there might be more content\n        // lurking in other places that we want to unify in. The sibling stuff\n        // below does some of that - but only if we've looked high enough up the DOM\n        // tree.\n        parentOfTopCandidate = topCandidate.parentNode;\n        var lastScore = topCandidate.readability.contentScore;\n        // The scores shouldn't get too low.\n        var scoreThreshold = lastScore / 3;\n        while (parentOfTopCandidate.tagName !== \"BODY\") {\n          if (!parentOfTopCandidate.readability) {\n            parentOfTopCandidate = parentOfTopCandidate.parentNode;\n            continue;\n          }\n          var parentScore = parentOfTopCandidate.readability.contentScore;\n          if (parentScore < scoreThreshold)\n            break;\n          if (parentScore > lastScore) {\n            // Alright! We found a better parent to use.\n            topCandidate = parentOfTopCandidate;\n            break;\n          }\n          lastScore = parentOfTopCandidate.readability.contentScore;\n          parentOfTopCandidate = parentOfTopCandidate.parentNode;\n        }\n\n        // If the top candidate is the only child, use parent instead. This will help sibling\n        // joining logic when adjacent content is actually located in parent's sibling node.\n        parentOfTopCandidate = topCandidate.parentNode;\n        while (parentOfTopCandidate.tagName != \"BODY\" && parentOfTopCandidate.children.length == 1) {\n          topCandidate = parentOfTopCandidate;\n          parentOfTopCandidate = topCandidate.parentNode;\n        }\n        if (!topCandidate.readability) {\n          this._initializeNode(topCandidate);\n        }\n      }\n\n      // Now that we have the top candidate, look through its siblings for content\n      // that might also be related. Things like preambles, content split by ads\n      // that we removed, etc.\n      var articleContent = doc.createElement(\"DIV\");\n      if (isPaging)\n        articleContent.id = \"readability-content\";\n\n      var siblingScoreThreshold = Math.max(10, topCandidate.readability.contentScore * 0.2);\n      // Keep potential top candidate's parent node to try to get text direction of it later.\n      parentOfTopCandidate = topCandidate.parentNode;\n      var siblings = parentOfTopCandidate.children;\n\n      for (var s = 0, sl = siblings.length; s < sl; s++) {\n        var sibling = siblings[s];\n        var append = false;\n\n        this.log(\"Looking at sibling node:\", sibling, sibling.readability ? (\"with score \" + sibling.readability.contentScore) : \"\");\n        this.log(\"Sibling has score\", sibling.readability ? sibling.readability.contentScore : \"Unknown\");\n\n        if (sibling === topCandidate) {\n          append = true;\n        } else {\n          var contentBonus = 0;\n\n          // Give a bonus if sibling nodes and top candidates have the example same classname\n          if (sibling.className === topCandidate.className && topCandidate.className !== \"\")\n            contentBonus += topCandidate.readability.contentScore * 0.2;\n\n          if (sibling.readability &&\n              ((sibling.readability.contentScore + contentBonus) >= siblingScoreThreshold)) {\n            append = true;\n          } else if (sibling.nodeName === \"P\") {\n            var linkDensity = this._getLinkDensity(sibling);\n            var nodeContent = this._getInnerText(sibling);\n            var nodeLength = nodeContent.length;\n\n            if (nodeLength > 80 && linkDensity < 0.25) {\n              append = true;\n            } else if (nodeLength < 80 && nodeLength > 0 && linkDensity === 0 &&\n                       nodeContent.search(/\\.( |$)/) !== -1) {\n              append = true;\n            }\n          }\n        }\n\n        if (append) {\n          this.log(\"Appending node:\", sibling);\n\n          if (this.ALTER_TO_DIV_EXCEPTIONS.indexOf(sibling.nodeName) === -1) {\n            // We have a node that isn't a common block level element, like a form or td tag.\n            // Turn it into a div so it doesn't get filtered out later by accident.\n            this.log(\"Altering sibling:\", sibling, \"to div.\");\n\n            sibling = this._setNodeTag(sibling, \"DIV\");\n          }\n\n          articleContent.appendChild(sibling);\n          // Fetch children again to make it compatible\n          // with DOM parsers without live collection support.\n          siblings = parentOfTopCandidate.children;\n          // siblings is a reference to the children array, and\n          // sibling is removed from the array when we call appendChild().\n          // As a result, we must revisit this index since the nodes\n          // have been shifted.\n          s -= 1;\n          sl -= 1;\n        }\n      }\n\n      if (this._debug)\n        this.log(\"Article content pre-prep: \" + articleContent.innerHTML);\n      // So we have all of the content that we need. Now we clean it up for presentation.\n      this._prepArticle(articleContent);\n      if (this._debug)\n        this.log(\"Article content post-prep: \" + articleContent.innerHTML);\n\n      if (neededToCreateTopCandidate) {\n        // We already created a fake div thing, and there wouldn't have been any siblings left\n        // for the previous loop, so there's no point trying to create a new div, and then\n        // move all the children over. Just assign IDs and class names here. No need to append\n        // because that already happened anyway.\n        topCandidate.id = \"readability-page-1\";\n        topCandidate.className = \"page\";\n      } else {\n        var div = doc.createElement(\"DIV\");\n        div.id = \"readability-page-1\";\n        div.className = \"page\";\n        while (articleContent.firstChild) {\n          div.appendChild(articleContent.firstChild);\n        }\n        articleContent.appendChild(div);\n      }\n\n      if (this._debug)\n        this.log(\"Article content after paging: \" + articleContent.innerHTML);\n\n      var parseSuccessful = true;\n\n      // Now that we've gone through the full algorithm, check to see if\n      // we got any meaningful content. If we didn't, we may need to re-run\n      // grabArticle with different flags set. This gives us a higher likelihood of\n      // finding the content, and the sieve approach gives us a higher likelihood of\n      // finding the -right- content.\n      var textLength = this._getInnerText(articleContent, true).length;\n      if (textLength < this._charThreshold) {\n        parseSuccessful = false;\n        page.innerHTML = pageCacheHtml;\n\n        if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) {\n          this._removeFlag(this.FLAG_STRIP_UNLIKELYS);\n          this._attempts.push({articleContent: articleContent, textLength: textLength});\n        } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) {\n          this._removeFlag(this.FLAG_WEIGHT_CLASSES);\n          this._attempts.push({articleContent: articleContent, textLength: textLength});\n        } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) {\n          this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY);\n          this._attempts.push({articleContent: articleContent, textLength: textLength});\n        } else {\n          this._attempts.push({articleContent: articleContent, textLength: textLength});\n          // No luck after removing flags, just return the longest text we found during the different loops\n          this._attempts.sort(function (a, b) {\n            return b.textLength - a.textLength;\n          });\n\n          // But first check if we actually have something\n          if (!this._attempts[0].textLength) {\n            return null;\n          }\n\n          articleContent = this._attempts[0].articleContent;\n          parseSuccessful = true;\n        }\n      }\n\n      if (parseSuccessful) {\n        // Find out text direction from ancestors of final top candidate.\n        var ancestors = [parentOfTopCandidate, topCandidate].concat(this._getNodeAncestors(parentOfTopCandidate));\n        this._someNode(ancestors, function(ancestor) {\n          if (!ancestor.tagName)\n            return false;\n          var articleDir = ancestor.getAttribute(\"dir\");\n          if (articleDir) {\n            this._articleDir = articleDir;\n            return true;\n          }\n          return false;\n        });\n        return articleContent;\n      }\n    }\n  },\n\n  /**\n   * Check whether the input string could be a byline.\n   * This verifies that the input is a string, and that the length\n   * is less than 100 chars.\n   *\n   * @param possibleByline {string} - a string to check whether its a byline.\n   * @return Boolean - whether the input string is a byline.\n   */\n  _isValidByline: function(byline) {\n    if (typeof byline == \"string\" || byline instanceof String) {\n      byline = byline.trim();\n      return (byline.length > 0) && (byline.length < 100);\n    }\n    return false;\n  },\n\n  /**\n   * Converts some of the common HTML entities in string to their corresponding characters.\n   *\n   * @param str {string} - a string to unescape.\n   * @return string without HTML entity.\n   */\n  _unescapeHtmlEntities: function(str) {\n    if (!str) {\n      return str;\n    }\n\n    var htmlEscapeMap = this.HTML_ESCAPE_MAP;\n    return str.replace(/&(quot|amp|apos|lt|gt);/g, function(_, tag) {\n      return htmlEscapeMap[tag];\n    }).replace(/&#(?:x([0-9a-z]{1,4})|([0-9]{1,4}));/gi, function(_, hex, numStr) {\n      var num = parseInt(hex || numStr, hex ? 16 : 10);\n      return String.fromCharCode(num);\n    });\n  },\n\n  /**\n   * Try to extract metadata from JSON-LD object.\n   * For now, only Schema.org objects of type Article or its subtypes are supported.\n   * @return Object with any metadata that could be extracted (possibly none)\n   */\n  _getJSONLD: function (doc) {\n    var scripts = this._getAllNodesWithTag(doc, [\"script\"]);\n\n    var metadata;\n\n    this._forEachNode(scripts, function(jsonLdElement) {\n      if (!metadata && jsonLdElement.getAttribute(\"type\") === \"application/ld+json\") {\n        try {\n          // Strip CDATA markers if present\n          var content = jsonLdElement.textContent.replace(/^\\s*<!\\[CDATA\\[|\\]\\]>\\s*$/g, \"\");\n          var parsed = JSON.parse(content);\n          if (\n            !parsed[\"@context\"] ||\n            !parsed[\"@context\"].match(/^https?\\:\\/\\/schema\\.org$/)\n          ) {\n            return;\n          }\n\n          if (!parsed[\"@type\"] && Array.isArray(parsed[\"@graph\"])) {\n            parsed = parsed[\"@graph\"].find(function(it) {\n              return (it[\"@type\"] || \"\").match(\n                this.REGEXPS.jsonLdArticleTypes\n              );\n            });\n          }\n\n          if (\n            !parsed ||\n            !parsed[\"@type\"] ||\n            !parsed[\"@type\"].match(this.REGEXPS.jsonLdArticleTypes)\n          ) {\n            return;\n          }\n\n          metadata = {};\n\n          if (typeof parsed.name === \"string\" && typeof parsed.headline === \"string\" && parsed.name !== parsed.headline) {\n            // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz\n            // put their own name into \"name\" and the article title to \"headline\" which confuses Readability. So we try to check if either\n            // \"name\" or \"headline\" closely matches the html title, and if so, use that one. If not, then we use \"name\" by default.\n\n            var title = this._getArticleTitle();\n            var nameMatches = this._textSimilarity(parsed.name, title) > 0.75;\n            var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75;\n\n            if (headlineMatches && !nameMatches) {\n              metadata.title = parsed.headline;\n            } else {\n              metadata.title = parsed.name;\n            }\n          } else if (typeof parsed.name === \"string\") {\n            metadata.title = parsed.name.trim();\n          } else if (typeof parsed.headline === \"string\") {\n            metadata.title = parsed.headline.trim();\n          }\n          if (parsed.author) {\n            if (typeof parsed.author.name === \"string\") {\n              metadata.byline = parsed.author.name.trim();\n            } else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === \"string\") {\n              metadata.byline = parsed.author\n                .filter(function(author) {\n                  return author && typeof author.name === \"string\";\n                })\n                .map(function(author) {\n                  return author.name.trim();\n                })\n                .join(\", \");\n            }\n          }\n          if (typeof parsed.description === \"string\") {\n            metadata.excerpt = parsed.description.trim();\n          }\n          if (\n            parsed.publisher &&\n            typeof parsed.publisher.name === \"string\"\n          ) {\n            metadata.siteName = parsed.publisher.name.trim();\n          }\n          if (typeof parsed.datePublished === \"string\") {\n            metadata.datePublished = parsed.datePublished.trim();\n          }\n          return;\n        } catch (err) {\n          this.log(err.message);\n        }\n      }\n    });\n    return metadata ? metadata : {};\n  },\n\n  /**\n   * Attempts to get excerpt and byline metadata for the article.\n   *\n   * @param {Object} jsonld — object containing any metadata that\n   * could be extracted from JSON-LD object.\n   *\n   * @return Object with optional \"excerpt\" and \"byline\" properties\n   */\n  _getArticleMetadata: function(jsonld) {\n    var metadata = {};\n    var values = {};\n    var metaElements = this._doc.getElementsByTagName(\"meta\");\n\n    // property is a space-separated list of values\n    var propertyPattern = /\\s*(article|dc|dcterm|og|twitter)\\s*:\\s*(author|creator|description|published_time|title|site_name)\\s*/gi;\n\n    // name is a single value\n    var namePattern = /^\\s*(?:(dc|dcterm|og|twitter|weibo:(article|webpage))\\s*[\\.:]\\s*)?(author|creator|description|title|site_name)\\s*$/i;\n\n    // Find description tags.\n    this._forEachNode(metaElements, function(element) {\n      var elementName = element.getAttribute(\"name\");\n      var elementProperty = element.getAttribute(\"property\");\n      var content = element.getAttribute(\"content\");\n      if (!content) {\n        return;\n      }\n      var matches = null;\n      var name = null;\n\n      if (elementProperty) {\n        matches = elementProperty.match(propertyPattern);\n        if (matches) {\n          // Convert to lowercase, and remove any whitespace\n          // so we can match below.\n          name = matches[0].toLowerCase().replace(/\\s/g, \"\");\n          // multiple authors\n          values[name] = content.trim();\n        }\n      }\n      if (!matches && elementName && namePattern.test(elementName)) {\n        name = elementName;\n        if (content) {\n          // Convert to lowercase, remove any whitespace, and convert dots\n          // to colons so we can match below.\n          name = name.toLowerCase().replace(/\\s/g, \"\").replace(/\\./g, \":\");\n          values[name] = content.trim();\n        }\n      }\n    });\n\n    // get title\n    metadata.title = jsonld.title ||\n                     values[\"dc:title\"] ||\n                     values[\"dcterm:title\"] ||\n                     values[\"og:title\"] ||\n                     values[\"weibo:article:title\"] ||\n                     values[\"weibo:webpage:title\"] ||\n                     values[\"title\"] ||\n                     values[\"twitter:title\"];\n\n    if (!metadata.title) {\n      metadata.title = this._getArticleTitle();\n    }\n\n    // get author\n    metadata.byline = jsonld.byline ||\n                      values[\"dc:creator\"] ||\n                      values[\"dcterm:creator\"] ||\n                      values[\"author\"];\n\n    // get description\n    metadata.excerpt = jsonld.excerpt ||\n                       values[\"dc:description\"] ||\n                       values[\"dcterm:description\"] ||\n                       values[\"og:description\"] ||\n                       values[\"weibo:article:description\"] ||\n                       values[\"weibo:webpage:description\"] ||\n                       values[\"description\"] ||\n                       values[\"twitter:description\"];\n\n    // get site name\n    metadata.siteName = jsonld.siteName ||\n                        values[\"og:site_name\"];\n\n    // get article published time\n    metadata.publishedTime = jsonld.datePublished ||\n      values[\"article:published_time\"] || null;\n\n    // in many sites the meta value is escaped with HTML entities,\n    // so here we need to unescape it\n    metadata.title = this._unescapeHtmlEntities(metadata.title);\n    metadata.byline = this._unescapeHtmlEntities(metadata.byline);\n    metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt);\n    metadata.siteName = this._unescapeHtmlEntities(metadata.siteName);\n    metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime);\n\n    return metadata;\n  },\n\n  /**\n   * Check if node is image, or if node contains exactly only one image\n   * whether as a direct child or as its descendants.\n   *\n   * @param Element\n  **/\n  _isSingleImage: function(node) {\n    if (node.tagName === \"IMG\") {\n      return true;\n    }\n\n    if (node.children.length !== 1 || node.textContent.trim() !== \"\") {\n      return false;\n    }\n\n    return this._isSingleImage(node.children[0]);\n  },\n\n  /**\n   * Find all <noscript> that are located after <img> nodes, and which contain only one\n   * <img> element. Replace the first image with the image from inside the <noscript> tag,\n   * and remove the <noscript> tag. This improves the quality of the images we use on\n   * some sites (e.g. Medium).\n   *\n   * @param Element\n  **/\n  _unwrapNoscriptImages: function(doc) {\n    // Find img without source or attributes that might contains image, and remove it.\n    // This is done to prevent a placeholder img is replaced by img from noscript in next step.\n    var imgs = Array.from(doc.getElementsByTagName(\"img\"));\n    this._forEachNode(imgs, function(img) {\n      for (var i = 0; i < img.attributes.length; i++) {\n        var attr = img.attributes[i];\n        switch (attr.name) {\n          case \"src\":\n          case \"srcset\":\n          case \"data-src\":\n          case \"data-srcset\":\n            return;\n        }\n\n        if (/\\.(jpg|jpeg|png|webp)/i.test(attr.value)) {\n          return;\n        }\n      }\n\n      img.parentNode.removeChild(img);\n    });\n\n    // Next find noscript and try to extract its image\n    var noscripts = Array.from(doc.getElementsByTagName(\"noscript\"));\n    this._forEachNode(noscripts, function(noscript) {\n      // Parse content of noscript and make sure it only contains image\n      var tmp = doc.createElement(\"div\");\n      tmp.innerHTML = noscript.innerHTML;\n      if (!this._isSingleImage(tmp)) {\n        return;\n      }\n\n      // If noscript has previous sibling and it only contains image,\n      // replace it with noscript content. However we also keep old\n      // attributes that might contains image.\n      var prevElement = noscript.previousElementSibling;\n      if (prevElement && this._isSingleImage(prevElement)) {\n        var prevImg = prevElement;\n        if (prevImg.tagName !== \"IMG\") {\n          prevImg = prevElement.getElementsByTagName(\"img\")[0];\n        }\n\n        var newImg = tmp.getElementsByTagName(\"img\")[0];\n        for (var i = 0; i < prevImg.attributes.length; i++) {\n          var attr = prevImg.attributes[i];\n          if (attr.value === \"\") {\n            continue;\n          }\n\n          if (attr.name === \"src\" || attr.name === \"srcset\" || /\\.(jpg|jpeg|png|webp)/i.test(attr.value)) {\n            if (newImg.getAttribute(attr.name) === attr.value) {\n              continue;\n            }\n\n            var attrName = attr.name;\n            if (newImg.hasAttribute(attrName)) {\n              attrName = \"data-old-\" + attrName;\n            }\n\n            newImg.setAttribute(attrName, attr.value);\n          }\n        }\n\n        noscript.parentNode.replaceChild(tmp.firstElementChild, prevElement);\n      }\n    });\n  },\n\n  /**\n   * Removes script tags from the document.\n   *\n   * @param Element\n  **/\n  _removeScripts: function(doc) {\n    this._removeNodes(this._getAllNodesWithTag(doc, [\"script\", \"noscript\"]));\n  },\n\n  /**\n   * Check if this node has only whitespace and a single element with given tag\n   * Returns false if the DIV node contains non-empty text nodes\n   * or if it contains no element with given tag or more than 1 element.\n   *\n   * @param Element\n   * @param string tag of child element\n  **/\n  _hasSingleTagInsideElement: function(element, tag) {\n    // There should be exactly 1 element child with given tag\n    if (element.children.length != 1 || element.children[0].tagName !== tag) {\n      return false;\n    }\n\n    // And there should be no text nodes with real content\n    return !this._someNode(element.childNodes, function(node) {\n      return node.nodeType === this.TEXT_NODE &&\n             this.REGEXPS.hasContent.test(node.textContent);\n    });\n  },\n\n  _isElementWithoutContent: function(node) {\n    return node.nodeType === this.ELEMENT_NODE &&\n      node.textContent.trim().length == 0 &&\n      (node.children.length == 0 ||\n       node.children.length == node.getElementsByTagName(\"br\").length + node.getElementsByTagName(\"hr\").length);\n  },\n\n  /**\n   * Determine whether element has any children block level elements.\n   *\n   * @param Element\n   */\n  _hasChildBlockElement: function (element) {\n    return this._someNode(element.childNodes, function(node) {\n      return this.DIV_TO_P_ELEMS.has(node.tagName) ||\n             this._hasChildBlockElement(node);\n    });\n  },\n\n  /***\n   * Determine if a node qualifies as phrasing content.\n   * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content\n  **/\n  _isPhrasingContent: function(node) {\n    return node.nodeType === this.TEXT_NODE || this.PHRASING_ELEMS.indexOf(node.tagName) !== -1 ||\n      ((node.tagName === \"A\" || node.tagName === \"DEL\" || node.tagName === \"INS\") &&\n        this._everyNode(node.childNodes, this._isPhrasingContent));\n  },\n\n  _isWhitespace: function(node) {\n    return (node.nodeType === this.TEXT_NODE && node.textContent.trim().length === 0) ||\n           (node.nodeType === this.ELEMENT_NODE && node.tagName === \"BR\");\n  },\n\n  /**\n   * Get the inner text of a node - cross browser compatibly.\n   * This also strips out any excess whitespace to be found.\n   *\n   * @param Element\n   * @param Boolean normalizeSpaces (default: true)\n   * @return string\n  **/\n  _getInnerText: function(e, normalizeSpaces) {\n    normalizeSpaces = (typeof normalizeSpaces === \"undefined\") ? true : normalizeSpaces;\n    var textContent = e.textContent.trim();\n\n    if (normalizeSpaces) {\n      return textContent.replace(this.REGEXPS.normalize, \" \");\n    }\n    return textContent;\n  },\n\n  /**\n   * Get the number of times a string s appears in the node e.\n   *\n   * @param Element\n   * @param string - what to split on. Default is \",\"\n   * @return number (integer)\n  **/\n  _getCharCount: function(e, s) {\n    s = s || \",\";\n    return this._getInnerText(e).split(s).length - 1;\n  },\n\n  /**\n   * Remove the style attribute on every e and under.\n   * TODO: Test if getElementsByTagName(*) is faster.\n   *\n   * @param Element\n   * @return void\n  **/\n  _cleanStyles: function(e) {\n    if (!e || e.tagName.toLowerCase() === \"svg\")\n      return;\n\n    // Remove `style` and deprecated presentational attributes\n    for (var i = 0; i < this.PRESENTATIONAL_ATTRIBUTES.length; i++) {\n      e.removeAttribute(this.PRESENTATIONAL_ATTRIBUTES[i]);\n    }\n\n    if (this.DEPRECATED_SIZE_ATTRIBUTE_ELEMS.indexOf(e.tagName) !== -1) {\n      e.removeAttribute(\"width\");\n      e.removeAttribute(\"height\");\n    }\n\n    var cur = e.firstElementChild;\n    while (cur !== null) {\n      this._cleanStyles(cur);\n      cur = cur.nextElementSibling;\n    }\n  },\n\n  /**\n   * Get the density of links as a percentage of the content\n   * This is the amount of text that is inside a link divided by the total text in the node.\n   *\n   * @param Element\n   * @return number (float)\n  **/\n  _getLinkDensity: function(element) {\n    var textLength = this._getInnerText(element).length;\n    if (textLength === 0)\n      return 0;\n\n    var linkLength = 0;\n\n    // XXX implement _reduceNodeList?\n    this._forEachNode(element.getElementsByTagName(\"a\"), function(linkNode) {\n      var href = linkNode.getAttribute(\"href\");\n      var coefficient = href && this.REGEXPS.hashUrl.test(href) ? 0.3 : 1;\n      linkLength += this._getInnerText(linkNode).length * coefficient;\n    });\n\n    return linkLength / textLength;\n  },\n\n  /**\n   * Get an elements class/id weight. Uses regular expressions to tell if this\n   * element looks good or bad.\n   *\n   * @param Element\n   * @return number (Integer)\n  **/\n  _getClassWeight: function(e) {\n    if (!this._flagIsActive(this.FLAG_WEIGHT_CLASSES))\n      return 0;\n\n    var weight = 0;\n\n    // Look for a special classname\n    if (typeof(e.className) === \"string\" && e.className !== \"\") {\n      if (this.REGEXPS.negative.test(e.className))\n        weight -= 25;\n\n      if (this.REGEXPS.positive.test(e.className))\n        weight += 25;\n    }\n\n    // Look for a special ID\n    if (typeof(e.id) === \"string\" && e.id !== \"\") {\n      if (this.REGEXPS.negative.test(e.id))\n        weight -= 25;\n\n      if (this.REGEXPS.positive.test(e.id))\n        weight += 25;\n    }\n\n    return weight;\n  },\n\n  /**\n   * Clean a node of all elements of type \"tag\".\n   * (Unless it's a youtube/vimeo video. People love movies.)\n   *\n   * @param Element\n   * @param string tag to clean\n   * @return void\n   **/\n  _clean: function(e, tag) {\n    var isEmbed = [\"object\", \"embed\", \"iframe\"].indexOf(tag) !== -1;\n\n    this._removeNodes(this._getAllNodesWithTag(e, [tag]), function(element) {\n      // Allow youtube and vimeo videos through as people usually want to see those.\n      if (isEmbed) {\n        // First, check the elements attributes to see if any of them contain youtube or vimeo\n        for (var i = 0; i < element.attributes.length; i++) {\n          if (this._allowedVideoRegex.test(element.attributes[i].value)) {\n            return false;\n          }\n        }\n\n        // For embed with <object> tag, check inner HTML as well.\n        if (element.tagName === \"object\" && this._allowedVideoRegex.test(element.innerHTML)) {\n          return false;\n        }\n      }\n\n      return true;\n    });\n  },\n\n  /**\n   * Check if a given node has one of its ancestor tag name matching the\n   * provided one.\n   * @param  HTMLElement node\n   * @param  String      tagName\n   * @param  Number      maxDepth\n   * @param  Function    filterFn a filter to invoke to determine whether this node 'counts'\n   * @return Boolean\n   */\n  _hasAncestorTag: function(node, tagName, maxDepth, filterFn) {\n    maxDepth = maxDepth || 3;\n    tagName = tagName.toUpperCase();\n    var depth = 0;\n    while (node.parentNode) {\n      if (maxDepth > 0 && depth > maxDepth)\n        return false;\n      if (node.parentNode.tagName === tagName && (!filterFn || filterFn(node.parentNode)))\n        return true;\n      node = node.parentNode;\n      depth++;\n    }\n    return false;\n  },\n\n  /**\n   * Return an object indicating how many rows and columns this table has.\n   */\n  _getRowAndColumnCount: function(table) {\n    var rows = 0;\n    var columns = 0;\n    var trs = table.getElementsByTagName(\"tr\");\n    for (var i = 0; i < trs.length; i++) {\n      var rowspan = trs[i].getAttribute(\"rowspan\") || 0;\n      if (rowspan) {\n        rowspan = parseInt(rowspan, 10);\n      }\n      rows += (rowspan || 1);\n\n      // Now look for column-related info\n      var columnsInThisRow = 0;\n      var cells = trs[i].getElementsByTagName(\"td\");\n      for (var j = 0; j < cells.length; j++) {\n        var colspan = cells[j].getAttribute(\"colspan\") || 0;\n        if (colspan) {\n          colspan = parseInt(colspan, 10);\n        }\n        columnsInThisRow += (colspan || 1);\n      }\n      columns = Math.max(columns, columnsInThisRow);\n    }\n    return {rows: rows, columns: columns};\n  },\n\n  /**\n   * Look for 'data' (as opposed to 'layout') tables, for which we use\n   * similar checks as\n   * https://searchfox.org/mozilla-central/rev/f82d5c549f046cb64ce5602bfd894b7ae807c8f8/accessible/generic/TableAccessible.cpp#19\n   */\n  _markDataTables: function(root) {\n    var tables = root.getElementsByTagName(\"table\");\n    for (var i = 0; i < tables.length; i++) {\n      var table = tables[i];\n      var role = table.getAttribute(\"role\");\n      if (role == \"presentation\") {\n        table._readabilityDataTable = false;\n        continue;\n      }\n      var datatable = table.getAttribute(\"datatable\");\n      if (datatable == \"0\") {\n        table._readabilityDataTable = false;\n        continue;\n      }\n      var summary = table.getAttribute(\"summary\");\n      if (summary) {\n        table._readabilityDataTable = true;\n        continue;\n      }\n\n      var caption = table.getElementsByTagName(\"caption\")[0];\n      if (caption && caption.childNodes.length > 0) {\n        table._readabilityDataTable = true;\n        continue;\n      }\n\n      // If the table has a descendant with any of these tags, consider a data table:\n      var dataTableDescendants = [\"col\", \"colgroup\", \"tfoot\", \"thead\", \"th\"];\n      var descendantExists = function(tag) {\n        return !!table.getElementsByTagName(tag)[0];\n      };\n      if (dataTableDescendants.some(descendantExists)) {\n        this.log(\"Data table because found data-y descendant\");\n        table._readabilityDataTable = true;\n        continue;\n      }\n\n      // Nested tables indicate a layout table:\n      if (table.getElementsByTagName(\"table\")[0]) {\n        table._readabilityDataTable = false;\n        continue;\n      }\n\n      var sizeInfo = this._getRowAndColumnCount(table);\n      if (sizeInfo.rows >= 10 || sizeInfo.columns > 4) {\n        table._readabilityDataTable = true;\n        continue;\n      }\n      // Now just go by size entirely:\n      table._readabilityDataTable = sizeInfo.rows * sizeInfo.columns > 10;\n    }\n  },\n\n  /* convert images and figures that have properties like data-src into images that can be loaded without JS */\n  _fixLazyImages: function (root) {\n    this._forEachNode(this._getAllNodesWithTag(root, [\"img\", \"picture\", \"figure\"]), function (elem) {\n      // In some sites (e.g. Kotaku), they put 1px square image as base64 data uri in the src attribute.\n      // So, here we check if the data uri is too short, just might as well remove it.\n      if (elem.src && this.REGEXPS.b64DataUrl.test(elem.src)) {\n        // Make sure it's not SVG, because SVG can have a meaningful image in under 133 bytes.\n        var parts = this.REGEXPS.b64DataUrl.exec(elem.src);\n        if (parts[1] === \"image/svg+xml\") {\n          return;\n        }\n\n        // Make sure this element has other attributes which contains image.\n        // If it doesn't, then this src is important and shouldn't be removed.\n        var srcCouldBeRemoved = false;\n        for (var i = 0; i < elem.attributes.length; i++) {\n          var attr = elem.attributes[i];\n          if (attr.name === \"src\") {\n            continue;\n          }\n\n          if (/\\.(jpg|jpeg|png|webp)/i.test(attr.value)) {\n            srcCouldBeRemoved = true;\n            break;\n          }\n        }\n\n        // Here we assume if image is less than 100 bytes (or 133B after encoded to base64)\n        // it will be too small, therefore it might be placeholder image.\n        if (srcCouldBeRemoved) {\n          var b64starts = elem.src.search(/base64\\s*/i) + 7;\n          var b64length = elem.src.length - b64starts;\n          if (b64length < 133) {\n            elem.removeAttribute(\"src\");\n          }\n        }\n      }\n\n      // also check for \"null\" to work around https://github.com/jsdom/jsdom/issues/2580\n      if ((elem.src || (elem.srcset && elem.srcset != \"null\")) && elem.className.toLowerCase().indexOf(\"lazy\") === -1) {\n        return;\n      }\n\n      for (var j = 0; j < elem.attributes.length; j++) {\n        attr = elem.attributes[j];\n        if (attr.name === \"src\" || attr.name === \"srcset\" || attr.name === \"alt\") {\n          continue;\n        }\n        var copyTo = null;\n        if (/\\.(jpg|jpeg|png|webp)\\s+\\d/.test(attr.value)) {\n          copyTo = \"srcset\";\n        } else if (/^\\s*\\S+\\.(jpg|jpeg|png|webp)\\S*\\s*$/.test(attr.value)) {\n          copyTo = \"src\";\n        }\n        if (copyTo) {\n          //if this is an img or picture, set the attribute directly\n          if (elem.tagName === \"IMG\" || elem.tagName === \"PICTURE\") {\n            elem.setAttribute(copyTo, attr.value);\n          } else if (elem.tagName === \"FIGURE\" && !this._getAllNodesWithTag(elem, [\"img\", \"picture\"]).length) {\n            //if the item is a <figure> that does not contain an image or picture, create one and place it inside the figure\n            //see the nytimes-3 testcase for an example\n            var img = this._doc.createElement(\"img\");\n            img.setAttribute(copyTo, attr.value);\n            elem.appendChild(img);\n          }\n        }\n      }\n    });\n  },\n\n  _getTextDensity: function(e, tags) {\n    var textLength = this._getInnerText(e, true).length;\n    if (textLength === 0) {\n      return 0;\n    }\n    var childrenLength = 0;\n    var children = this._getAllNodesWithTag(e, tags);\n    this._forEachNode(children, (child) => childrenLength += this._getInnerText(child, true).length);\n    return childrenLength / textLength;\n  },\n\n  /**\n   * Clean an element of all tags of type \"tag\" if they look fishy.\n   * \"Fishy\" is an algorithm based on content length, classnames, link density, number of images & embeds, etc.\n   *\n   * @return void\n   **/\n  _cleanConditionally: function(e, tag) {\n    if (!this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY))\n      return;\n\n    // Gather counts for other typical elements embedded within.\n    // Traverse backwards so we can remove nodes at the same time\n    // without effecting the traversal.\n    //\n    // TODO: Consider taking into account original contentScore here.\n    this._removeNodes(this._getAllNodesWithTag(e, [tag]), function(node) {\n      // First check if this node IS data table, in which case don't remove it.\n      var isDataTable = function(t) {\n        return t._readabilityDataTable;\n      };\n\n      var isList = tag === \"ul\" || tag === \"ol\";\n      if (!isList) {\n        var listLength = 0;\n        var listNodes = this._getAllNodesWithTag(node, [\"ul\", \"ol\"]);\n        this._forEachNode(listNodes, (list) => listLength += this._getInnerText(list).length);\n        isList = listLength / this._getInnerText(node).length > 0.9;\n      }\n\n      if (tag === \"table\" && isDataTable(node)) {\n        return false;\n      }\n\n      // Next check if we're inside a data table, in which case don't remove it as well.\n      if (this._hasAncestorTag(node, \"table\", -1, isDataTable)) {\n        return false;\n      }\n\n      if (this._hasAncestorTag(node, \"code\")) {\n        return false;\n      }\n\n      var weight = this._getClassWeight(node);\n\n      this.log(\"Cleaning Conditionally\", node);\n\n      var contentScore = 0;\n\n      if (weight + contentScore < 0) {\n        return true;\n      }\n\n      if (this._getCharCount(node, \",\") < 10) {\n        // If there are not very many commas, and the number of\n        // non-paragraph elements is more than paragraphs or other\n        // ominous signs, remove the element.\n        var p = node.getElementsByTagName(\"p\").length;\n        var img = node.getElementsByTagName(\"img\").length;\n        var li = node.getElementsByTagName(\"li\").length - 100;\n        var input = node.getElementsByTagName(\"input\").length;\n        var headingDensity = this._getTextDensity(node, [\"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\"]);\n\n        var embedCount = 0;\n        var embeds = this._getAllNodesWithTag(node, [\"object\", \"embed\", \"iframe\"]);\n\n        for (var i = 0; i < embeds.length; i++) {\n          // If this embed has attribute that matches video regex, don't delete it.\n          for (var j = 0; j < embeds[i].attributes.length; j++) {\n            if (this._allowedVideoRegex.test(embeds[i].attributes[j].value)) {\n              return false;\n            }\n          }\n\n          // For embed with <object> tag, check inner HTML as well.\n          if (embeds[i].tagName === \"object\" && this._allowedVideoRegex.test(embeds[i].innerHTML)) {\n            return false;\n          }\n\n          embedCount++;\n        }\n\n        var linkDensity = this._getLinkDensity(node);\n        var contentLength = this._getInnerText(node).length;\n\n        var haveToRemove =\n          (img > 1 && p / img < 0.5 && !this._hasAncestorTag(node, \"figure\")) ||\n          (!isList && li > p) ||\n          (input > Math.floor(p/3)) ||\n          (!isList && headingDensity < 0.9 && contentLength < 25 && (img === 0 || img > 2) && !this._hasAncestorTag(node, \"figure\")) ||\n          (!isList && weight < 25 && linkDensity > 0.2) ||\n          (weight >= 25 && linkDensity > 0.5) ||\n          ((embedCount === 1 && contentLength < 75) || embedCount > 1);\n        // Allow simple lists of images to remain in pages\n        if (isList && haveToRemove) {\n          for (var x = 0; x < node.children.length; x++) {\n            let child = node.children[x];\n            // Don't filter in lists with li's that contain more than one child\n            if (child.children.length > 1) {\n              return haveToRemove;\n            }\n          }\n          let li_count = node.getElementsByTagName(\"li\").length;\n          // Only allow the list to remain if every li contains an image\n          if (img == li_count) {\n            return false;\n          }\n        }\n        return haveToRemove;\n      }\n      return false;\n    });\n  },\n\n  /**\n   * Clean out elements that match the specified conditions\n   *\n   * @param Element\n   * @param Function determines whether a node should be removed\n   * @return void\n   **/\n  _cleanMatchedNodes: function(e, filter) {\n    var endOfSearchMarkerNode = this._getNextNode(e, true);\n    var next = this._getNextNode(e);\n    while (next && next != endOfSearchMarkerNode) {\n      if (filter.call(this, next, next.className + \" \" + next.id)) {\n        next = this._removeAndGetNext(next);\n      } else {\n        next = this._getNextNode(next);\n      }\n    }\n  },\n\n  /**\n   * Clean out spurious headers from an Element.\n   *\n   * @param Element\n   * @return void\n  **/\n  _cleanHeaders: function(e) {\n    let headingNodes = this._getAllNodesWithTag(e, [\"h1\", \"h2\"]);\n    this._removeNodes(headingNodes, function(node) {\n      let shouldRemove = this._getClassWeight(node) < 0;\n      if (shouldRemove) {\n        this.log(\"Removing header with low class weight:\", node);\n      }\n      return shouldRemove;\n    });\n  },\n\n  /**\n   * Check if this node is an H1 or H2 element whose content is mostly\n   * the same as the article title.\n   *\n   * @param Element  the node to check.\n   * @return boolean indicating whether this is a title-like header.\n   */\n  _headerDuplicatesTitle: function(node) {\n    if (node.tagName != \"H1\" && node.tagName != \"H2\") {\n      return false;\n    }\n    var heading = this._getInnerText(node, false);\n    this.log(\"Evaluating similarity of header:\", heading, this._articleTitle);\n    return this._textSimilarity(this._articleTitle, heading) > 0.75;\n  },\n\n  _flagIsActive: function(flag) {\n    return (this._flags & flag) > 0;\n  },\n\n  _removeFlag: function(flag) {\n    this._flags = this._flags & ~flag;\n  },\n\n  _isProbablyVisible: function(node) {\n    // Have to null-check node.style and node.className.indexOf to deal with SVG and MathML nodes.\n    return (!node.style || node.style.display != \"none\")\n      && (!node.style || node.style.visibility != \"hidden\")\n      && !node.hasAttribute(\"hidden\")\n      //check for \"fallback-image\" so that wikimedia math images are displayed\n      && (!node.hasAttribute(\"aria-hidden\") || node.getAttribute(\"aria-hidden\") != \"true\" || (node.className && node.className.indexOf && node.className.indexOf(\"fallback-image\") !== -1));\n  },\n\n  /**\n   * Runs readability.\n   *\n   * Workflow:\n   *  1. Prep the document by removing script tags, css, etc.\n   *  2. Build readability's DOM tree.\n   *  3. Grab the article content from the current dom tree.\n   *  4. Replace the current DOM tree with the new one.\n   *  5. Read peacefully.\n   *\n   * @return void\n   **/\n  parse: function () {\n    // Avoid parsing too large documents, as per configuration option\n    if (this._maxElemsToParse > 0) {\n      var numTags = this._doc.getElementsByTagName(\"*\").length;\n      if (numTags > this._maxElemsToParse) {\n        throw new Error(\"Aborting parsing document; \" + numTags + \" elements found\");\n      }\n    }\n\n    // Unwrap image from noscript\n    this._unwrapNoscriptImages(this._doc);\n\n    // Extract JSON-LD metadata before removing scripts\n    var jsonLd = this._disableJSONLD ? {} : this._getJSONLD(this._doc);\n\n    // Remove script tags from the document.\n    this._removeScripts(this._doc);\n\n    this._prepDocument();\n\n    var metadata = this._getArticleMetadata(jsonLd);\n    this._articleTitle = metadata.title;\n\n    var articleContent = this._grabArticle();\n    if (!articleContent)\n      return null;\n\n    this.log(\"Grabbed: \" + articleContent.innerHTML);\n\n    this._postProcessContent(articleContent);\n\n    // If we haven't found an excerpt in the article's metadata, use the article's\n    // first paragraph as the excerpt. This is used for displaying a preview of\n    // the article's content.\n    if (!metadata.excerpt) {\n      var paragraphs = articleContent.getElementsByTagName(\"p\");\n      if (paragraphs.length > 0) {\n        metadata.excerpt = paragraphs[0].textContent.trim();\n      }\n    }\n\n    var textContent = articleContent.textContent;\n    return {\n      title: this._articleTitle,\n      byline: metadata.byline || this._articleByline,\n      dir: this._articleDir,\n      lang: this._articleLang,\n      content: this._serializer(articleContent),\n      textContent: textContent,\n      length: textContent.length,\n      excerpt: metadata.excerpt,\n      siteName: metadata.siteName || this._articleSiteName,\n      publishedTime: metadata.publishedTime\n    };\n  }\n};\n\nif (typeof module === \"object\") {\n  /* global module */\n  module.exports = Readability;\n}\n"
  },
  {
    "path": "bookmarks/styles/auth.css",
    "content": ".auth-page {\n  margin: 0 auto;\n  max-width: 350px;\n}\n"
  },
  {
    "path": "bookmarks/styles/bookmark-details.css",
    "content": ".bookmark-details {\n  .modal-container {\n    width: 100%;\n  }\n\n  .title {\n    word-break: break-word;\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 5;\n    overflow: hidden;\n  }\n\n  & .weblinks {\n    display: flex;\n    flex-direction: column;\n    gap: var(--unit-2);\n  }\n\n  & a.weblink {\n    display: flex;\n    align-items: center;\n    gap: var(--unit-2);\n  }\n\n  & a.weblink img,\n  & a.weblink svg {\n    flex: 0 0 auto;\n    width: 16px;\n    height: 16px;\n    color: var(--text-color);\n  }\n\n  & a.weblink span {\n    flex: 1 1 0;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  & .preview-image {\n    margin: var(--unit-4) 0;\n\n    img {\n      max-width: 100%;\n      max-height: 200px;\n    }\n  }\n\n  & .sections section {\n    margin-top: var(--unit-4);\n  }\n\n  & .sections h3 {\n    margin-bottom: var(--unit-2);\n    font-size: var(--font-size);\n    font-weight: bold;\n  }\n\n  & .assets {\n    margin-top: var(--unit-2);\n\n    & .filesize {\n      color: var(--tertiary-text-color);\n    }\n  }\n\n  & .assets-actions {\n    display: flex;\n    gap: var(--unit-4);\n    align-items: center;\n    margin-top: var(--unit-2);\n\n    & .btn.btn-link {\n      height: unset;\n      padding: 0;\n      border: none;\n    }\n  }\n\n  & .tags a {\n    color: var(--alternative-color);\n  }\n\n  & .status form {\n    display: flex;\n    gap: var(--unit-2);\n  }\n\n  & .status .form-group,\n  .status .form-switch {\n    margin: 0;\n  }\n\n  & .actions {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/bookmark-form.css",
    "content": ".bookmarks-form-page {\n  main {\n    max-width: 550px;\n    margin: 0 auto;\n  }\n}\n\n.bookmarks-form {\n  & .has-icon-right > input,\n  & .has-icon-right > textarea {\n    padding-right: 30px;\n  }\n\n  & .form-icon.loading {\n    visibility: hidden;\n  }\n\n  & .form-group .suffix-button {\n    padding: 0;\n    border: none;\n    height: auto;\n    font-size: var(--font-size-sm);\n  }\n\n  & .form-group ld-clear-button,\n  & .form-group #refresh-button {\n    display: none;\n  }\n\n  & .form-group input.modified,\n  & .form-group textarea.modified {\n    background: var(--primary-color-shade);\n  }\n\n  & .form-input-hint.bookmark-exists {\n    display: none;\n    color: var(--warning-color);\n  }\n\n  & .form-input-hint.auto-tags {\n    display: none;\n    color: var(--success-color);\n  }\n\n  & details.notes textarea {\n    box-sizing: border-box;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/bookmark-page.css",
    "content": ":root {\n  --bookmark-title-color: var(--primary-text-color);\n  --bookmark-title-weight: 500;\n  --bookmark-description-color: var(--text-color);\n  --bookmark-description-weight: 400;\n  --bookmark-actions-color: var(--secondary-text-color);\n  --bookmark-actions-hover-color: var(--text-color);\n  --bookmark-actions-weight: 400;\n  --bulk-actions-bg-color: var(--gray-50);\n}\n\n/* Bookmark page grid */\n.bookmarks-page {\n  &.grid {\n    grid-gap: var(--unit-9);\n  }\n\n  ld-filter-drawer-trigger {\n    display: none;\n  }\n\n  @media (max-width: 840px) {\n    section.side-panel {\n      display: none;\n    }\n\n    ld-filter-drawer-trigger {\n      display: inline-block;\n    }\n  }\n\n  &.collapse-side-panel {\n    main {\n      grid-column: span var(--grid-columns);\n    }\n\n    .side-panel {\n      display: none;\n    }\n\n    ld-filter-drawer-trigger {\n      display: inline-block;\n    }\n  }\n}\n\n/* Bookmark area header controls */\n.bookmarks-page .search-container {\n  flex: 1 1 0;\n  display: flex;\n  max-width: 300px;\n  margin-left: auto;\n\n  & form {\n    width: 100%;\n  }\n\n  @media (max-width: 600px) {\n    max-width: initial;\n    margin-left: 0;\n  }\n\n  /* Group search options button with search button */\n  height: var(--control-size);\n  border-radius: var(--border-radius);\n  box-shadow: var(--box-shadow-xs);\n\n  & input,\n  & .form-autocomplete-input {\n    border-top-right-radius: 0;\n    border-bottom-right-radius: 0;\n    box-shadow: none;\n  }\n\n  & .dropdown-toggle {\n    border-left: none;\n    border-top-left-radius: 0;\n    border-bottom-left-radius: 0;\n    box-shadow: none;\n    outline-offset: calc(var(--focus-outline-offset) * -1);\n  }\n\n  /* Search option menu styles */\n\n  & .dropdown {\n    & .menu {\n      padding: var(--unit-4);\n      min-width: 250px;\n      font-size: var(--font-size-sm);\n    }\n\n    & .menu .actions {\n      margin-top: var(--unit-4);\n      display: flex;\n      justify-content: space-between;\n    }\n\n    & .form-group:first-of-type {\n      margin-top: 0;\n    }\n\n    & .form-group {\n      margin-bottom: var(--unit-3);\n    }\n\n    & .radio-group {\n      & .form-label {\n        margin-bottom: var(--unit-1);\n      }\n\n      & .form-radio.form-inline {\n        margin: 0 var(--unit-2) 0 0;\n        padding: 0;\n        display: inline-flex;\n        align-items: center;\n        column-gap: var(--unit-1);\n      }\n\n      & .form-icon {\n        top: 0;\n        position: relative;\n      }\n    }\n  }\n}\n\n/* Bookmark list */\n@keyframes appear {\n  0% {\n    opacity: 0;\n  }\n  90% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n\nul.bookmark-list {\n  list-style: none;\n  margin: var(--unit-4) 0 0 0;\n  padding: 0;\n\n  /* Increase line-height for better separation within / between items */\n  line-height: 1.1rem;\n\n  > li {\n    position: relative;\n    display: flex;\n    gap: var(--unit-2);\n    margin-top: 0;\n    margin-bottom: var(--unit-3);\n\n    & .content {\n      flex: 1 1 0;\n      min-width: 0;\n    }\n\n    & .preview-image {\n      flex: 0 0 auto;\n      width: 100px;\n      height: 60px;\n      margin-top: var(--unit-h);\n      border-radius: var(--border-radius);\n      border: solid 1px var(--border-color);\n      object-fit: cover;\n\n      &.placeholder {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        background: var(--body-color-contrast);\n\n        & .img {\n          width: var(--unit-12);\n          height: var(--unit-12);\n          background-color: var(--tertiary-text-color);\n          -webkit-mask: url(preview-placeholder.svg) no-repeat center;\n          mask: url(preview-placeholder.svg) no-repeat center;\n        }\n      }\n    }\n\n    & .title {\n      position: relative;\n    }\n\n    & .title img {\n      position: absolute;\n      width: 16px;\n      height: 16px;\n      left: 0;\n      top: 50%;\n      transform: translateY(-50%);\n      pointer-events: none;\n    }\n\n    & .title img + a {\n      padding-left: 22px;\n    }\n\n    & .title a {\n      color: var(--bookmark-title-color);\n      font-weight: var(--bookmark-title-weight);\n      display: block;\n      width: 100%;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n\n    & .title a[data-tooltip]:hover::after,\n    & .title a[data-tooltip]:focus::after {\n      content: attr(data-tooltip);\n      position: absolute;\n      z-index: 10;\n      top: 100%;\n      left: 50%;\n      transform: translateX(-50%);\n      width: max-content;\n      max-width: 90%;\n      height: fit-content;\n      background-color: #292f62;\n      color: #fff;\n      padding: var(--unit-1);\n      border-radius: var(--border-radius);\n      border: 1px solid #424a8c;\n      font-size: var(--font-size-sm);\n      font-style: normal;\n      white-space: normal;\n      pointer-events: none;\n      animation: 0.3s ease 0s appear;\n    }\n\n    @media (pointer: coarse) {\n      & .title a[data-tooltip]::after {\n        display: none;\n      }\n    }\n\n    &.unread .title a {\n      font-style: italic;\n    }\n\n    & .url-path,\n    & .url-display {\n      font-size: var(--font-size-sm);\n      color: var(--secondary-link-color);\n    }\n\n    & .description {\n      color: var(--bookmark-description-color);\n      font-weight: var(--bookmark-description-weight);\n    }\n\n    & .description.separate {\n      display: -webkit-box;\n      -webkit-box-orient: vertical;\n      -webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);\n      overflow: hidden;\n    }\n\n    & .tags {\n      & a,\n      & a:visited:hover {\n        color: var(--alternative-color);\n      }\n      & a:not(:last-child) {\n        margin-right: var(--unit-1);\n      }\n    }\n\n    & .actions,\n    & .extra-actions {\n      display: flex;\n      align-items: baseline;\n      flex-wrap: wrap;\n      column-gap: var(--unit-2);\n    }\n\n    @media (max-width: 600px) {\n      & .extra-actions {\n        width: 100%;\n        margin-top: var(--unit-1);\n      }\n    }\n\n    & .actions {\n      color: var(--bookmark-actions-color);\n      font-size: var(--font-size-sm);\n\n      & a,\n      & button.btn-link {\n        color: var(--bookmark-actions-color);\n        --btn-icon-color: var(--bookmark-actions-color);\n        font-weight: var(--bookmark-actions-weight);\n        padding: 0;\n        height: auto;\n        vertical-align: unset;\n        border: none;\n        box-sizing: border-box;\n        transition: none;\n        text-decoration: none;\n\n        &:focus,\n        &:hover,\n        &:active,\n        &.active {\n          color: var(--bookmark-actions-hover-color);\n          --btn-icon-color: var(--bookmark-actions-hover-color);\n        }\n      }\n    }\n  }\n}\n\n/* Bookmark pagination */\n.bookmark-pagination {\n  margin-top: var(--unit-4);\n\n  &.sticky {\n    position: sticky;\n    bottom: 0;\n    border-top: solid 1px var(--secondary-border-color);\n    background: var(--body-color);\n    padding-bottom: var(--unit-h);\n\n    &:before {\n      content: \"\";\n      position: absolute;\n      top: 0;\n      bottom: 0;\n      left: calc(\n        -1 *\n          calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))\n      );\n      width: calc(\n        var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)\n      );\n      background: var(--body-color);\n    }\n  }\n\n  & .pagination {\n    overflow: hidden;\n  }\n}\n\n.bundle-menu {\n  list-style-type: none;\n  margin: 0 0 var(--unit-6);\n\n  .bundle-menu-item {\n    margin: 0;\n    margin-bottom: var(--unit-2);\n  }\n\n  .bundle-menu-item a {\n    padding: var(--unit-1) var(--unit-2);\n    border-radius: var(--border-radius);\n  }\n\n  .bundle-menu-item.selected a {\n    background: var(--primary-color);\n    color: var(--contrast-text-color);\n  }\n}\n\n.tag-cloud {\n  /* Increase line-height for better separation within / between items */\n  line-height: 1.1rem;\n\n  & .selected-tags {\n    margin-bottom: var(--unit-4);\n\n    & a,\n    & a:visited:hover {\n      color: var(--error-color);\n    }\n  }\n\n  & .unselected-tags {\n    & a,\n    & a:visited:hover {\n      color: var(--alternative-color);\n    }\n  }\n\n  & .group {\n    margin-bottom: var(--unit-3);\n  }\n\n  & .highlight-char {\n    font-weight: bold;\n    text-transform: uppercase;\n    color: var(--alternative-color-dark);\n  }\n}\n\n/* Bookmark notes */\nul.bookmark-list {\n  & .notes {\n    display: none;\n    max-height: 300px;\n    margin: var(--unit-1) 0;\n    overflow-y: auto;\n    background: var(--body-color-contrast);\n    border-radius: var(--border-radius);\n  }\n\n  & .notes .markdown {\n    padding: var(--unit-2) var(--unit-3);\n  }\n\n  &.show-notes .notes,\n  & li.show-notes .notes {\n    display: block;\n  }\n}\n\n/* Bookmark bulk edit */\n:root {\n  --bulk-edit-toggle-width: 16px;\n  --bulk-edit-toggle-offset: 8px;\n  --bulk-edit-bar-offset: calc(\n    var(--bulk-edit-toggle-width) + (2 * var(--bulk-edit-toggle-offset))\n  );\n}\n\nld-bookmark-page {\n  & .bulk-edit-bar {\n    display: none;\n    align-items: center;\n    padding: var(--unit-1) 0;\n    gap: var(--unit-2);\n    background: var(--bulk-actions-bg-color);\n    border-bottom: solid 1px var(--secondary-border-color);\n\n    /* Actions */\n    & button {\n      --control-padding-x-sm: 0;\n    }\n\n    & button:hover {\n      text-decoration: underline;\n    }\n\n    & > input,\n    & .form-autocomplete,\n    & select {\n      width: auto;\n      max-width: 140px;\n      -webkit-appearance: none;\n    }\n\n    & .select-across {\n      margin: 0 0 0 auto;\n      font-size: var(--font-size-sm);\n    }\n  }\n\n  &.active .bulk-edit-bar {\n    display: flex;\n  }\n\n  /* All checkbox */\n\n  & .form-checkbox.bulk-edit-checkbox.all {\n    display: block;\n    width: var(--bulk-edit-toggle-width);\n    margin: 0 0 0 var(--bulk-edit-toggle-offset);\n    padding: 0;\n  }\n\n  /* Bookmark checkboxes */\n\n  & .form-checkbox.bulk-edit-checkbox:not(.all) {\n    display: none;\n    position: absolute;\n    width: var(--bulk-edit-toggle-width);\n    min-height: var(--bulk-edit-toggle-width);\n    left: calc(\n      -1 * var(--bulk-edit-toggle-width) - var(--bulk-edit-toggle-offset)\n    );\n    top: 50%;\n    transform: translateY(-50%);\n    padding: 0;\n    margin: 0;\n\n    .form-icon {\n      top: 0;\n    }\n  }\n\n  &.active .form-checkbox.bulk-edit-checkbox:not(.all) {\n    display: block;\n  }\n\n  &.active ul.bookmark-list > li {\n    padding-left: calc(\n      var(--bulk-edit-toggle-width) + calc(var(--bulk-edit-toggle-offset) * 2)\n    );\n  }\n\n  & ld-tag-autocomplete {\n    display: none;\n  }\n\n  &[data-bulk-action=\"bulk_tag\"],\n  &[data-bulk-action=\"bulk_untag\"] {\n    & ld-tag-autocomplete {\n      display: inline-block;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/bundles.css",
    "content": ".bundles-page {\n  .crud-table {\n    svg {\n      cursor: grab;\n    }\n\n    tr.drag-start {\n      --secondary-border-color: transparent;\n    }\n\n    tr.dragging > * {\n      opacity: 0;\n    }\n  }\n}\n\n.bundles-editor-page {\n  &.grid {\n    gap: var(--unit-9);\n  }\n\n  .form-footer {\n    position: sticky;\n    bottom: 0;\n    border-top: solid 1px var(--secondary-border-color);\n    background: var(--body-color);\n    padding: var(--unit-3) 0;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/components.css",
    "content": "/* Shared components */\n\n/* Section header component */\n.section-header {\n  border-bottom: solid 1px var(--secondary-border-color);\n  display: flex;\n  flex-wrap: wrap;\n  column-gap: var(--unit-5);\n  padding-bottom: var(--unit-2);\n  margin-bottom: var(--unit-4);\n\n  h1,\n  h2,\n  h3 {\n    font-size: var(--font-size-lg);\n    flex: 0 0 auto;\n    line-height: var(--unit-9);\n    margin: 0;\n  }\n\n  .header-controls {\n    flex: 1 1 0;\n    display: flex;\n  }\n}\n\n@media (max-width: 600px) {\n  .section-header:not(.no-wrap) {\n    flex-direction: column;\n  }\n}\n\n/* Confirm button component */\n.confirm-dropdown.active {\n  position: fixed;\n  z-index: 500;\n\n  & .menu {\n    position: fixed;\n    display: flex;\n    flex-direction: column;\n    box-sizing: border-box;\n    gap: var(--unit-2);\n    padding: var(--unit-2);\n    transform: none;\n  }\n}\n\n/* Divider */\n.divider {\n  border-bottom: solid 1px var(--secondary-border-color);\n  margin: var(--unit-5) 0;\n}\n\n/* Turbo progress bar */\n.turbo-progress-bar {\n  background-color: var(--primary-color);\n}\n\n/* Messages */\n.message-list {\n  margin: var(--unit-4) 0;\n\n  .toast {\n    margin-bottom: var(--unit-2);\n  }\n\n  .toast a.btn-clear:visited {\n    color: currentColor;\n  }\n}\n\n/* Item list */\n.item-list {\n  & .list-item {\n    display: flex;\n    align-items: center;\n    gap: var(--unit-2);\n    padding: var(--unit-2) 0;\n    border-top: var(--unit-o) solid var(--secondary-border-color);\n  }\n\n  & .list-item:last-child {\n    border-bottom: var(--unit-o) solid var(--secondary-border-color);\n  }\n\n  & .list-item-icon {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n\n  & .list-item-text {\n    flex: 1 1 0;\n    gap: var(--unit-2);\n    min-width: 0;\n    display: flex;\n  }\n\n  & .list-item-text .truncate {\n    flex-shrink: 1;\n  }\n\n  & .list-item-actions {\n    display: flex;\n    gap: var(--unit-2);\n    margin-left: var(--unit-4);\n    align-items: center;\n\n    & .btn.btn-link {\n      height: unset;\n      padding: 0;\n      border: none;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/crud.css",
    "content": ".crud-page {\n  .crud-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: var(--unit-6);\n\n    h1 {\n      font-size: var(--font-size-xl);\n      margin: 0;\n    }\n  }\n\n  .crud-filters {\n    background: var(--body-color-contrast);\n    border-radius: var(--border-radius);\n    border: solid 1px var(--secondary-border-color);\n    padding: var(--unit-3);\n    margin-bottom: var(--unit-4);\n\n    form {\n      display: flex;\n      flex-wrap: wrap;\n      gap: var(--unit-4);\n\n      & .form-group {\n        margin: 0;\n      }\n\n      &.form-input,\n      &.form-select {\n        width: auto;\n      }\n\n      & .form-group:has(.form-checkbox) {\n        align-self: flex-end;\n      }\n    }\n  }\n}\n\n.crud-table {\n  .btn.btn-link {\n    padding: 0;\n    height: unset;\n  }\n\n  th,\n  td {\n    max-width: 0;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  th.actions,\n  td.actions {\n    width: 1%;\n    max-width: 150px;\n\n    *:not(:last-child) {\n      margin-right: var(--unit-2);\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/layout.css",
    "content": "/* Main layout */\nbody {\n  margin: 20px 10px;\n\n  @media (min-width: 600px) {\n    /* Horizontal offset accounts for checkboxes that show up in bulk edit mode */\n    margin: 20px 32px;\n  }\n}\n\nheader {\n  margin-bottom: var(--unit-9);\n\n  a.app-link:hover {\n    text-decoration: none;\n  }\n\n  .app-logo {\n    width: 28px;\n    height: 28px;\n  }\n\n  .app-name {\n    margin-left: var(--unit-3);\n    font-size: var(--font-size-lg);\n    font-weight: 500;\n    line-height: 1.2;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/markdown.css",
    "content": ".markdown {\n  & p,\n  & ul,\n  & ol,\n  & pre,\n  & blockquote {\n    margin: 0 0 var(--unit-2) 0;\n  }\n\n  & > *:first-child {\n    margin-top: 0;\n  }\n\n  & > *:last-child {\n    margin-bottom: 0;\n  }\n\n  & ul,\n  & ol {\n    margin-left: var(--unit-4);\n  }\n\n  & ul li,\n  & ol li {\n    margin-top: var(--unit-1);\n  }\n\n  & pre {\n    padding: var(--unit-1) var(--unit-2);\n    background-color: var(--code-bg-color);\n    border-radius: var(--unit-1);\n    overflow-x: auto;\n  }\n\n  & pre code {\n    background: none;\n    box-shadow: none;\n    padding: 0;\n  }\n\n  & > pre:first-child:last-child {\n    padding: 0;\n    background: none;\n    border-radius: 0;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/reader-mode.css",
    "content": "html.reader-mode {\n  --font-size: 1rem;\n  line-height: 1.6;\n\n  body {\n    margin: 3rem 2rem;\n  }\n\n  .container {\n    max-width: 600px;\n  }\n\n  .byline {\n    font-style: italic;\n    font-size: 0.8rem;\n  }\n\n  .reading-time {\n    font-size: 0.7rem;\n  }\n\n  img {\n    max-width: 100%;\n    height: auto;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/responsive.css",
    "content": ".show-sm,\n.show-md {\n  display: none !important;\n}\n\n.width-25 {\n  width: 25%;\n}\n\n.width-50 {\n  width: 50%;\n}\n\n.width-75 {\n  width: 75%;\n}\n\n.width-100 {\n  width: 100%;\n}\n\n.container {\n  margin-left: auto;\n  margin-right: auto;\n  width: 100%;\n  max-width: var(--size-lg);\n}\n\n.grid {\n  --grid-columns: 3;\n  display: grid;\n  grid-template-columns: repeat(var(--grid-columns), 1fr);\n  grid-gap: var(--unit-4);\n}\n\n.grid > * {\n  min-width: 0;\n}\n\n.columns-2 {\n  --grid-columns: 2;\n}\n\n.gap-0 {\n  gap: 0;\n}\n\n.col-1 {\n  grid-column: span min(1, var(--grid-columns));\n}\n\n.col-2 {\n  grid-column: span min(2, var(--grid-columns));\n}\n\n.col-3 {\n  grid-column: span min(3, var(--grid-columns));\n}\n\n@media (max-width: 840px) {\n  .hide-md {\n    display: none !important;\n  }\n  .show-md {\n    display: block !important;\n  }\n\n  .width-md-25 {\n    width: 25%;\n  }\n  .width-md-50 {\n    width: 50%;\n  }\n  .width-md-75 {\n    width: 75%;\n  }\n  .width-md-100 {\n    width: 100%;\n  }\n\n  .columns-md-1 {\n    --grid-columns: 1;\n  }\n  .columns-md-2 {\n    --grid-columns: 2;\n  }\n}\n\n@media (max-width: 600px) {\n  .hide-sm {\n    display: none !important;\n  }\n  .show-sm {\n    display: block !important;\n  }\n\n  .width-sm-25 {\n    width: 25%;\n  }\n  .width-sm-50 {\n    width: 50%;\n  }\n  .width-sm-75 {\n    width: 75%;\n  }\n  .width-sm-100 {\n    width: 100%;\n  }\n\n  .columns-sm-1 {\n    --grid-columns: 1;\n  }\n  .columns-sm-2 {\n    --grid-columns: 2;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/settings.css",
    "content": ".settings-page {\n  h1 {\n    font-size: var(--font-size-xl);\n    margin-bottom: var(--unit-6);\n  }\n\n  section {\n    margin-bottom: var(--unit-10);\n\n    h2 {\n      font-size: var(--font-size-lg);\n      margin-bottom: var(--unit-4);\n    }\n  }\n\n  textarea.monospace {\n    font-family: monospace;\n    box-sizing: border-box;\n  }\n\n  .input-group > input[type=\"submit\"] {\n    height: auto;\n  }\n\n  section.about table {\n    max-width: 400px;\n  }\n\n  & .form-group {\n    margin-bottom: var(--unit-4);\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/tags.css",
    "content": ".tags-editor-page {\n  main {\n    max-width: 550px;\n    margin: 0 auto;\n  }\n}\n\n.tag-edit-modal {\n  .modal-container {\n    max-width: 400px;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2016 - 2020 Yan Zhu\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": "bookmarks/styles/theme/_normalize.css",
    "content": "/* Manually forked from Normalize.css */\n/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */\n\n/**\n * 1. Change the default font family in all browsers (opinionated).\n * 2. Correct the line height in all browsers.\n * 3. Prevent adjustments of font size after orientation changes in\n *    IE on Windows Phone and in iOS.\n */\n\n/* Document\n   ========================================================================== */\n\nhtml {\n  font-family: sans-serif; /* 1 */\n  -ms-text-size-adjust: 100%; /* 3 */\n  -webkit-text-size-adjust: 100%; /* 3 */\n}\n\n/* Sections\n   ========================================================================== */\n\n/**\n * Remove the margin in all browsers (opinionated).\n */\n\nbody {\n  margin: 0;\n}\n\n/**\n * Add the correct display in IE 9-.\n */\n\narticle,\naside,\nfooter,\nheader,\nnav,\nsection {\n  display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n  font-size: 2em;\n  margin: 0.67em 0;\n}\n\n/* Grouping content\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 9-.\n * 1. Add the correct display in IE.\n */\n\nfigcaption,\nfigure,\nmain {\n  /* 1 */\n  display: block;\n}\n\n/**\n * Add the correct margin in IE 8 (removed).\n */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n  box-sizing: content-box; /* 1 */\n  height: 0; /* 1 */\n  overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers. (removed)\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\n/* Text-level semantics\n   ========================================================================== */\n\n/**\n * 1. Remove the gray background on active links in IE 10.\n * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.\n */\n\na {\n  background-color: transparent; /* 1 */\n  -webkit-text-decoration-skip: objects; /* 2 */\n}\n\n/**\n * Remove the outline on focused links when they are also active or hovered\n * in all browsers (opinionated).\n */\n\na:active,\na:hover {\n  outline-width: 0;\n}\n\n/**\n * Modify default styling of address.\n */\n\naddress {\n  font-style: normal;\n}\n\n/**\n * 1. Remove the bottom border in Firefox 39-.\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed)\n */\n\n/**\n * Prevent the duplicate application of `bolder` by the next rule in Safari 6.\n */\n\nb,\nstrong {\n  font-weight: inherit;\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n  font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\npre,\nsamp {\n  font-family: var(--mono-font-family); /* 1 (changed) */\n  font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font style in Android 4.3-.\n */\n\ndfn {\n  font-style: italic;\n}\n\n/**\n * Add the correct background and color in IE 9-. (Removed)\n */\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n  font-size: 80%;\n  font-weight: 400; /* (added) */\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\nsub {\n  bottom: -0.25em;\n}\n\nsup {\n  top: -0.5em;\n}\n\n/* Embedded content\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 9-.\n */\n\naudio,\nvideo {\n  display: inline-block;\n}\n\n/**\n * Add the correct display in iOS 4-7.\n */\n\naudio:not([controls]) {\n  display: none;\n  height: 0;\n}\n\n/**\n * Remove the border on images inside links in IE 10-.\n */\n\nimg {\n  border-style: none;\n}\n\n/**\n * Hide the overflow in IE.\n */\n\nsvg:not(:root) {\n  overflow: hidden;\n}\n\n/* Forms\n   ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers (opinionated).\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  font-family: inherit; /* 1 (changed) */\n  font-size: inherit; /* 1 (changed) */\n  line-height: inherit; /* 1 (changed) */\n  margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput {\n  /* 1 */\n  overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect {\n  /* 1 */\n  text-transform: none;\n}\n\n/**\n * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n *    controls in Android 4.\n * 2. Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\nhtml [type=\"button\"], /* 1 */\n[type=\"reset\"],\n[type=\"submit\"] {\n  -webkit-appearance: button; /* 2 */\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n  border-style: none;\n  padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule (removed).\n */\n\n/**\n * Change the border, margin, and padding in all browsers (opinionated) (changed).\n */\n\nfieldset {\n  border: 0;\n  margin: 0;\n  padding: 0;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n *    `fieldset` elements in all browsers.\n */\n\nlegend {\n  box-sizing: border-box; /* 1 */\n  color: inherit; /* 2 */\n  display: table; /* 1 */\n  max-width: 100%; /* 1 */\n  padding: 0; /* 3 */\n  white-space: normal; /* 1 */\n}\n\n/**\n * 1. Add the correct display in IE 9-.\n * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n  display: inline-block; /* 1 */\n  vertical-align: baseline; /* 2 */\n}\n\n/**\n * Remove the default vertical scrollbar in IE.\n */\n\ntextarea {\n  overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10-.\n * 2. Remove the padding in IE 10-.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n  box-sizing: border-box; /* 1 */\n  padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n  -webkit-appearance: textfield; /* 1 */\n  outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-cancel-button,\n[type=\"search\"]::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n  -webkit-appearance: button; /* 1 */\n  font: inherit; /* 2 */\n}\n\n/* Interactive\n   ========================================================================== */\n\n/*\n * Add the correct display in IE 9-.\n * 1. Add the correct display in Edge, IE, and Firefox.\n */\n\ndetails, /* 1 */\nmenu {\n  display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n  display: list-item;\n  outline: none;\n}\n\n/* Scripting\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 9-.\n */\n\ncanvas {\n  display: inline-block;\n}\n\n/**\n * Add the correct display in IE.\n */\n\ntemplate {\n  display: none;\n}\n\n/* Hidden\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 10-.\n */\n\n[hidden] {\n  display: none;\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/animations.css",
    "content": "/* Animations */\n@keyframes loading {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes slide-down {\n  0% {\n    opacity: 0;\n    transform: translateY(calc(-1 * var(--unit-8)));\n  }\n  100% {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes fade-in {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n\n@keyframes fade-out {\n  0% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/asian.css",
    "content": "/* Optimized for East Asian CJK */\nhtml:lang(zh),\nhtml:lang(zh-Hans),\n.lang-zh,\n.lang-zh-hans {\n  font-family: var(--cjk-zh-hans-font-family);\n}\n\nhtml:lang(zh-Hant),\n.lang-zh-hant {\n  font-family: var(--cjk-zh-hant-font-family);\n}\n\nhtml:lang(ja),\n.lang-ja {\n  font-family: var(--cjk-jp-font-family);\n}\n\nhtml:lang(ko),\n.lang-ko {\n  font-family: var(--cjk-ko-font-family);\n}\n\n:lang(zh),\n:lang(ja),\n.lang-cjk {\n  & ins,\n  & u {\n    border-bottom: var(--border-width) solid;\n    text-decoration: none;\n  }\n\n  & del + del,\n  & del + s,\n  & ins + ins,\n  & ins + u,\n  & s + del,\n  & s + s,\n  & u + ins,\n  & u + u {\n    margin-left: 0.125em;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/autocomplete.css",
    "content": "/* Autocomplete */\n.form-autocomplete {\n  & .form-autocomplete-input {\n    box-sizing: border-box;\n    align-content: flex-start;\n    display: flex;\n    flex-wrap: wrap;\n    background: var(--input-bg-color);\n    height: var(--control-size);\n    min-height: var(--control-size);\n    padding: 0;\n\n    &.is-focused {\n      outline: var(--focus-outline);\n      outline-offset: calc(var(--focus-outline-offset) * -1);\n    }\n\n    & .form-input {\n      background: transparent;\n      border-color: transparent;\n      box-shadow: none;\n      display: inline-block;\n      flex: 1 0 auto;\n      line-height: var(--unit-4);\n      width: 100%;\n      height: 100% !important;\n      margin: 0;\n      border: none;\n\n      &:focus {\n        outline: none;\n      }\n    }\n\n    &:has(.is-error) {\n      background: var(--error-color-shade);\n      border-color: var(--error-color);\n\n      &.is-focused {\n        outline-color: var(--error-color);\n      }\n    }\n  }\n\n  &.small {\n    .form-autocomplete-input {\n      height: var(--control-size-sm);\n      min-height: var(--control-size-sm);\n    }\n\n    .form-autocomplete-input input {\n      padding: 0.05rem 0.3rem;\n      font-size: var(--font-size-sm);\n    }\n\n    .menu .menu-item {\n      font-size: var(--font-size-sm);\n    }\n  }\n\n  & .menu {\n    display: none;\n    position: fixed;\n    max-height: var(--menu-max-height, 200px);\n    overflow: auto;\n\n    & .menu-item {\n      white-space: normal;\n    }\n\n    & .menu-item.selected > a,\n    & .menu-item > a:hover {\n      background: var(--menu-item-hover-bg-color);\n      color: var(--menu-item-hover-color);\n    }\n\n    & .group-item,\n    & .group-item:hover {\n      margin-top: var(--unit-2);\n      text-transform: uppercase;\n      background: none;\n      font-size: 0.6rem;\n      font-weight: bold;\n    }\n  }\n\n  & .menu.open {\n    display: block;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/badges.css",
    "content": "/* Badges */\n.badge {\n  position: relative;\n  white-space: nowrap;\n\n  &[data-badge],\n  &:not([data-badge]) {\n    &::after {\n      background: var(--primary-color);\n      background-clip: padding-box;\n      border-radius: 0.5rem;\n      box-shadow: 0 0 0 1px var(--body-color);\n      color: var(--contrast-text-color);\n      content: attr(data-badge);\n      display: inline-block;\n      transform: translate(-0.05rem, -0.5rem);\n    }\n  }\n\n  &[data-badge] {\n    &::after {\n      font-size: var(--font-size-sm);\n      height: 0.9rem;\n      line-height: 1;\n      min-width: 0.9rem;\n      padding: 0.1rem 0.2rem;\n      text-align: center;\n      white-space: nowrap;\n    }\n  }\n\n  &:not([data-badge]),\n  &[data-badge=\"\"] {\n    &::after {\n      height: 6px;\n      min-width: 6px;\n      padding: 0;\n      width: 6px;\n    }\n  }\n\n  /* Badges for Buttons */\n\n  &.btn {\n    &::after {\n      position: absolute;\n      top: 0;\n      right: 0;\n      transform: translate(50%, -50%);\n    }\n  }\n\n  /* Badges for Avatars */\n\n  &.avatar {\n    &::after {\n      position: absolute;\n      top: 14.64%;\n      right: 14.64%;\n      transform: translate(50%, -50%);\n      z-index: var(--zindex-1);\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/base.css",
    "content": "/* Base */\n*,\n*::before,\n*::after {\n  box-sizing: inherit;\n}\n\nhtml {\n  box-sizing: border-box;\n  font-size: var(--html-font-size);\n  line-height: var(--html-line-height);\n  -webkit-tap-highlight-color: transparent;\n}\n\n/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */\nhtml {\n  scrollbar-gutter: stable;\n}\n\n@media (pointer: coarse) {\n  html {\n    scrollbar-gutter: initial;\n  }\n}\n\nbody {\n  background: var(--body-color);\n  color: var(--text-color);\n  font-family: var(--body-font-family);\n  font-size: var(--font-size);\n  overflow-x: hidden;\n  text-rendering: optimizeLegibility;\n}\n\na {\n  color: var(--link-color);\n  outline: none;\n  text-decoration: none;\n}\n\na:focus-visible {\n  outline: var(--focus-outline);\n  outline-offset: var(--focus-outline-offset);\n}\n\na:focus,\na:hover,\na:active,\na.active {\n  text-decoration: underline;\n}\n\nsummary {\n  cursor: pointer;\n}\n\nsummary:focus-visible {\n  outline: var(--focus-outline);\n  outline-offset: var(--focus-outline-offset);\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/buttons.css",
    "content": "/* Buttons */\n:root {\n  --btn-bg-color: var(--body-color);\n  --btn-hover-bg-color: var(--gray-50);\n  --btn-border-color: var(--border-color);\n  --btn-text-color: var(--text-color);\n  --btn-icon-color: var(--icon-color);\n  --btn-font-weight: 400;\n  --btn-box-shadow: var(--box-shadow-xs);\n\n  --btn-primary-bg-color: var(--primary-color);\n  --btn-primary-hover-bg-color: var(--primary-color-highlight);\n  --btn-primary-text-color: var(--contrast-text-color);\n\n  --btn-success-bg-color: var(--success-color);\n  --btn-success-hover-bg-color: var(--success-color-highlight);\n  --btn-success-text-color: var(--contrast-text-color);\n\n  --btn-error-bg-color: var(--error-color);\n  --btn-error-hover-bg-color: var(--error-color-highlight);\n  --btn-error-text-color: var(--contrast-text-color);\n\n  --btn-link-text-color: var(--link-color);\n  --btn-link-hover-text-color: var(--link-color);\n}\n\n.btn {\n  appearance: none;\n  background: var(--btn-bg-color);\n  border: var(--border-width) solid var(--btn-border-color);\n  border-radius: var(--border-radius);\n  color: var(--btn-text-color);\n  font-weight: var(--btn-font-weight);\n  cursor: pointer;\n  display: inline-flex;\n  align-items: baseline;\n  justify-content: center;\n  font-size: var(--font-size);\n  height: var(--control-size);\n  line-height: var(--line-height);\n  outline: none;\n  padding: var(--control-padding-y) var(--control-padding-x);\n  box-shadow: var(--btn-box-shadow);\n  text-align: center;\n  text-decoration: none;\n  transition:\n    background 0.2s,\n    border 0.2s,\n    box-shadow 0.2s,\n    color 0.2s;\n  user-select: none;\n  vertical-align: middle;\n  white-space: nowrap;\n\n  &:focus-visible {\n    outline: var(--focus-outline);\n    outline-offset: var(--focus-outline-offset);\n  }\n\n  &:hover {\n    background: var(--btn-hover-bg-color);\n    text-decoration: none;\n  }\n\n  &[disabled],\n  &:disabled,\n  &.disabled {\n    cursor: default;\n    opacity: 0.5;\n    pointer-events: none;\n  }\n\n  &:focus,\n  &:hover,\n  &:active,\n  &.active {\n    text-decoration: none;\n  }\n\n  /* Button Primary */\n\n  &.btn-primary {\n    background: var(--btn-primary-bg-color);\n    border-color: transparent;\n    color: var(--btn-primary-text-color);\n    --btn-icon-color: var(--btn-primary-text-color);\n\n    &:hover {\n      background: var(--btn-primary-hover-bg-color);\n    }\n\n    &.loading {\n      &::after {\n        border-bottom-color: var(--btn-primary-text-color);\n        border-left-color: var(--btn-primary-text-color);\n      }\n    }\n  }\n\n  /* Button Colors */\n\n  &.btn-success {\n    background: var(--btn-success-bg-color);\n    border-color: transparent;\n    color: var(--btn-success-text-color);\n    --btn-icon-color: var(--btn-success-text-color);\n\n    &:hover {\n      background: var(--btn-success-hover-bg-color);\n    }\n  }\n\n  &.btn-error {\n    --btn-border-color: var(--error-color);\n    --btn-text-color: var(--error-color);\n    --btn-icon-color: var(--error-color);\n\n    &:hover {\n      --btn-hover-bg-color: var(--error-color-shade);\n    }\n  }\n\n  /* Button no border */\n  &.btn-noborder {\n    border-color: transparent;\n    box-shadow: none;\n  }\n\n  /* Button Link */\n\n  &.btn-link {\n    background: transparent;\n    border-color: transparent;\n    box-shadow: none;\n    color: var(--btn-link-text-color);\n    --btn-icon-color: var(--btn-link-text-color);\n\n    &:hover {\n      color: var(--btn-link-hover-text-color);\n      --btn-icon-color: var(--btn-link-hover-text-color);\n    }\n\n    &:focus,\n    &:hover,\n    &:active,\n    &.active {\n      text-decoration: none;\n    }\n  }\n\n  /* Button Sizes */\n\n  &.btn-sm {\n    font-size: var(--font-size-sm);\n    height: var(--control-size-sm);\n    padding: var(--control-padding-y-sm) var(--control-padding-x-sm);\n  }\n\n  &.btn-lg {\n    font-size: var(--font-size-lg);\n    height: var(--control-size-lg);\n    padding: var(--control-padding-y-lg) var(--control-padding-x-lg);\n  }\n\n  /* Button Block */\n\n  &.btn-block {\n    display: block;\n    width: 100%;\n  }\n\n  /* Button Action */\n\n  &.btn-action {\n    width: var(--control-size);\n    padding-left: 0;\n    padding-right: 0;\n\n    &.btn-sm {\n      width: var(--control-size-sm);\n    }\n\n    &.btn-lg {\n      width: var(--control-size-lg);\n    }\n  }\n\n  /* Button Clear */\n\n  &.btn-clear {\n    background: transparent;\n    border: 0;\n    color: currentColor;\n    box-shadow: none;\n    height: var(--unit-5);\n    line-height: var(--unit-4);\n    margin-left: var(--unit-1);\n    margin-right: -2px;\n    opacity: 1;\n    padding: var(--unit-h);\n    text-decoration: none;\n    width: var(--unit-5);\n\n    &::before {\n      content: \"\\2715\";\n    }\n  }\n\n  /* Wider button */\n\n  &.btn-wide {\n    padding-left: var(--unit-6);\n    padding-right: var(--unit-6);\n  }\n\n  /* Small icon button */\n\n  &.btn-sm.btn-icon {\n    display: inline-flex;\n    align-items: baseline;\n    gap: var(--unit-h);\n\n    svg {\n      align-self: center;\n    }\n  }\n\n  /* Button icons */\n\n  & svg {\n    color: var(--btn-icon-color);\n    align-self: center;\n  }\n}\n\n/* Button groups */\n.btn-group {\n  display: inline-flex;\n  flex-wrap: wrap;\n\n  .btn {\n    flex: 1 0 auto;\n\n    &:first-child:not(:last-child) {\n      border-bottom-right-radius: 0;\n      border-top-right-radius: 0;\n    }\n\n    &:not(:first-child):not(:last-child) {\n      border-radius: 0;\n      margin-left: calc(-1 * var(--border-width));\n    }\n\n    &:last-child:not(:first-child) {\n      border-bottom-left-radius: 0;\n      border-top-left-radius: 0;\n      margin-left: calc(-1 * var(--border-width));\n    }\n\n    &:focus,\n    &:hover,\n    &:active,\n    &.active {\n      z-index: var(--zindex-0);\n    }\n  }\n\n  &.btn-group-block {\n    display: flex;\n\n    .btn {\n      flex: 1 0 0;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/code.css",
    "content": "/* Code */\n:root {\n  --code-bg-color: var(--body-color-contrast);\n  --code-color: var(--text-color);\n}\n\ncode {\n  border-radius: var(--border-radius);\n  line-height: 1.25;\n  padding: 0.1rem 0.2rem;\n  background: var(--code-bg-color);\n  color: var(--code-color);\n  font-size: 85%;\n}\n\n.code {\n  border-radius: var(--border-radius);\n  background: var(--code-bg-color);\n  color: var(--text-color);\n  position: relative;\n\n  & code {\n    color: inherit;\n    display: block;\n    line-height: 1.5;\n    overflow-x: auto;\n    padding: var(--unit-2);\n    width: 100%;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/dropdowns.css",
    "content": "/* Dropdown */\n.dropdown {\n  --dropdown-focus-display: block;\n\n  display: inline-block;\n  position: relative;\n\n  .menu {\n    animation: fade-in 0.15s ease 1;\n    display: none;\n    left: 0;\n    max-height: 50vh;\n    overflow-y: auto;\n    position: absolute;\n    top: 100%;\n  }\n\n  &.dropdown-right {\n    .menu {\n      left: auto;\n      right: 0;\n    }\n  }\n\n  &:focus-within .menu {\n    /* Use custom CSS property to allow disabling opening on focus when using JS */\n    display: var(--dropdown-focus-display);\n  }\n\n  &.active .menu {\n    /* Always show menu when class is added through JS */\n    display: block;\n  }\n\n  /* Fix dropdown-toggle border radius in button groups */\n  .btn-group {\n    .dropdown-toggle:nth-last-child(2) {\n      border-bottom-right-radius: var(--border-radius);\n      border-top-right-radius: var(--border-radius);\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/empty.css",
    "content": "/* Empty states (or Blank slates) */\n.empty {\n  background: var(--body-color-contrast);\n  border-radius: var(--border-radius);\n  color: var(--secondary-text-color);\n  text-align: center;\n  padding: var(--unit-8) var(--unit-8);\n\n  .empty-icon {\n    margin-bottom: var(--layout-spacing-lg);\n  }\n\n  .empty-title,\n  .empty-subtitle {\n    margin: var(--layout-spacing) auto;\n  }\n\n  .empty-action {\n    margin-top: var(--layout-spacing-lg);\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/forms.css",
    "content": "/* Forms */\n:root {\n  --input-bg-color: var(--body-color);\n  --input-disabled-bg-color: var(--gray-100);\n  --input-text-color: var(--text-color);\n  --input-hint-color: var(--secondary-text-color);\n  --input-border-color: var(--border-color);\n  --input-placeholder-color: var(--tertiary-text-color);\n  --input-box-shadow: var(--box-shadow-xs);\n\n  --checkbox-bg-color: var(--body-color);\n  --checkbox-checked-bg-color: var(--primary-color);\n  --checkbox-disabled-bg-color: var(--gray-100);\n  --checkbox-border-color: var(--border-color);\n  --checkbox-icon-color: #fff;\n\n  --switch-bg-color: var(--gray-300);\n  --switch-border-color: var(--gray-400);\n  --switch-toggle-color: #fff;\n}\n\n.form-group {\n  &:not(:last-child) {\n    margin-bottom: var(--unit-4);\n  }\n}\n\nfieldset {\n  margin-bottom: var(--layout-spacing-lg);\n}\n\nlegend {\n  font-size: var(--font-size-lg);\n  font-weight: 500;\n  margin-bottom: var(--layout-spacing-lg);\n}\n\n/* Form element: Label */\n.form-label {\n  display: block;\n  line-height: var(--line-height);\n  margin-bottom: var(--unit-2);\n  font-weight: 500;\n}\n\ndetails summary .form-label {\n  margin-bottom: 0;\n}\n\ndetails[open] summary .form-label {\n  margin-bottom: var(--unit-2);\n}\n\n/* Form element: Input */\n.form-input {\n  appearance: none;\n  background: var(--input-bg-color);\n  background-image: none;\n  border: var(--border-width) solid var(--input-border-color);\n  border-radius: var(--border-radius);\n  box-shadow: var(--input-box-shadow);\n  color: var(--input-text-color);\n  display: block;\n  font-size: var(--font-size);\n  height: var(--control-size);\n  line-height: var(--line-height);\n  max-width: 100%;\n  outline: none;\n  padding: var(--control-padding-y) var(--control-padding-x);\n  position: relative;\n  transition:\n    background 0.2s,\n    border 0.2s,\n    color 0.2s;\n  width: 100%;\n\n  &:focus {\n    outline: var(--focus-outline);\n    outline-offset: calc(var(--focus-outline-offset) * -1);\n  }\n\n  &::placeholder {\n    color: var(--input-placeholder-color);\n    opacity: 1;\n  }\n\n  /* Input sizes */\n\n  &.input-sm {\n    font-size: var(--font-size-sm);\n    height: var(--control-size-sm);\n    padding: var(--control-padding-y-sm) var(--control-padding-x-sm);\n  }\n\n  &.input-lg {\n    font-size: var(--font-size-lg);\n    height: var(--control-size-lg);\n    padding: var(--control-padding-y-lg) var(--control-padding-x-lg);\n  }\n\n  &.input-inline {\n    display: inline-block;\n    vertical-align: middle;\n    width: auto;\n  }\n\n  /* Input types */\n\n  &[type=\"file\"] {\n    height: auto;\n  }\n}\n\n/* Form element: Textarea */\ntextarea.form-input {\n  &,\n  &.input-lg,\n  &.input-sm {\n    height: auto;\n  }\n}\n\n/* Form element: Input hint */\n.form-input-hint {\n  color: var(--input-hint-color);\n  font-size: var(--font-size-sm);\n  margin-top: var(--unit-1);\n\n  .has-success &,\n  .is-success + & {\n    color: var(--success-color);\n  }\n\n  .has-error &,\n  .is-error + & {\n    color: var(--error-color);\n  }\n\n  &.is-error {\n    color: var(--error-color);\n  }\n}\n\n/* Form element: Select */\n.form-select {\n  appearance: none;\n  background: var(--input-bg-color);\n  border: var(--border-width) solid var(--input-border-color);\n  border-radius: var(--border-radius);\n  box-shadow: var(--input-box-shadow);\n  color: var(--input-text-color);\n  font-size: var(--font-size);\n  height: var(--control-size);\n  line-height: var(--line-height);\n  outline: none;\n  padding: var(--control-padding-y) var(--control-padding-x);\n  vertical-align: middle;\n  width: 100%;\n\n  &:focus {\n    outline: var(--focus-outline);\n    outline-offset: calc(var(--focus-outline-offset) * -1);\n  }\n\n  /* Select sizes */\n\n  &.select-sm {\n    font-size: var(--font-size-sm);\n    height: var(--control-size-sm);\n    padding: var(--control-padding-y-sm)\n      calc(var(--control-icon-size) + var(--control-padding-x-sm))\n      var(--control-padding-y-sm) var(--control-padding-x-sm);\n  }\n\n  &.select-lg {\n    font-size: var(--font-size-lg);\n    height: var(--control-size-lg);\n    padding: var(--control-padding-y-lg)\n      calc(var(--control-icon-size) + var(--control-padding-x-lg))\n      var(--control-padding-y-lg) var(--control-padding-x-lg);\n  }\n\n  /* Multiple select */\n\n  &[size],\n  &[multiple] {\n    height: auto;\n    padding: var(--control-padding-y) var(--control-padding-x);\n\n    & option {\n      padding: var(--unit-h) var(--unit-1);\n    }\n  }\n\n  &:not([multiple]):not([size]) {\n    background: var(--input-bg-color)\n      url(\"data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E\")\n      no-repeat right 0.35rem center / 0.4rem 0.5rem;\n    padding-right: calc(var(--control-icon-size) + var(--control-padding-x));\n  }\n\n  /* Options */\n  & option {\n    /* On Windows with Chrome / Edge, options seems to use the same\n    background color as the select. However for the dark theme the\n    background is a semi-transparent white, resulting in an opaque white\n    background for the dropdown. Use the modal background color to force\n    a dark background instead. */\n    background: var(--modal-container-bg-color);\n  }\n}\n\n/* Form element: Checkbox and Radio */\n.form-checkbox,\n.form-radio,\n.form-switch {\n  display: block;\n  line-height: var(--line-height);\n  margin: calc((var(--control-size) - var(--control-size-sm)) / 2) 0;\n  min-height: var(--control-size-sm);\n  padding: calc((var(--control-size-sm) - var(--line-height)) / 2)\n    var(--control-padding-x)\n    calc((var(--control-size-sm) - var(--line-height)) / 2)\n    calc(var(--control-icon-size) + var(--control-padding-x));\n  position: relative;\n\n  input {\n    opacity: 0;\n    position: absolute;\n    top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);\n    left: 0;\n    height: var(--control-icon-size);\n    width: var(--control-icon-size);\n    cursor: pointer;\n\n    &:focus-visible + .form-icon {\n      outline: var(--focus-outline);\n      outline-offset: var(--focus-outline-offset);\n    }\n\n    &:checked + .form-icon {\n      background: var(--checkbox-checked-bg-color);\n      border-color: var(--checkbox-checked-bg-color);\n    }\n  }\n\n  .form-icon {\n    pointer-events: none;\n    border: var(--border-width) solid var(--checkbox-border-color);\n    box-shadow: var(--input-box-shadow);\n    display: inline-block;\n    position: absolute;\n    transition:\n      background 0.2s,\n      border 0.2s,\n      color 0.2s;\n  }\n\n  /* Input checkbox, radio, and switch sizes */\n\n  &.input-sm {\n    font-size: var(--font-size-sm);\n    margin: 0;\n  }\n\n  &.input-lg {\n    font-size: var(--font-size-lg);\n    margin: calc((var(--control-size-lg) - var(--control-size-sm)) / 2) 0;\n  }\n}\n\n.form-checkbox,\n.form-radio {\n  .form-icon {\n    background: var(--checkbox-bg-color);\n    height: var(--control-icon-size);\n    left: 0;\n    top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);\n    width: var(--control-icon-size);\n  }\n}\n\n.form-checkbox {\n  font-weight: 500;\n\n  .form-icon {\n    border-radius: var(--border-radius);\n  }\n\n  input {\n    &:checked + .form-icon {\n      &::before {\n        background-clip: padding-box;\n        border: var(--border-width-lg) solid var(--checkbox-icon-color);\n        border-left-width: 0;\n        border-top-width: 0;\n        content: \"\";\n        height: 9px;\n        left: 50%;\n        margin-left: -3px;\n        margin-top: -6px;\n        position: absolute;\n        top: 50%;\n        transform: rotate(45deg);\n        width: 6px;\n      }\n    }\n\n    &:indeterminate + .form-icon {\n      background: var(--checkbox-checked-bg-color);\n      border-color: var(--checkbox-checked-bg-color);\n\n      &::before {\n        background: var(--checkbox-icon-color);\n        content: \"\";\n        height: 2px;\n        left: 50%;\n        margin-left: -5px;\n        margin-top: -1px;\n        position: absolute;\n        top: 50%;\n        width: 10px;\n      }\n    }\n  }\n}\n\n.form-radio {\n  .form-icon {\n    border-radius: 50%;\n  }\n\n  input {\n    &:checked + .form-icon {\n      &::before {\n        background: var(--checkbox-icon-color);\n        border-radius: 50%;\n        content: \"\";\n        height: 6px;\n        left: 50%;\n        position: absolute;\n        top: 50%;\n        transform: translate(-50%, -50%);\n        width: 6px;\n      }\n    }\n  }\n}\n\n/* Form element: Switch */\n.form-switch {\n  padding-left: calc(var(--unit-8) + var(--control-padding-x));\n\n  .form-icon {\n    background: var(--switch-bg-color);\n    background-clip: padding-box;\n    border-color: var(--switch-border-color);\n    border-radius: calc(var(--unit-2) + var(--border-width));\n    height: calc(var(--unit-4) + var(--border-width) * 2);\n    left: 0;\n    top: calc(\n      (var(--control-size-sm) - var(--unit-4)) / 2 - var(--border-width)\n    );\n    width: var(--unit-8);\n\n    &::before {\n      background: var(--switch-toggle-color);\n      border-radius: 50%;\n      content: \"\";\n      display: block;\n      height: var(--unit-4);\n      left: 0;\n      position: absolute;\n      top: 0;\n      transition:\n        background 0.2s,\n        border 0.2s,\n        color 0.2s,\n        left 0.2s;\n      width: var(--unit-4);\n    }\n  }\n\n  input {\n    &:checked + .form-icon {\n      &::before {\n        left: 14px;\n      }\n    }\n  }\n}\n\n/* Form Icons */\n.has-icon-left,\n.has-icon-right {\n  position: relative;\n\n  .form-icon {\n    height: var(--control-icon-size);\n    margin: 0 var(--control-padding-y);\n    position: absolute;\n    top: 50%;\n    transform: translateY(-50%);\n    width: var(--control-icon-size);\n    z-index: calc(var(--zindex-0) + 1);\n  }\n}\n\n.has-icon-left {\n  & .form-icon {\n    left: var(--border-width);\n  }\n\n  & .form-input {\n    padding-left: calc(var(--control-icon-size) + var(--control-padding-y) * 2);\n  }\n}\n\n.has-icon-right {\n  & .form-icon {\n    right: var(--border-width);\n  }\n\n  & .form-input {\n    padding-right: calc(\n      var(--control-icon-size) + var(--control-padding-y) * 2\n    );\n  }\n}\n\n/* Form element: Input groups */\n.input-group {\n  display: flex;\n  border-radius: var(--border-radius);\n  box-shadow: var(--input-box-shadow);\n\n  > * {\n    box-shadow: none !important;\n  }\n\n  .input-group-addon {\n    display: flex;\n    align-items: center;\n    background: var(--input-bg-color);\n    border: var(--border-width) solid var(--input-border-color);\n    border-radius: var(--border-radius);\n    line-height: var(--line-height);\n    padding: 0 var(--control-padding-x);\n    white-space: nowrap;\n\n    &.addon-sm {\n      font-size: var(--font-size-sm);\n      padding: var(--control-padding-y-sm) var(--control-padding-x-sm);\n    }\n\n    &.addon-lg {\n      font-size: var(--font-size-lg);\n      padding: var(--control-padding-y-lg) var(--control-padding-x-lg);\n    }\n  }\n\n  .form-input,\n  .form-select {\n    flex: 1 1 auto;\n    width: 1%;\n  }\n\n  .input-group-btn {\n    z-index: var(--zindex-0);\n  }\n\n  .form-input,\n  .form-select,\n  .input-group-addon,\n  .input-group-btn {\n    &:first-child:not(:last-child) {\n      border-bottom-right-radius: 0;\n      border-top-right-radius: 0;\n    }\n\n    &:not(:first-child):not(:last-child) {\n      border-radius: 0;\n      margin-left: calc(-1 * var(--border-width));\n    }\n\n    &:last-child:not(:first-child) {\n      border-bottom-left-radius: 0;\n      border-top-left-radius: 0;\n      margin-left: calc(-1 * var(--border-width));\n    }\n\n    &:focus {\n      z-index: calc(var(--zindex-0) + 1);\n    }\n  }\n\n  .form-select {\n    width: auto;\n  }\n\n  &.input-inline {\n    display: inline-flex;\n  }\n}\n\n/* Form validation states */\n.form-input,\n.form-select {\n  .has-success &,\n  &.is-success {\n    background: var(--success-color-shade);\n    border-color: var(--success-color);\n\n    &:focus {\n      outline-color: var(--success-color);\n    }\n  }\n\n  .has-error &,\n  &.is-error {\n    background: var(--error-color-shade);\n    border-color: var(--error-color);\n\n    &:focus {\n      outline-color: var(--error-color);\n    }\n  }\n}\n\n/* Form disabled and readonly */\n.form-input,\n.form-select {\n  &:disabled,\n  &.disabled {\n    background-color: var(--input-disabled-bg-color);\n    cursor: not-allowed;\n  }\n}\n\ninput {\n  &:disabled,\n  &.disabled {\n    & + .form-icon {\n      background: var(--checkbox-disabled-bg-color);\n      cursor: not-allowed;\n    }\n  }\n}\n\n/* Increase input font size on small viewports to prevent zooming on focus the input */\n/* on mobile devices. 430px relates to the \"normalized\" iPhone 14 Pro Max */\n/* viewport size */\n@media screen and (max-width: 430px) {\n  .form-input {\n    font-size: 16px;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/menus.css",
    "content": ":root {\n  --menu-bg-color: var(--body-color);\n  --menu-border-color: var(--gray-200);\n  --menu-border-radius: var(--border-radius);\n  --menu-box-shadow: var(--box-shadow);\n  --menu-item-color: var(--text-color);\n  --menu-item-hover-color: var(--primary-text-color);\n  --menu-item-bg-color: transparent;\n  --menu-item-hover-bg-color: var(--primary-color-shade);\n  --menu-item-min-width: 120px;\n}\n\n/* Menus */\n.menu {\n  background: var(--menu-bg-color);\n  border: solid 1px var(--menu-border-color);\n  border-radius: var(--menu-border-radius);\n  box-shadow: var(--menu-box-shadow);\n  list-style: none;\n  margin: 0;\n  min-width: var(--menu-item-min-width);\n  transform: translateY(var(--layout-spacing-sm));\n  z-index: var(--zindex-3);\n\n  &.menu-nav {\n    background: transparent;\n    box-shadow: none;\n  }\n\n  .menu-item {\n    margin-top: 0;\n    padding: 0 var(--unit-3);\n    position: relative;\n    text-decoration: none;\n    white-space: nowrap;\n\n    &:first-of-type {\n      padding-top: var(--unit-1);\n    }\n\n    &:last-of-type {\n      padding-bottom: var(--unit-1);\n    }\n\n    & > a,\n    .btn.btn-link {\n      border-radius: var(--menu-border-radius);\n      color: var(--menu-item-color);\n      background: var(--menu-item-bg-color);\n      display: block;\n      margin: 0 calc(-1 * var(--unit-2));\n      padding: var(--unit-1) var(--unit-2);\n      text-decoration: none;\n\n      &:focus,\n      &:hover,\n      &:active,\n      &.active {\n        background: var(--menu-item-hover-bg-color);\n        color: var(--menu-item-hover-color);\n      }\n    }\n\n    .form-checkbox,\n    .form-radio,\n    .form-switch {\n      margin: var(--unit-h) 0;\n    }\n\n    & + .menu-item {\n      margin-top: var(--unit-1);\n    }\n  }\n\n  & .menu-badge {\n    align-items: center;\n    display: flex;\n    height: 100%;\n    position: absolute;\n    right: 0;\n    top: 0;\n\n    .label {\n      margin-right: var(--unit-2);\n    }\n  }\n\n  & .divider {\n    border-bottom: solid 1px var(--secondary-border-color);\n    margin: var(--unit-2) 0;\n  }\n\n  &.with-arrow {\n    overflow: visible;\n    --arrow-size: 16px;\n\n    .menu-arrow {\n      display: block;\n      position: absolute;\n      top: 0;\n      width: var(--arrow-size);\n      height: var(--arrow-size);\n      translate: 0 -50%;\n      rotate: 45deg;\n      background: inherit;\n      border: inherit;\n      clip-path: polygon(0 0, 0 100%, 100% 0);\n    }\n\n    &.top-aligned .menu-arrow {\n      top: auto;\n      bottom: 0;\n      rotate: 225deg;\n      translate: 0 50%;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/modals.css",
    "content": "/* Modals */\n:root {\n  --modal-overlay-bg-color: rgba(243, 244, 246, 0.6);\n  --modal-container-bg-color: var(--body-color);\n  --modal-container-border-color: var(--gray-200);\n  --modal-border-radius: var(--border-radius-lg);\n  --modal-box-shadow: var(--box-shadow-lg);\n}\n\n.modal {\n  align-items: center;\n  bottom: 0;\n  display: none;\n  justify-content: center;\n  left: 0;\n  opacity: 0;\n  overflow: hidden;\n  padding: var(--layout-spacing);\n  position: fixed;\n  right: 0;\n  top: 0;\n\n  &:target,\n  &.active {\n    display: flex;\n    opacity: 1;\n    z-index: var(--zindex-4);\n\n    & .modal-overlay {\n      animation: fade-in 0.15s ease 1;\n      background: var(--modal-overlay-bg-color);\n      bottom: 0;\n      cursor: default;\n      display: block;\n      left: 0;\n      position: fixed;\n      right: 0;\n      top: 0;\n    }\n\n    & .modal-container {\n      animation: fade-in 0.15s ease 1;\n      z-index: var(--zindex-0);\n    }\n  }\n\n  &.active.closing {\n    & .modal-overlay,\n    & .modal-container {\n      animation: fade-out 0.15s ease 1;\n    }\n  }\n}\n\n.modal-container {\n  background: var(--modal-container-bg-color);\n  border: solid 1px var(--modal-container-border-color);\n  border-radius: var(--modal-border-radius);\n  box-shadow: var(--modal-box-shadow);\n  display: flex;\n  flex-direction: column;\n  gap: var(--unit-4);\n  max-height: 75vh;\n  max-width: var(--control-width-md);\n\n  & .modal-header {\n    display: flex;\n    align-items: flex-start;\n    gap: var(--unit-2);\n    padding: var(--unit-6);\n    padding-bottom: 0;\n    color: var(--text-color);\n\n    & h2 {\n      flex: 1 1 0;\n      align-items: flex-start;\n      font-size: 1rem;\n      margin: 0;\n    }\n\n    & .close {\n      padding: 0;\n      height: auto;\n    }\n  }\n\n  & .modal-body {\n    overflow-y: auto;\n    padding: 0 var(--unit-6);\n  }\n\n  & .modal-body:not(:has(+ .modal-footer)) {\n    margin-bottom: var(--unit-6);\n  }\n\n  & .modal-footer {\n    padding: var(--unit-6);\n    padding-top: 0;\n  }\n}\n\n.modal.drawer {\n  display: block;\n\n  & .modal-container {\n    position: fixed;\n    top: 0;\n    right: 0;\n    width: 400px;\n    max-width: 100%;\n    height: 100%;\n    max-height: 100%;\n    border: none;\n    border-left: solid 1px var(--modal-container-border-color);\n    border-radius: 0;\n    transform: translateX(100%);\n    animation: fade-in 0.25s ease 1;\n    transition: transform 0.25s ease;\n  }\n\n  &.active {\n    & .modal-container {\n      transform: translateX(0);\n    }\n  }\n\n  &.active.closing {\n    & .modal-container {\n      animation: fade-out 0.25s ease 1;\n      transform: translateX(100%);\n    }\n  }\n}\n\n.scroll-lock {\n  overflow: hidden !important;\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/pagination.css",
    "content": "/* Pagination */\n.pagination {\n  display: flex;\n  list-style: none;\n  margin: var(--unit-1) 0;\n  padding: var(--unit-1) 0;\n\n  & .page-item {\n    margin: var(--unit-1) var(--unit-o);\n\n    & span {\n      display: inline-block;\n      padding: var(--unit-1) var(--unit-1);\n    }\n\n    & a {\n      border-radius: var(--border-radius);\n      display: inline-block;\n      padding: var(--unit-1) var(--unit-2);\n      text-decoration: none;\n\n      &:focus,\n      &:hover {\n        color: var(--primary-text-color);\n      }\n    }\n\n    &.disabled {\n      & a {\n        cursor: default;\n        opacity: 0.5;\n        pointer-events: none;\n      }\n    }\n\n    &:first-child a {\n      /* Remove left padding from first pagination link */\n      padding-left: 0;\n    }\n\n    &.active {\n      & a {\n        background: var(--primary-color);\n        color: var(--contrast-text-color);\n      }\n    }\n\n    &.page-prev,\n    &.page-next {\n      flex: 1 0 50%;\n    }\n\n    &.page-next {\n      text-align: right;\n    }\n\n    & .page-item-title {\n      margin: 0;\n    }\n\n    & .page-item-subtitle {\n      margin: 0;\n      opacity: 0.5;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/tables.css",
    "content": "/* Tables */\n.table {\n  border-collapse: collapse;\n  border-spacing: 0;\n  width: 100%;\n  text-align: left;\n\n  td,\n  th {\n    border-bottom: var(--border-width) solid var(--secondary-border-color);\n    padding: var(--unit-2) var(--unit-2);\n  }\n\n  th {\n    font-weight: 500;\n    border-bottom-color: var(--border-color);\n  }\n\n  th:first-child,\n  td:first-child {\n    padding-left: 0;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/tabs.css",
    "content": "/* Tabs */\n:root {\n  --tab-color: var(--text-color);\n  --tab-hover-color: var(--primary-text-color);\n  --tab-active-color: var(--primary-text-color);\n  --tab-highlight-color: var(--primary-color);\n}\n\n.tab {\n  align-items: center;\n  border-bottom: var(--border-width) solid var(--border-color);\n  display: flex;\n  flex-wrap: wrap;\n  list-style: none;\n  margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0;\n\n  & .tab-item {\n    margin-top: 0;\n\n    & a {\n      border-bottom: var(--border-width-lg) solid transparent;\n      color: var(--tab-color);\n      display: block;\n      margin: 0 var(--unit-2) 0 0;\n      padding: var(--unit-2) var(--unit-1)\n        calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1);\n      text-decoration: none;\n\n      &:focus,\n      &:hover {\n        color: var(--tab-hover-color);\n      }\n    }\n\n    &.active a,\n    & a.active {\n      border-bottom-color: var(--tab-highlight-color);\n      color: var(--tab-active-color);\n    }\n\n    &.tab-action {\n      flex: 1 0 auto;\n      text-align: right;\n    }\n\n    & .btn-clear {\n      margin-top: calc(-1 * var(--unit-1));\n    }\n  }\n\n  &.tab-block {\n    & .tab-item {\n      flex: 1 0 0;\n      text-align: center;\n\n      & a {\n        margin: 0;\n      }\n\n      & .badge {\n        &[data-badge]::after {\n          position: absolute;\n          right: var(--unit-h);\n          top: var(--unit-h);\n          transform: translate(0, 0);\n        }\n      }\n    }\n  }\n\n  &:not(.tab-block) {\n    & .badge {\n      padding-right: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/toasts.css",
    "content": "/* Toasts */\n.toast {\n  background: var(--gray-600);\n  border-radius: var(--border-radius);\n  color: var(--contrast-text-color);\n  display: block;\n  padding: var(--layout-spacing);\n  width: 100%;\n\n  &.toast-primary {\n    background: var(--primary-color);\n  }\n\n  &.toast-success {\n    background: var(--success-color);\n  }\n\n  &.toast-warning {\n    background: var(--warning-color);\n  }\n\n  &.toast-error {\n    background: var(--error-color);\n  }\n\n  .btn-clear {\n    margin: var(--unit-h);\n  }\n\n  p {\n    &:last-child {\n      margin-bottom: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/typography.css",
    "content": "/* Typography */\n/* Headings */\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  color: inherit;\n  font-weight: 500;\n  line-height: 1.2;\n  margin-bottom: 0.5em;\n  margin-top: 0;\n}\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n  font-weight: 500;\n}\nh1,\n.h1 {\n  font-size: 2rem;\n}\nh2,\n.h2 {\n  font-size: 1.6rem;\n}\nh3,\n.h3 {\n  font-size: 1.4rem;\n}\nh4,\n.h4 {\n  font-size: 1.2rem;\n}\nh5,\n.h5 {\n  font-size: 1rem;\n}\nh6,\n.h6 {\n  font-size: 0.8rem;\n}\n\n/* Paragraphs */\np {\n  margin: 0 0 var(--line-height);\n}\n\n/* Semantic text elements */\na,\nins,\nu {\n  text-decoration-skip-ink: auto;\n}\n\nabbr[title] {\n  border-bottom: var(--border-width) dotted;\n  cursor: help;\n  text-decoration: none;\n}\n\n/* Blockquote */\nblockquote {\n  border-left: var(--border-width-lg) solid var(--border-color);\n  margin-left: 0;\n  padding: var(--unit-2) var(--unit-4);\n\n  & p:last-child {\n    margin-bottom: 0;\n  }\n}\n\n/* Lists */\nul,\nol {\n  margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);\n  padding: 0;\n\n  & ul,\n  & ol {\n    margin: var(--unit-4) 0 var(--unit-4) var(--unit-4);\n  }\n\n  & li {\n    margin-top: var(--unit-2);\n  }\n}\n\nul {\n  list-style: disc inside;\n\n  & ul {\n    list-style-type: circle;\n  }\n}\n\nol {\n  list-style: decimal inside;\n\n  & ol {\n    list-style-type: lower-alpha;\n  }\n}\n\ndl {\n  & dt {\n    font-weight: bold;\n  }\n\n  & dd {\n    margin: var(--unit-1) 0 var(--unit-4) 0;\n  }\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/utilities.css",
    "content": "/* Colors */\n.text-primary {\n  color: var(--primary-text-color);\n}\n\n.text-secondary {\n  color: var(--secondary-text-color);\n}\n\n.text-tertiary {\n  color: var(--tertiary-text-color);\n}\n\n.text-success {\n  color: var(--success-color);\n}\n\n.text-warning {\n  color: var(--warning-color);\n}\n\n.text-error {\n  color: var(--error-color);\n}\n\n.icon-color {\n  color: var(--icon-color);\n}\n\n/* Display */\n.d-block {\n  display: block;\n}\n\n.d-inline {\n  display: inline;\n}\n\n.d-inline-block {\n  display: inline-block;\n}\n\n.d-flex {\n  display: flex;\n}\n\n.d-inline-flex {\n  display: inline-flex;\n}\n\n.d-none,\n.d-hide {\n  display: none !important;\n}\n\n.d-visible {\n  visibility: visible;\n}\n\n.d-invisible {\n  visibility: hidden;\n}\n\n.text-hide {\n  background: transparent;\n  border: 0;\n  color: transparent;\n  font-size: 0;\n  line-height: 0;\n  text-shadow: none;\n}\n\n.text-assistive {\n  border: 0;\n  clip: rect(0, 0, 0, 0);\n  height: 1px;\n  margin: -1px;\n  overflow: hidden;\n  padding: 0;\n  position: absolute;\n  width: 1px;\n}\n\n/* Loading */\n.loading {\n  color: transparent !important;\n  min-height: var(--unit-4);\n  pointer-events: none;\n  position: relative;\n\n  &::after {\n    animation: loading 500ms infinite linear;\n    background: transparent;\n    border: var(--border-width-lg) solid var(--primary-color);\n    border-radius: 50%;\n    border-right-color: transparent;\n    border-top-color: transparent;\n    content: \"\";\n    display: block;\n    height: var(--unit-4);\n    left: 50%;\n    margin-left: calc(-1 * var(--unit-2));\n    margin-top: calc(-1 * var(--unit-2));\n    opacity: 1;\n    padding: 0;\n    position: absolute;\n    top: 50%;\n    width: var(--unit-4);\n    z-index: var(--zindex-0);\n  }\n\n  &.loading-lg {\n    min-height: var(--unit-10);\n\n    &::after {\n      height: var(--unit-8);\n      margin-left: calc(-1 * var(--unit-4));\n      margin-top: calc(-1 * var(--unit-4));\n      width: var(--unit-8);\n    }\n  }\n}\n\n/* Position */\n.m-0 {\n  margin: 0 !important;\n}\n\n.mb-0 {\n  margin-bottom: 0 !important;\n}\n\n.ml-0 {\n  margin-left: 0 !important;\n}\n\n.mr-0 {\n  margin-right: 0 !important;\n}\n\n.mt-0 {\n  margin-top: 0 !important;\n}\n\n.mx-0 {\n  margin-left: 0 !important;\n  margin-right: 0 !important;\n}\n\n.my-0 {\n  margin-bottom: 0 !important;\n  margin-top: 0 !important;\n}\n\n.m-1 {\n  margin: var(--unit-1) !important;\n}\n\n.mb-1 {\n  margin-bottom: var(--unit-1) !important;\n}\n\n.ml-1 {\n  margin-left: var(--unit-1) !important;\n}\n\n.mr-1 {\n  margin-right: var(--unit-1) !important;\n}\n\n.mt-1 {\n  margin-top: var(--unit-1) !important;\n}\n\n.mx-1 {\n  margin-left: var(--unit-1) !important;\n  margin-right: var(--unit-1) !important;\n}\n\n.my-1 {\n  margin-bottom: var(--unit-1) !important;\n  margin-top: var(--unit-1) !important;\n}\n\n.m-2 {\n  margin: var(--unit-2) !important;\n}\n\n.mb-2 {\n  margin-bottom: var(--unit-2) !important;\n}\n\n.ml-2 {\n  margin-left: var(--unit-2) !important;\n}\n\n.mr-2 {\n  margin-right: var(--unit-2) !important;\n}\n\n.mt-2 {\n  margin-top: var(--unit-2) !important;\n}\n\n.mx-2 {\n  margin-left: var(--unit-2) !important;\n  margin-right: var(--unit-2) !important;\n}\n\n.my-2 {\n  margin-bottom: var(--unit-2) !important;\n  margin-top: var(--unit-2) !important;\n}\n\n.m-4 {\n  margin: var(--unit-4) !important;\n}\n\n.mb-4 {\n  margin-bottom: var(--unit-4) !important;\n}\n\n.ml-4 {\n  margin-left: var(--unit-4) !important;\n}\n\n.mr-4 {\n  margin-right: var(--unit-4) !important;\n}\n\n.mt-4 {\n  margin-top: var(--unit-4) !important;\n}\n\n.mx-4 {\n  margin-left: var(--unit-4) !important;\n  margin-right: var(--unit-4) !important;\n}\n\n.my-4 {\n  margin-bottom: var(--unit-4) !important;\n  margin-top: var(--unit-4) !important;\n}\n\n.m-6 {\n  margin: var(--unit-6) !important;\n}\n\n.mb-6 {\n  margin-bottom: var(--unit-6) !important;\n}\n\n.ml-6 {\n  margin-left: var(--unit-6) !important;\n}\n\n.mr-6 {\n  margin-right: var(--unit-6) !important;\n}\n\n.mt-6 {\n  margin-top: var(--unit-6) !important;\n}\n\n.mx-6 {\n  margin-left: var(--unit-6) !important;\n  margin-right: var(--unit-6) !important;\n}\n\n.my-6 {\n  margin-bottom: var(--unit-6) !important;\n  margin-top: var(--unit-6) !important;\n}\n\n.ml-auto {\n  margin-left: auto;\n}\n\n.mr-auto {\n  margin-right: auto;\n}\n\n.mx-auto {\n  margin-left: auto;\n  margin-right: auto;\n}\n\n/* Text */\n.text-normal {\n  font-weight: normal;\n}\n\n.text-semibold {\n  font-weight: 500;\n}\n\n.text-bold {\n  font-weight: bold;\n}\n\n.text-italic {\n  font-style: italic;\n}\n\n.text-large {\n  font-size: 1.2em;\n}\n\n.text-small {\n  font-size: 0.9em;\n}\n\n.text-tiny {\n  font-size: 0.8em;\n}\n\n.text-muted {\n  opacity: 0.8;\n}\n\n.truncate {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* Flex */\n.flex-column {\n  flex-direction: column;\n}\n\n.align-baseline {\n  align-items: baseline;\n}\n\n.align-center {\n  align-items: center;\n}\n\n.justify-between {\n  justify-content: space-between;\n}\n\n.gap-2 {\n  gap: var(--unit-2);\n}\n"
  },
  {
    "path": "bookmarks/styles/theme/variables.css",
    "content": ":root {\n  /* Color palette */\n  --gray-50: rgb(249, 250, 251);\n  --gray-100: rgb(243, 244, 246);\n  --gray-200: rgb(229, 231, 235);\n  --gray-300: rgb(209, 213, 219);\n  --gray-400: rgb(156, 163, 175);\n  --gray-500: rgb(107, 114, 128);\n  --gray-600: rgb(75, 85, 99);\n  --gray-700: rgb(55, 65, 81);\n  --gray-800: rgb(31, 41, 55);\n  --gray-900: rgb(17, 24, 39);\n\n  --primary-color: hsl(241, 63%, 59%);\n  --primary-color-highlight: hsl(241, 63%, 64%);\n  --primary-color-shade: hsl(241, 63%, 59%, 0.075);\n\n  --alternative-color: hsl(179, 94%, 29%);\n  --alternative-color-dark: hsl(179, 94%, 22%);\n\n  --success-color: hsl(142, 76%, 36%);\n  --success-color-highlight: hsl(142, 76%, 40%);\n  --success-color-shade: hsla(142, 76%, 36%, 0.1);\n\n  --warning-color: hsl(38, 92%, 50%);\n  --warning-color-highlight: hsl(38, 92%, 55%);\n  --warning-color-shade: hsla(38, 92%, 50%, 0.1);\n\n  --error-color: hsl(0, 72%, 51%);\n  --error-color-highlight: hsl(0, 72%, 60%);\n  --error-color-shade: hsla(0, 72%, 51%, 0.1);\n\n  /* Core colors */\n  --text-color: var(--gray-700);\n  --secondary-text-color: var(--gray-500);\n  --tertiary-text-color: var(--gray-500);\n  --contrast-text-color: #fff;\n  --primary-text-color: hsl(241, 63%, 55%);\n\n  --link-color: var(--primary-text-color);\n  --secondary-link-color: hsla(241, 63%, 54%, 0.8);\n\n  --icon-color: var(--gray-500);\n\n  --border-color: var(--gray-300);\n  --secondary-border-color: var(--gray-200);\n\n  --body-color: #fff;\n  --body-color-contrast: var(--gray-100);\n\n  /* Fonts */\n  --base-font-family:\n    -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", Roboto;\n  --mono-font-family:\n    \"SF Mono\", \"Segoe UI Mono\", \"Roboto Mono\", Menlo, Courier, monospace;\n  --fallback-font-family: \"Helvetica Neue\", sans-serif;\n  --cjk-zh-hans-font-family:\n    var(--base-font-family), \"PingFang SC\", \"Hiragino Sans GB\",\n    \"Microsoft YaHei\", var(--fallback-font-family);\n  --cjk-zh-hant-font-family:\n    var(--base-font-family), \"PingFang TC\", \"Hiragino Sans CNS\",\n    \"Microsoft JhengHei\", var(--fallback-font-family);\n  --cjk-jp-font-family:\n    var(--base-font-family), \"Hiragino Sans\", \"Hiragino Kaku Gothic Pro\",\n    \"Yu Gothic\", YuGothic, Meiryo, var(--fallback-font-family);\n  --cjk-ko-font-family:\n    var(--base-font-family), \"Malgun Gothic\", var(--fallback-font-family);\n  --body-font-family: var(--base-font-family), var(--fallback-font-family);\n\n  /* Unit sizes */\n  --unit-o: 0.05rem;\n  --unit-h: 0.1rem;\n  --unit-1: 0.2rem;\n  --unit-2: 0.4rem;\n  --unit-3: 0.6rem;\n  --unit-4: 0.8rem;\n  --unit-5: 1rem;\n  --unit-6: 1.2rem;\n  --unit-7: 1.4rem;\n  --unit-8: 1.6rem;\n  --unit-9: 1.8rem;\n  --unit-10: 2rem;\n  --unit-12: 2.4rem;\n  --unit-16: 3.2rem;\n\n  /* Font sizes */\n  --html-font-size: 20px;\n  --html-line-height: 1.5;\n  --font-size: 0.7rem;\n  --font-size-sm: 0.65rem;\n  --font-size-lg: 0.8rem;\n  --font-size-xl: 1rem;\n  --line-height: 1rem;\n\n  /* Sizes */\n  --layout-spacing: var(--unit-2);\n  --layout-spacing-sm: var(--unit-1);\n  --layout-spacing-lg: var(--unit-4);\n  --border-radius: var(--unit-1);\n  --border-radius-lg: var(--unit-2);\n  --border-width: var(--unit-o);\n  --border-width-lg: var(--unit-h);\n  --control-size: var(--unit-8);\n  --control-size-sm: var(--unit-6);\n  --control-size-lg: var(--unit-9);\n  --control-padding-x: var(--unit-2);\n  --control-padding-x-sm: calc(var(--unit-2) * 0.75);\n  --control-padding-x-lg: calc(var(--unit-2) * 1.5);\n  --control-padding-y: calc(\n    (var(--control-size) - var(--line-height)) / 2 - var(--border-width)\n  );\n  --control-padding-y-sm: calc(\n    (var(--control-size-sm) - var(--line-height)) / 2 - var(--border-width)\n  );\n  --control-padding-y-lg: calc(\n    (var(--control-size-lg) - var(--line-height)) / 2 - var(--border-width)\n  );\n  --control-icon-size: 0.8rem;\n\n  --control-width-xs: 180px;\n  --control-width-sm: 320px;\n  --control-width-md: 640px;\n  --control-width-lg: 960px;\n  --control-width-xl: 1280px;\n\n  /* Responsive breakpoints */\n  --size-xs: 480px;\n  --size-sm: 600px;\n  --size-md: 840px;\n  --size-lg: 960px;\n  --size-xl: 1280px;\n  --size-2x: 1440px;\n\n  --responsive-breakpoint: var(--size-xs);\n\n  /* Z-index */\n  --zindex-0: 1;\n  --zindex-1: 100;\n  --zindex-2: 200;\n  --zindex-3: 300;\n  --zindex-4: 400;\n\n  /* Focus */\n  --focus-outline: 2px solid var(--primary-color);\n  --focus-outline-offset: 2px;\n\n  /* Shadows */\n  --box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;\n  --box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\n  --box-shadow-lg:\n    0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n}\n"
  },
  {
    "path": "bookmarks/styles/theme-dark.css",
    "content": "@import \"theme-light.css\";\n\n:root {\n  /* Color palette */\n  --contrast-5: hsla(241, 65%, 85%, 0.06);\n  --contrast-10: hsla(241, 60%, 80%, 0.14);\n  --contrast-20: hsla(241, 64%, 82%, 0.23);\n  --contrast-30: hsla(241, 69%, 84%, 0.32);\n  --contrast-40: hsla(241, 73%, 86%, 0.41);\n  --contrast-50: hsla(241, 78%, 88%, 0.5);\n  --contrast-60: hsla(241, 82%, 90%, 0.58);\n  --contrast-70: hsla(241, 87%, 92%, 0.69);\n  --contrast-80: hsla(241, 91%, 94%, 0.8);\n  --contrast-90: hsla(241, 96%, 96%, 0.9);\n\n  --primary-color: hsl(241, 75%, 64%);\n  --primary-color-highlight: hsl(241, 75%, 68%);\n  --primary-color-shade: hsl(241, 75%, 64%, 0.42);\n\n  --alternative-color: hsl(179, 50%, 58%);\n  --alternative-color-dark: hsl(179, 80%, 75%);\n\n  --success-color: hsl(142, 76%, 36%);\n  --success-color-highlight: hsl(142, 76%, 40%);\n  --success-color-shade: hsla(142, 76%, 36%, 0.1);\n\n  --warning-color: hsl(38, 92%, 50%);\n  --warning-color-highlight: hsl(38, 92%, 55%);\n  --warning-color-shade: hsla(38, 92%, 50%, 0.1);\n\n  --error-color: hsl(0, 80%, 60%);\n  --error-color-highlight: hsl(0, 72%, 60%);\n  --error-color-shade: hsla(0, 72%, 51%, 0.1);\n\n  /* Core colors */\n  --text-color: var(--gray-300);\n  --secondary-text-color: var(--gray-400);\n  --tertiary-text-color: var(--gray-500);\n  --contrast-text-color: #fff;\n  --primary-text-color: hsl(241, 82%, 82%);\n\n  --link-color: var(--primary-text-color);\n  --secondary-link-color: hsla(241, 82%, 82%, 0.8);\n\n  --icon-color: var(--text-color);\n\n  --border-color: var(--contrast-30);\n  --secondary-border-color: var(--contrast-20);\n\n  --body-color: hsl(241, 15%, 14%);\n  --body-color-contrast: var(--contrast-10);\n\n  /* Focus */\n  --focus-outline: 2px solid hsl(241, 100%, 78%);\n  --focus-outline-offset: 2px;\n\n  /* Shadows */\n  --box-shadow-xs: none;\n  --box-shadow: none;\n  --box-shadow-lg: none;\n}\n\n:root {\n  --input-bg-color: var(--contrast-5);\n  --input-disabled-bg-color: var(--contrast-30);\n  --input-text-color: var(--text-color);\n  --input-hint-color: var(--secondary-text-color);\n  --input-border-color: var(--border-color);\n  --input-placeholder-color: var(--tertiary-text-color);\n  --input-box-shadow: var(--box-shadow-xs);\n\n  --checkbox-bg-color: var(--contrast-10);\n  --checkbox-checked-bg-color: var(--primary-color);\n  --checkbox-disabled-bg-color: var(--contrast-30);\n  --checkbox-border-color: var(--border-color);\n  --checkbox-icon-color: #fff;\n\n  --switch-bg-color: var(--contrast-10);\n  --switch-border-color: var(--border-color);\n  --switch-toggle-color: var(--text-color);\n}\n\n:root {\n  --btn-bg-color: var(--contrast-5);\n  --btn-hover-bg-color: var(--contrast-20);\n  --btn-border-color: var(--border-color);\n  --btn-text-color: var(--text-color);\n  --btn-icon-color: var(--icon-color);\n  --btn-font-weight: 400;\n  --btn-box-shadow: var(--box-shadow-xs);\n\n  --btn-primary-bg-color: var(--primary-color);\n  --btn-primary-hover-bg-color: var(--primary-color-highlight);\n  --btn-primary-text-color: var(--contrast-text-color);\n\n  --btn-success-bg-color: var(--success-color);\n  --btn-success-hover-bg-color: var(--success-color-highlight);\n  --btn-success-text-color: var(--contrast-text-color);\n\n  --btn-error-bg-color: var(--error-color);\n  --btn-error-hover-bg-color: var(--error-color-highlight);\n  --btn-error-text-color: var(--contrast-text-color);\n\n  --btn-link-text-color: var(--link-color);\n  --btn-link-hover-text-color: var(--link-color);\n}\n\n:root {\n  --modal-overlay-bg-color: hsla(229, 21%, 16%, 0.55);\n  --modal-container-bg-color: hsl(241, 20%, 20%);\n  --modal-container-border-color: var(--contrast-30);\n  --modal-border-radius: var(--border-radius-lg);\n  --modal-box-shadow: none;\n}\n\n:root {\n  --menu-bg-color: hsl(241, 20%, 20%);\n  --menu-border-color: var(--contrast-30);\n  --menu-border-radius: var(--border-radius);\n  --menu-box-shadow: none;\n  --menu-item-color: var(--text-color);\n  --menu-item-hover-color: var(--text-color);\n  --menu-item-bg-color: transparent;\n  --menu-item-hover-bg-color: var(--contrast-20);\n}\n\n:root {\n  --tab-color: var(--text-color);\n  --tab-hover-color: var(--primary-text-color);\n  --tab-active-color: var(--primary-text-color);\n  --tab-highlight-color: var(--primary-text-color);\n}\n\n:root {\n  --bookmark-title-color: var(--primary-text-color);\n  --bookmark-title-weight: 500;\n  --bookmark-description-color: var(--text-color);\n  --bookmark-description-weight: 400;\n  --bookmark-actions-color: var(--secondary-text-color);\n  --bookmark-actions-hover-color: var(--text-color);\n  --bookmark-actions-weight: 400;\n  --bulk-actions-bg-color: var(--contrast-5);\n}\n\n/* Try to force dark color scheme for all native elements (e.g. upload button\nin file inputs, native select dropdown). For the select dropdown some browsers\nignore this and use whatever users have configured in their system settings. */\n:root {\n  color-scheme: dark;\n}\n"
  },
  {
    "path": "bookmarks/styles/theme-light.css",
    "content": "@import \"theme/variables.css\";\n@import \"theme/_normalize.css\";\n@import \"theme/base.css\";\n@import \"theme/typography.css\";\n@import \"theme/asian.css\";\n@import \"theme/tables.css\";\n@import \"theme/buttons.css\";\n@import \"theme/forms.css\";\n@import \"theme/code.css\";\n@import \"theme/dropdowns.css\";\n@import \"theme/menus.css\";\n@import \"theme/badges.css\";\n@import \"theme/empty.css\";\n@import \"theme/modals.css\";\n@import \"theme/pagination.css\";\n@import \"theme/tabs.css\";\n@import \"theme/toasts.css\";\n@import \"theme/autocomplete.css\";\n@import \"theme/animations.css\";\n@import \"theme/utilities.css\";\n\n@import \"responsive.css\";\n@import \"layout.css\";\n@import \"components.css\";\n@import \"crud.css\";\n@import \"bookmark-details.css\";\n@import \"bookmark-form.css\";\n@import \"bookmark-page.css\";\n@import \"markdown.css\";\n@import \"reader-mode.css\";\n@import \"settings.css\";\n@import \"bundles.css\";\n@import \"tags.css\";\n@import \"auth.css\";\n"
  },
  {
    "path": "bookmarks/tasks.py",
    "content": "# Expose task modules to Huey Django extension\n# noinspection PyUnusedImports\nimport bookmarks.services.tasks  # noqa: F401\n"
  },
  {
    "path": "bookmarks/templates/admin/background_tasks.html",
    "content": "{% extends \"admin/base_site.html\" %}\n{% block content %}\n  <table style=\"width: 100%\">\n    <thead>\n      <tr>\n        <th>ID</th>\n        <th>Name</th>\n        <th>Args</th>\n        <th>Retries</th>\n      </tr>\n    </thead>\n    <tbody>\n      {% for task in tasks %}\n        <tr>\n          <td>{{ task.id }}</td>\n          <td>{{ task.name }}</td>\n          <td>{{ task.args }}</td>\n          <td>{{ task.retries }}</td>\n        </tr>\n      {% endfor %}\n    </tbody>\n  </table>\n  <p class=\"paginator\">\n    {% if page.paginator.num_pages > 1 %}\n      {% for page_number in page_range %}\n        {% if page_number == page.number %}\n          <span class=\"this-page\">{{ page_number }}</span>\n        {% elif page_number == '…' %}\n          <span>…</span>\n        {% else %}\n          <a href=\"?p={{ page_number }}\">{{ page_number }}</a>\n        {% endif %}\n      {% endfor %}\n      &nbsp;\n    {% endif %}\n    {{ page.paginator.count }} tasks\n  </p>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/bookmark_list.html",
    "content": "{% load static shared pagination %}\n{% if bookmark_list.is_empty %}\n  {% include 'bookmarks/empty_bookmarks.html' %}\n{% else %}\n  <section aria-label=\"Bookmark list\">\n    <ul class=\"bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}\"\n        role=\"list\"\n        tabindex=\"-1\"\n        style=\"--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }}\"\n        data-bookmarks-total=\"{{ bookmark_list.bookmarks_total }}\">\n      {% for bookmark_item in bookmark_list.items %}\n        <li data-bookmark-id=\"{{ bookmark_item.id }}\"\n            role=\"listitem\"\n            {% if bookmark_item.css_classes %}class=\"{{ bookmark_item.css_classes }}\"{% endif %}>\n          <div class=\"content\">\n            <div class=\"title\">\n              {% if not bookmark_list.is_preview %}\n                <label class=\"form-checkbox bulk-edit-checkbox\">\n                  <input type=\"checkbox\" name=\"bookmark_id\" value=\"{{ bookmark_item.id }}\">\n                  <i class=\"form-icon\"></i>\n                </label>\n              {% endif %}\n              {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}\n                <img class=\"favicon\" src=\"{% static bookmark_item.favicon_file %}\" alt=\"\">\n              {% endif %}\n              <a href=\"{{ bookmark_item.url }}\"\n                 target=\"{{ bookmark_list.link_target }}\"\n                 rel=\"noopener\">\n                <span>{{ bookmark_item.title }}</span>\n              </a>\n            </div>\n            {% if bookmark_list.show_url %}\n              <div class=\"url-path truncate\">\n                <a href=\"{{ bookmark_item.url }}\"\n                   target=\"{{ bookmark_list.link_target }}\"\n                   rel=\"noopener\"\n                   class=\"url-display\">{{ bookmark_item.url }}</a>\n              </div>\n            {% endif %}\n            {% if bookmark_list.description_display == 'inline' %}\n              <div class=\"description inline truncate\">\n                {% if bookmark_item.tags %}\n                  <span class=\"tags\">\n                    {% for tag in bookmark_item.tags %}<a href=\"?{{ tag.query_string }}\">#{{ tag.name }}</a>{% endfor %}\n                  </span>\n                {% endif %}\n                {% if bookmark_item.tags and bookmark_item.description %}|{% endif %}\n                {% if bookmark_item.description %}<span>{{ bookmark_item.description }}</span>{% endif %}\n              </div>\n            {% else %}\n              {% if bookmark_item.description %}<div class=\"description separate\">{{ bookmark_item.description }}</div>{% endif %}\n              {% if bookmark_item.tags %}\n                <div class=\"tags\">\n                  {% for tag in bookmark_item.tags %}<a href=\"?{{ tag.query_string }}\">#{{ tag.name }}</a>{% endfor %}\n                </div>\n              {% endif %}\n            {% endif %}\n            {% if bookmark_item.notes %}\n              <div class=\"notes\">\n                <div class=\"markdown\">{% markdown bookmark_item.notes %}</div>\n              </div>\n            {% endif %}\n            <div class=\"actions\">\n              {% if bookmark_item.display_date %}\n                {% if bookmark_item.snapshot_url %}\n                  <a href=\"{{ bookmark_item.snapshot_url }}\"\n                     title=\"{{ bookmark_item.snapshot_title }}\"\n                     target=\"{{ bookmark_list.link_target }}\"\n                     rel=\"noopener\">{{ bookmark_item.display_date }}</a>\n                {% else %}\n                  <span>{{ bookmark_item.display_date }}</span>\n                {% endif %}\n                {% if not bookmark_list.is_preview %}<span>|</span>{% endif %}\n              {% endif %}\n              {% if not bookmark_list.is_preview %}\n                {# View link is visible for both owned and shared bookmarks #}\n                {% if bookmark_list.show_view_action %}\n                  <a href=\"{{ bookmark_item.details_url }}\"\n                     class=\"view-action\"\n                     data-turbo-action=\"replace\"\n                     data-turbo-frame=\"details-modal\">View</a>\n                {% endif %}\n                {% if bookmark_item.is_editable %}\n                  {# Bookmark owner actions #}\n                  {% if bookmark_list.show_edit_action %}\n                    <a href=\"{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}\">Edit</a>\n                  {% endif %}\n                  {% if bookmark_list.show_archive_action %}\n                    {% if bookmark_item.is_archived %}\n                      <button type=\"submit\"\n                              name=\"unarchive\"\n                              value=\"{{ bookmark_item.id }}\"\n                              class=\"btn btn-link btn-sm\">Unarchive</button>\n                    {% else %}\n                      <button type=\"submit\"\n                              name=\"archive\"\n                              value=\"{{ bookmark_item.id }}\"\n                              class=\"btn btn-link btn-sm\">Archive</button>\n                    {% endif %}\n                  {% endif %}\n                  {% if bookmark_list.show_remove_action %}\n                    <button data-confirm\n                            type=\"submit\"\n                            name=\"remove\"\n                            value=\"{{ bookmark_item.id }}\"\n                            class=\"btn btn-link btn-sm\">Remove</button>\n                  {% endif %}\n                {% else %}\n                  {# Shared bookmark actions #}\n                  <span>Shared by\n                    <a href=\"?{% replace_query_param user=bookmark_item.owner.username %}\">{{ bookmark_item.owner.username }}</a>\n                  </span>\n                {% endif %}\n                {% if bookmark_item.has_extra_actions %}\n                  <div class=\"extra-actions\">\n                    <span class=\"hide-sm\">|</span>\n                    {% if bookmark_item.show_mark_as_read %}\n                      <button type=\"submit\"\n                              name=\"mark_as_read\"\n                              value=\"{{ bookmark_item.id }}\"\n                              class=\"btn btn-link btn-sm btn-icon\"\n                              data-confirm\n                              data-confirm-question=\"Mark as read?\">\n                        <svg width=\"16\" height=\"16\">\n                          <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#unread\"></use>\n                        </svg>\n                        Unread\n                      </button>\n                    {% endif %}\n                    {% if bookmark_item.show_unshare %}\n                      <button type=\"submit\"\n                              name=\"unshare\"\n                              value=\"{{ bookmark_item.id }}\"\n                              class=\"btn btn-link btn-sm btn-icon\"\n                              data-confirm\n                              data-confirm-question=\"Unshare?\">\n                        <svg width=\"16\" height=\"16\">\n                          <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#share\"></use>\n                        </svg>\n                        Shared\n                      </button>\n                    {% endif %}\n                    {% if bookmark_item.show_notes_button %}\n                      <button type=\"button\" class=\"btn btn-link btn-sm btn-icon toggle-notes\">\n                        <svg width=\"16\" height=\"16\">\n                          <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#note\"></use>\n                        </svg>\n                        Notes\n                      </button>\n                    {% endif %}\n                  </div>\n                {% endif %}\n              {% endif %}\n            </div>\n          </div>\n          {% if bookmark_list.show_preview_images %}\n            {% if bookmark_item.preview_image_file %}\n              <img class=\"preview-image\"\n                   src=\"{% static bookmark_item.preview_image_file %}\"\n                   alt=\"\"\n                   loading=\"lazy\" />\n            {% else %}\n              <div class=\"preview-image placeholder\">\n                <div class=\"img\" /></div>\n            {% endif %}\n          {% endif %}\n        </li>\n      {% endfor %}\n    </ul>\n    <div class=\"bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}\">\n      {% pagination bookmark_list.bookmarks_page %}\n    </div>\n  </section>\n{% endif %}\n<script>\n  document.dispatchEvent(new CustomEvent('bookmark-list-updated'));\n</script>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/bookmark_page.html",
    "content": "{% extends \"shared/layout.html\" %}\n{% load static shared bookmarks %}\n{% block content %}\n  <ld-bookmark-page {% if not bookmark_list.bulk_edit_enabled %}no-bulk-edit{% endif %}\n                    class=\"bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}\">\n    {# Bookmark list #}\n    <main class=\"main col-2\" aria-labelledby=\"main-heading\">\n      <div class=\"section-header {% if bookmark_list.bulk_edit_enabled %}mb-0{% endif %}\">\n        <h1 id=\"main-heading\">{{ bookmark_list.list_title }}</h1>\n        <div class=\"header-controls\">\n          {% bookmark_search bookmark_list.search mode=bookmark_list.search_mode %}\n          {% if bookmark_list.bulk_edit_enabled %}\n            <button class=\"btn hide-sm ml-2 bulk-edit-active-toggle\" title=\"Bulk edit\">\n              <svg width=\"20px\" height=\"20px\">\n                <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#bulk-edit\"></use>\n              </svg>\n            </button>\n          {% endif %}\n          <ld-filter-drawer-trigger>\n            <button class=\"btn ml-2\">Filters</button>\n          </ld-filter-drawer-trigger>\n        </div>\n      </div>\n      <form class=\"bookmark-actions\"\n            action=\"{{ bookmark_list.action_url|safe }}\"\n            method=\"post\"\n            autocomplete=\"off\">\n        {% csrf_token %}\n        {% if bookmark_list.bulk_edit_enabled %}\n          {% include 'bookmarks/bulk_edit_bar.html' %}\n        {% endif %}\n        <div id=\"bookmark-list-container\">{% include 'bookmarks/bookmark_list.html' %}</div>\n      </form>\n    </main>\n    {# Filters #}\n    <div class=\"side-panel col-1 hide-md\">\n      {% if bundles %}\n        {% include 'bookmarks/bundle_section.html' %}\n      {% endif %}\n      {% if user_list %}\n        {% include 'bookmarks/user_section.html' %}\n      {% endif %}\n      {% include 'bookmarks/tag_section.html' %}\n    </div>\n  </ld-bookmark-page>\n{% endblock %}\n{% block overlays %}\n  {% include 'bookmarks/details/modal.html' %}\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/bulk_edit_bar.html",
    "content": "{% load shared %}\n{% htmlmin %}\n<div class=\"bulk-edit-bar\">\n  <label class=\"form-checkbox bulk-edit-checkbox all\">\n    <input type=\"checkbox\">\n    <i class=\"form-icon\"></i>\n  </label>\n  <select name=\"bulk_action\" class=\"form-select select-sm\">\n    {% if not 'bulk_archive' in bookmark_list.bulk_edit_disabled_actions %}\n      <option value=\"bulk_archive\">Archive</option>\n    {% endif %}\n    {% if not 'bulk_unarchive' in bookmark_list.bulk_edit_disabled_actions %}\n      <option value=\"bulk_unarchive\">Unarchive</option>\n    {% endif %}\n    <option value=\"bulk_delete\">Delete</option>\n    <option value=\"bulk_tag\">Add tags</option>\n    <option value=\"bulk_untag\">Remove tags</option>\n    <option value=\"bulk_read\">Mark as read</option>\n    <option value=\"bulk_unread\">Mark as unread</option>\n    {% if request.user_profile.enable_sharing %}\n      <option value=\"bulk_share\">Share</option>\n      <option value=\"bulk_unshare\">Unshare</option>\n    {% endif %}\n    <option value=\"bulk_refresh\">Refresh from website</option>\n    {% if bookmark_list.snapshot_feature_enabled %}<option value=\"bulk_snapshot\">Create HTML snapshot</option>{% endif %}\n  </select>\n  <ld-tag-autocomplete input-name=\"bulk_tag_string\"\n                       input-placeholder=\"Tag names...\"\n                       variant=\"small\">\n  </ld-tag-autocomplete>\n  <button data-confirm\n          type=\"submit\"\n          name=\"bulk_execute\"\n          class=\"btn btn-link btn-sm\">\n    <span>Execute</span>\n  </button>\n  <label class=\"form-checkbox select-across d-none\">\n    <input type=\"checkbox\" name=\"bulk_select_across\">\n    <i class=\"form-icon\"></i>\n    All <span class=\"total\">{{ bookmark_list.bookmarks_total }}</span> bookmarks\n  </label>\n</div>\n{% endhtmlmin %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/bundle_section.html",
    "content": "{% load static %}\n{% if not request.user_profile.hide_bundles %}\n  <section aria-labelledby=\"bundles-heading\">\n    <div class=\"section-header no-wrap\">\n      <h2 id=\"bundles-heading\">Bundles</h2>\n      <ld-dropdown class=\"dropdown dropdown-right ml-auto\">\n        <button class=\"btn btn-noborder dropdown-toggle\" aria-label=\"Bundles menu\">\n          <svg width=\"20\" height=\"20\">\n            <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#menu\"></use>\n          </svg>\n        </button>\n        <ul class=\"menu\" role=\"list\" tabindex=\"-1\">\n          <li class=\"menu-item\">\n            <a href=\"{% url 'linkding:bundles.index' %}\" class=\"menu-link\">Manage bundles</a>\n          </li>\n          {% if bookmark_list.search.q %}\n            <li class=\"menu-item\">\n              <a href=\"{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}\"\n                 class=\"menu-link\">Create\n              bundle from search</a>\n            </li>\n          {% endif %}\n        </ul>\n      </ld-dropdown>\n    </div>\n    <ul class=\"bundle-menu\">\n      {% for bundle in bundles.bundles %}\n        <li class=\"bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}\">\n          <a href=\"?bundle={{ bundle.id }}\">{{ bundle.name }}</a>\n        </li>\n      {% endfor %}\n    </ul>\n  </section>\n{% endif %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/close.html",
    "content": "{% extends \"shared/layout.html\" %}\n{% block content %}\n  <script type=\"application/javascript\">\n    window.close()\n  </script>\n  <p>You can now close this window.</p>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/details/asset_icon.html",
    "content": "{% if asset.content_type == 'text/html' %}\n  <svg xmlns=\"http://www.w3.org/2000/svg\"\n       width=\"24\"\n       height=\"24\"\n       viewBox=\"0 0 24 24\"\n       fill=\"none\"\n       stroke=\"currentColor\"\n       stroke-width=\"2\"\n       stroke-linecap=\"round\"\n       stroke-linejoin=\"round\">\n    <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n    <path d=\"M14 3v4a1 1 0 0 0 1 1h4\" />\n    <path d=\"M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4\" />\n    <path d=\"M2 21v-6\" />\n    <path d=\"M5 15v6\" />\n    <path d=\"M2 18h3\" />\n    <path d=\"M20 15v6h2\" />\n    <path d=\"M13 21v-6l2 3l2 -3v6\" />\n    <path d=\"M7.5 15h3\" />\n    <path d=\"M9 15v6\" />\n  </svg>\n{% elif asset.content_type == 'application/pdf' %}\n  <svg xmlns=\"http://www.w3.org/2000/svg\"\n       width=\"24\"\n       height=\"24\"\n       viewBox=\"0 0 24 24\"\n       fill=\"none\"\n       stroke=\"currentColor\"\n       stroke-width=\"2\"\n       stroke-linecap=\"round\"\n       stroke-linejoin=\"round\">\n    <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n    <path d=\"M14 3v4a1 1 0 0 0 1 1h4\" />\n    <path d=\"M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4\" />\n    <path d=\"M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6\" />\n    <path d=\"M17 18h2\" />\n    <path d=\"M20 15h-3v6\" />\n    <path d=\"M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z\" />\n  </svg>\n{% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %}\n  <svg xmlns=\"http://www.w3.org/2000/svg\"\n       width=\"24\"\n       height=\"24\"\n       viewBox=\"0 0 24 24\"\n       fill=\"none\"\n       stroke=\"currentColor\"\n       stroke-width=\"2\"\n       stroke-linecap=\"round\"\n       stroke-linejoin=\"round\">\n    <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n    <path d=\"M15 8h.01\" />\n    <path d=\"M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z\" />\n    <path d=\"M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5\" />\n    <path d=\"M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3\" />\n  </svg>\n{% else %}\n  <svg xmlns=\"http://www.w3.org/2000/svg\"\n       width=\"24\"\n       height=\"24\"\n       viewBox=\"0 0 24 24\"\n       fill=\"none\"\n       stroke=\"currentColor\"\n       stroke-width=\"2\"\n       stroke-linecap=\"round\"\n       stroke-linejoin=\"round\">\n    <path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" />\n    <path d=\"M14 3v4a1 1 0 0 0 1 1h4\" />\n    <path d=\"M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z\" />\n  </svg>\n{% endif %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/details/assets.html",
    "content": "<div>\n  {% if details.assets %}\n    <div class=\"item-list assets\">\n      {% for asset in details.assets %}\n        <div class=\"list-item\" data-asset-id=\"{{ asset.id }}\">\n          <div class=\"list-item-icon {{ asset.icon_classes }}\">{% include 'bookmarks/details/asset_icon.html' %}</div>\n          <div class=\"list-item-text {{ asset.text_classes }}\">\n            <span class=\"truncate\">\n              {{ asset.display_name }}\n              {% if asset.status == 'pending' %}(queued){% endif %}\n              {% if asset.status == 'failure' %}(failed){% endif %}\n            </span>\n            {% if asset.file_size %}<span class=\"filesize\">{{ asset.file_size|filesizeformat }}</span>{% endif %}\n          </div>\n          <div class=\"list-item-actions\">\n            {% if asset.file %}\n              <a class=\"btn btn-link\"\n                 href=\"{% url 'linkding:assets.view' asset.id %}\"\n                 target=\"_blank\">View</a>\n            {% endif %}\n            {% if details.is_editable %}\n              <button data-confirm\n                      type=\"submit\"\n                      name=\"remove_asset\"\n                      value=\"{{ asset.id }}\"\n                      class=\"btn btn-link\">Remove</button>\n            {% endif %}\n          </div>\n        </div>\n      {% endfor %}\n    </div>\n  {% endif %}\n  {% if details.is_editable %}\n    <div class=\"assets-actions\">\n      {% if details.snapshots_enabled %}\n        <button type=\"submit\"\n                name=\"create_html_snapshot\"\n                value=\"{{ details.bookmark.id }}\"\n                class=\"btn btn-sm\"\n                {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot</button>\n      {% endif %}\n      {% if details.uploads_enabled %}\n        <ld-upload-button>\n          <button id=\"upload-asset\"\n                  name=\"upload_asset\"\n                  value=\"{{ details.bookmark.id }}\"\n                  type=\"submit\"\n                  class=\"btn btn-sm\">Upload file</button>\n          <input id=\"upload-asset-file\"\n                 name=\"upload_asset_file\"\n                 type=\"file\"\n                 class=\"d-hide\">\n        </ld-upload-button>\n      {% endif %}\n    </div>\n  {% endif %}\n</div>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/details/form.html",
    "content": "{% load static %}\n{% load shared %}\n<ld-form>\n  <form action=\"{{ details.action_url }}\"\n        method=\"post\"\n        enctype=\"multipart/form-data\">\n    {% csrf_token %}\n    <input type=\"hidden\" name=\"update_state\" value=\"{{ details.bookmark.id }}\">\n    <div class=\"weblinks\">\n      <a class=\"weblink\"\n         href=\"{{ details.bookmark.url }}\"\n         rel=\"noopener\"\n         target=\"{{ details.profile.bookmark_link_target }}\">\n        {% if details.show_link_icons %}\n          <img class=\"favicon\"\n               src=\"{% static details.bookmark.favicon_file %}\"\n               alt=\"\">\n        {% endif %}\n        <span>{{ details.bookmark.url }}</span>\n      </a>\n      {% if details.latest_snapshot %}\n        <a class=\"weblink\"\n           href=\"{% url 'linkding:assets.read' details.latest_snapshot.id %}\"\n           target=\"{{ details.profile.bookmark_link_target }}\">\n          {% if details.show_link_icons %}\n            <svg class=\"favicon\">\n              <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#unread\"></use>\n            </svg>\n          {% endif %}\n          <span>Reader mode</span>\n        </a>\n      {% endif %}\n      {% if details.web_archive_snapshot_url %}\n        <a class=\"weblink\"\n           href=\"{{ details.web_archive_snapshot_url }}\"\n           target=\"{{ details.profile.bookmark_link_target }}\">\n          {% if details.show_link_icons %}\n            <svg class=\"favicon\"\n                 viewBox=\"0 0 76 86\"\n                 xmlns=\"http://www.w3.org/2000/svg\">\n              <path d=\"m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z\" fill=\"currentColor\" fill-rule=\"evenodd\" />\n            </svg>\n          {% endif %}\n          <span>Internet Archive</span>\n        </a>\n      {% endif %}\n    </div>\n    {% if details.preview_image_enabled and details.bookmark.preview_image_file %}\n      <div class=\"preview-image\">\n        <img src=\"{% static details.bookmark.preview_image_file %}\" alt=\"\" />\n      </div>\n    {% endif %}\n    <div class=\"sections grid columns-2 columns-sm-1 gap-0\">\n      {% if details.is_editable %}\n        <section class=\"status col-2\">\n          <h3>Status</h3>\n          <div class=\"d-flex\" style=\"gap: .8rem\">\n            <div class=\"form-group\">\n              <label class=\"form-checkbox\">\n                <input data-submit-on-change\n                       type=\"checkbox\"\n                       name=\"is_archived\"\n                       {% if details.bookmark.is_archived %}checked{% endif %}>\n                <i class=\"form-icon\"></i> Archived\n              </label>\n            </div>\n            <div class=\"form-group\">\n              <label class=\"form-checkbox\">\n                <input data-submit-on-change\n                       type=\"checkbox\"\n                       name=\"unread\"\n                       {% if details.bookmark.unread %}checked{% endif %}>\n                <i class=\"form-icon\"></i> Unread\n              </label>\n            </div>\n            {% if details.profile.enable_sharing %}\n              <div class=\"form-group\">\n                <label class=\"form-checkbox\">\n                  <input data-submit-on-change\n                         type=\"checkbox\"\n                         name=\"shared\"\n                         {% if details.bookmark.shared %}checked{% endif %}>\n                  <i class=\"form-icon\"></i> Shared\n                </label>\n              </div>\n            {% endif %}\n          </div>\n        </section>\n      {% endif %}\n      <section class=\"files col-2\">\n        <h3>Files</h3>\n        <div>{% include 'bookmarks/details/assets.html' %}</div>\n      </section>\n      {% if details.bookmark.tag_names %}\n        <section class=\"tags col-1\">\n          <h3 id=\"details-modal-tags-title\">Tags</h3>\n          <div>\n            {% for tag in details.tags %}\n              <a href=\"{% url 'linkding:bookmarks.index' %}?{{ tag.query_string }}\">#{{ tag.name }}</a>\n            {% endfor %}\n          </div>\n        </section>\n      {% endif %}\n      <section class=\"date-added col-1\">\n        <h3>Date added</h3>\n        <div>\n          <span>{{ details.bookmark.date_added }}</span>\n        </div>\n      </section>\n      {% if details.bookmark.resolved_description %}\n        <section class=\"description col-2\">\n          <h3>Description</h3>\n          <div>{{ details.bookmark.resolved_description }}</div>\n        </section>\n      {% endif %}\n      {% if details.bookmark.notes %}\n        <section class=\"notes col-2\">\n          <h3>Notes</h3>\n          <div class=\"markdown\">{% markdown details.bookmark.notes %}</div>\n        </section>\n      {% endif %}\n    </div>\n  </form>\n</ld-form>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/details/modal.html",
    "content": "<turbo-frame id=\"details-modal\" target=\"_top\">\n{% if details %}\n  <ld-details-modal class=\"modal active bookmark-details\"\n                    data-bookmark-id=\"{{ details.bookmark.id }}\"\n                    data-close-url=\"{{ details.close_url }}\"\n                    data-turbo-frame=\"details-modal\">\n    <div class=\"modal-overlay\" data-close-modal></div>\n    <div class=\"modal-container\" role=\"dialog\" aria-modal=\"true\">\n      {% include 'shared/modal_header.html' with title=details.bookmark.resolved_title %}\n      <div class=\"modal-body\">{% include 'bookmarks/details/form.html' %}</div>\n      {% if details.is_editable %}\n        <div class=\"modal-footer\">\n          <div class=\"actions\">\n            <div class=\"left-actions\">\n              <a class=\"btn btn-wide\"\n                 href=\"{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}\">Edit</a>\n            </div>\n            <div class=\"right-actions\">\n              <form action=\"{{ details.delete_url }}\"\n                    method=\"post\"\n                    data-turbo-action=\"replace\">\n                {% csrf_token %}\n                <input type=\"hidden\" name=\"disable_turbo\" value=\"true\">\n                <button data-confirm\n                        class=\"btn btn-error btn-wide\"\n                        type=\"submit\"\n                        name=\"remove\"\n                        value=\"{{ details.bookmark.id }}\">Delete</button>\n              </form>\n            </div>\n          </div>\n        </div>\n      {% endif %}\n    </div>\n  </ld-details-modal>\n{% endif %}\n</turbo-frame>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/edit.html",
    "content": "{% extends 'shared/layout.html' %}\n{% block head %}\n  {% with page_title=\"Edit bookmark - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <div class=\"bookmarks-form-page\">\n    <main aria-labelledby=\"main-heading\">\n      <div class=\"section-header\">\n        <h1 id=\"main-heading\">Edit bookmark</h1>\n      </div>\n      <ld-form data-submit-on-ctrl-enter>\n        <form action=\"{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}\"\n              method=\"post\"\n              novalidate>\n          {% include 'bookmarks/form.html' %}\n        </form>\n      </ld-form>\n    </main>\n  </div>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/empty_bookmarks.html",
    "content": "<div class=\"empty mt-4\">\n  {% if not bookmark_list.query_is_valid %}\n    <p class=\"empty-title h5\">Invalid search query</p>\n    <p class=\"empty-subtitle\">\n      The search query you entered is not valid. Common reasons are unclosed parentheses or a logical operator (AND, OR,\n      NOT) without operands. The error message from the parser is: \"{{ bookmark_list.query_error_message }}\".\n    </p>\n  {% else %}\n    <p class=\"empty-title h5\">You have no bookmarks yet</p>\n    <p class=\"empty-subtitle\">\n      You can get started by <a href=\"{% url 'linkding:bookmarks.new' %}\">adding</a> bookmarks,\n      <a href=\"{% url 'linkding:settings.general' %}\">importing</a> your existing bookmarks or configuring the\n      <a href=\"{% url 'linkding:settings.integrations' %}\">browser extension</a> or the <a href=\"{% url 'linkding:settings.integrations' %}\">bookmarklet</a>.\n    </p>\n  {% endif %}\n</div>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/form.html",
    "content": "{% load static %}\n{% load shared %}\n<div class=\"bookmarks-form\">\n  {% csrf_token %}\n  {{ form.auto_close }}\n  <div class=\"form-group\">\n    {% formlabel form.url \"URL\" %}\n    <div class=\"has-icon-right\">\n      {% formfield form.url autofocus=True %}\n      <i class=\"form-icon loading\"></i>\n    </div>\n    {{ form.url.errors }}\n    <div class=\"form-input-hint bookmark-exists\">\n      This URL is already bookmarked.\n      The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.\n    </div>\n  </div>\n  <div class=\"form-group\">\n    {% formlabel form.tag_string \"Tags\" %}\n    {% formfield form.tag_string has_help=True %}\n    {% formhelp form.tag_string %}\n      Enter any number of tags separated by space and <strong>without</strong> the hash (#).\n      If a tag does not exist it will be automatically created.\n    {% endformhelp %}\n    <div class=\"form-input-hint auto-tags\"></div>\n  </div>\n  <div class=\"form-group\">\n    <div class=\"d-flex justify-between align-baseline\">\n      {% formlabel form.title \"Title\" %}\n      <div class=\"flex\">\n        <button id=\"refresh-button\" class=\"btn btn-link suffix-button\" type=\"button\">Refresh from website</button>\n        <ld-clear-button data-for=\"{{ form.title.id_for_label }}\">\n          <button class=\"ml-2 btn btn-link suffix-button\" type=\"button\">Clear</button>\n        </ld-clear-button>\n      </div>\n    </div>\n    {% formfield form.title %}\n  </div>\n  <div class=\"form-group\">\n    <div class=\"d-flex justify-between align-baseline\">\n      {% formlabel form.description \"Description\" %}\n      <ld-clear-button data-for=\"{{ form.description.id_for_label }}\">\n        <button class=\"btn btn-link suffix-button\" type=\"button\">Clear</button>\n      </ld-clear-button>\n    </div>\n    {% formfield form.description rows=\"3\" %}\n  </div>\n  <div class=\"form-group\">\n    <details class=\"notes\"{% if form.has_notes %} open{% endif %}>\n      <summary>\n        <span class=\"form-label d-inline-block\">Notes</span>\n      </summary>\n      <label for=\"{{ form.notes.id_for_label }}\" class=\"text-assistive\">Notes</label>\n      {% formfield form.notes rows=\"8\" has_help=True %}\n      {% formhelp form.notes %}\n        Additional notes, supports Markdown.\n      {% endformhelp %}\n    </details>\n  </div>\n  <div class=\"form-group\">\n    {% formfield form.unread label=\"Mark as unread\" has_help=True %}\n    {% formhelp form.unread %}\n      Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.\n    {% endformhelp %}\n  </div>\n  {% if request.user_profile.enable_sharing %}\n    <div class=\"form-group\">\n      {% formfield form.shared label=\"Share\" has_help=True %}\n      {% formhelp form.shared %}\n        {% if request.user_profile.enable_public_sharing %}\n          Share this bookmark with other registered users and anonymous users.\n        {% else %}\n          Share this bookmark with other registered users.\n        {% endif %}\n      {% endformhelp %}\n    </div>\n  {% endif %}\n  <div class=\"divider\"></div>\n  <div class=\"form-group d-flex justify-between\">\n    {% if form.is_auto_close %}\n      <input type=\"submit\" value=\"Save and close\" class=\"btn btn-primary btn-wide\">\n    {% else %}\n      <input type=\"submit\"\n             value=\"Save\"\n             class=\"btn btn-primary btn btn-primary btn-wide\">\n    {% endif %}\n    <a href=\"{{ return_url }}\" class=\"btn\">Cancel</a>\n  </div>\n  <script type=\"application/javascript\">\n    /**\n     * - Pre-fill title and description with metadata from website as soon as URL changes\n     * - Show hint if URL is already bookmarked\n     */\n    (function init() {\n      const urlInput = document.getElementById('{{ form.url.id_for_label }}');\n      const titleInput = document.getElementById('{{ form.title.id_for_label }}');\n      const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');\n      const notesDetails = document.querySelector('form details.notes');\n      const notesInput = document.getElementById('{{ form.notes.id_for_label }}');\n      const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');\n      const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');\n      const refreshButton = document.getElementById('refresh-button');\n      const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');\n      const editedBookmarkId = parseInt('{{ form.instance.id|default:0 }}');\n      let isTitleModified = !!titleInput.value;\n      let isDescriptionModified = !!descriptionInput.value;\n\n      function toggleLoadingIcon(input, show) {\n        const icon = input.parentNode.querySelector('i.form-icon');\n        icon.style['visibility'] = show ? 'visible' : 'hidden';\n      }\n\n      function updateInput(input, value) {\n        if (!input) {\n          return;\n        }\n        input.value = value;\n        input.dispatchEvent(new Event('value-changed'));\n      }\n\n      function updateCheckbox(input, value) {\n        if (!input) {\n          return;\n        }\n        input.checked = value;\n      }\n\n      function checkUrl() {\n        if (!urlInput.value) {\n          return;\n        }\n\n        toggleLoadingIcon(urlInput, true);\n\n        const websiteUrl = encodeURIComponent(urlInput.value);\n        const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`;\n        fetch(requestUrl)\n          .then(response => response.json())\n          .then(data => {\n            const metadata = data.metadata;\n            toggleLoadingIcon(urlInput, false);\n\n            // Display hint if URL is already bookmarked\n            const existingBookmark = data.bookmark;\n            bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';\n            refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';\n\n            // Prefill form with existing bookmark data\n            if (existingBookmark) {\n              // Workaround: tag input will be replaced by tag autocomplete, so\n              // defer getting the input until we need it\n              const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');\n\n              bookmarkExistsHint.style['display'] = 'block';\n              notesDetails.open = !!existingBookmark.notes;\n              updateInput(titleInput, existingBookmark.title);\n              updateInput(descriptionInput, existingBookmark.description);\n              updateInput(notesInput, existingBookmark.notes);\n              updateInput(tagsInput, existingBookmark.tag_names.join(\" \"));\n              updateCheckbox(unreadCheckbox, existingBookmark.unread);\n              updateCheckbox(sharedCheckbox, existingBookmark.shared);\n            } else {\n              // Update title and description with website metadata, unless they have been modified\n              if (!isTitleModified) {\n                updateInput(titleInput, metadata.title);\n              }\n              if (!isDescriptionModified) {\n                updateInput(descriptionInput, metadata.description);\n              }\n            }\n\n            // Preview auto tags\n            const autoTags = data.auto_tags;\n            const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');\n\n            if (autoTags.length > 0) {\n              autoTags.sort();\n              autoTagsHint.style['display'] = 'block';\n              autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(\" \")}`;\n            } else {\n              autoTagsHint.style['display'] = 'none';\n            }\n          });\n      }\n\n      function refreshMetadata() {\n        if (!urlInput.value) {\n          return;\n        }\n\n        toggleLoadingIcon(urlInput, true);\n\n        const websiteUrl = encodeURIComponent(urlInput.value);\n        const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;\n\n        fetch(requestUrl)\n          .then(response => response.json())\n          .then(data => {\n            const metadata = data.metadata;\n            const existingBookmark = data.bookmark;\n            toggleLoadingIcon(urlInput, false);\n\n            if (metadata.title && metadata.title !== existingBookmark?.title) {\n              titleInput.value = metadata.title;\n              titleInput.classList.add(\"modified\");\n            }\n\n            if (metadata.description && metadata.description !== existingBookmark?.description) {\n              descriptionInput.value = metadata.description;\n              descriptionInput.classList.add(\"modified\");\n            }\n          });\n      }\n\n      refreshButton.addEventListener('click', refreshMetadata);\n\n      // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark\n      if (!editedBookmarkId) {\n        checkUrl();\n        urlInput.addEventListener('input', checkUrl);\n        titleInput.addEventListener('input', () => {\n          isTitleModified = true;\n        });\n        descriptionInput.addEventListener('input', () => {\n          isDescriptionModified = true;\n        });\n      } else {\n        refreshButton.style['display'] = 'inline-block';\n      }\n    })();\n  </script>\n</div>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/new.html",
    "content": "{% extends 'shared/layout.html' %}\n{% block head %}\n  {% with page_title=\"New bookmark - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <div class=\"bookmarks-form-page\">\n    <main aria-labelledby=\"main-heading\">\n      <div class=\"section-header\">\n        <h1 id=\"main-heading\">New bookmark</h1>\n      </div>\n      <ld-form data-submit-on-ctrl-enter>\n        <form action=\"{% url 'linkding:bookmarks.new' %}\" method=\"post\" novalidate>\n          {% include 'bookmarks/form.html' %}\n        </form>\n      </ld-form>\n    </main>\n  </div>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/read.html",
    "content": "{% load static %}\n<!DOCTYPE html>\n<html lang=\"en\" class=\"reader-mode\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <title>Reader view</title>\n    <meta name=\"viewport\"\n          content=\"width=device-width, initial-scale=1.0, minimal-ui\">\n    {# Include specific theme variant based on user profile setting #}\n    {% if request.user_profile.theme == 'light' %}\n      <link href=\"{% static 'theme-light.css' %}?v={{ app_version }}\"\n            rel=\"stylesheet\"\n            type=\"text/css\" />\n      <meta name=\"theme-color\" content=\"#5856e0\">\n    {% elif request.user_profile.theme == 'dark' %}\n      <link href=\"{% static 'theme-dark.css' %}?v={{ app_version }}\"\n            rel=\"stylesheet\"\n            type=\"text/css\" />\n      <meta name=\"theme-color\" content=\"#161822\">\n    {% else %}\n      {# Use auto theme as fallback #}\n      <link href=\"{% static 'theme-dark.css' %}?v={{ app_version }}\"\n            rel=\"stylesheet\"\n            type=\"text/css\"\n            media=\"(prefers-color-scheme: dark)\" />\n      <link href=\"{% static 'theme-light.css' %}?v={{ app_version }}\"\n            rel=\"stylesheet\"\n            type=\"text/css\"\n            media=\"(prefers-color-scheme: light)\" />\n      <meta name=\"theme-color\"\n            media=\"(prefers-color-scheme: dark)\"\n            content=\"#161822\">\n      <meta name=\"theme-color\"\n            media=\"(prefers-color-scheme: light)\"\n            content=\"#5856e0\">\n    {% endif %}\n    {% if request.user_profile.custom_css %}\n      <link href=\"{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}\"\n            rel=\"stylesheet\"\n            type=\"text/css\" />\n    {% endif %}\n  </head>\n  <body>\n    <template id=\"content\">{{ content|safe }}</template>\n    <script src=\"{% static 'vendor/Readability.js' %}\"\n            type=\"application/javascript\"></script>\n    <script type=\"application/javascript\">\n      function estimateReadingTime(charCount, wordsPerMinute) {\n        const avgWordLength = 5;\n        const totalWords = charCount / avgWordLength;\n        return Math.ceil(totalWords / wordsPerMinute);\n      }\n\n      function postProcess(articleContent) {\n        articleContent.querySelectorAll('table').forEach(table => {\n          table.classList.add('table');\n        });\n      }\n\n      function makeReadable() {\n        const content = document.getElementById('content');\n        const contentHtml = content.innerHTML;\n        const dom = new DOMParser().parseFromString(contentHtml, 'text/html');\n        const article = new Readability(dom).parse();\n\n        document.title = article.title;\n\n        const container = document.createElement('div');\n        container.classList.add('container');\n\n        const articleTitle = document.createElement('h1');\n        articleTitle.textContent = article.title;\n        container.append(articleTitle);\n\n        const byline = [article.byline, article.siteName].filter(Boolean);\n        if (byline.length > 0) {\n          const articleByline = document.createElement('p');\n          articleByline.textContent = byline.join(' | ');\n          articleByline.classList.add('byline');\n          container.append(articleByline);\n        }\n\n        if (article.length) {\n          const minTime = estimateReadingTime(article.length, 225);\n          const maxTime = estimateReadingTime(article.length, 175);\n\n          const articleReadingTime = document.createElement('p');\n          articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;\n          articleReadingTime.classList.add('reading-time');\n          container.append(articleReadingTime);\n        }\n\n        const divider = document.createElement('hr');\n        container.append(divider);\n\n        const articleContent = document.createElement('div');\n        articleContent.innerHTML = article.content;\n        postProcess(articleContent);\n        container.append(articleContent);\n\n        content.replaceWith(container);\n      }\n      makeReadable();\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/search.html",
    "content": "{% load static shared %}\n<div class=\"search-container\">\n  <form id=\"search\" action=\"\" method=\"get\" role=\"search\">\n    <ld-search-autocomplete input-name=\"q\"\n                            input-placeholder=\"Search for words or #tags\"\n                            input-value=\"{{ search.q|default_if_none:'' }}\"\n                            target=\"{{ request.user_profile.bookmark_link_target }}\"\n                            mode=\"{{ mode }}\"\n                            user=\"{{ search.user }}\"\n                            shared=\"{{ search.shared }}\"\n                            unread=\"{{ search.unread }}\">\n    </ld-search-autocomplete>\n    <input type=\"submit\" value=\"Search\" class=\"d-none\">\n    {% for hidden_field in search_form.hidden_fields %}{{ hidden_field }}{% endfor %}\n  </form>\n  <ld-dropdown class=\"search-options dropdown dropdown-right\">\n    <button type=\"button\"\n            aria-label=\"Search preferences\"\n            class=\"btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}\">\n      <svg width=\"20\" height=\"20\">\n        <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#preferences\"></use>\n      </svg>\n    </button>\n    <div class=\"menu\" tabindex=\"0\">\n      <form id=\"search_preferences\" action=\"\" method=\"post\">\n        {% csrf_token %}\n        {% if 'sort' in preferences_form.editable_fields %}\n          <div class=\"form-group\">\n            <label for=\"{{ preferences_form.sort.id_for_label }}\"\n                   class=\"form-label{% if 'sort' in search.modified_params %} text-bold{% endif %}\">Sort by</label>\n            {% formfield preferences_form.sort class='select-sm' %}\n          </div>\n        {% endif %}\n        {% if 'shared' in preferences_form.editable_fields %}\n          <div class=\"form-group radio-group\"\n               role=\"radiogroup\"\n               aria-labelledby=\"search-shared-label\">\n            <label id=\"search-shared-label\"\n                   class=\"form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}\">\n              Shared filter\n            </label>\n            {% for radio in preferences_form.shared %}\n              <label for=\"{{ radio.id_for_label }}\" class=\"form-radio form-inline\">\n                {{ radio.tag }}\n                <i class=\"form-icon\"></i>\n                {{ radio.choice_label }}\n              </label>\n            {% endfor %}\n          </div>\n        {% endif %}\n        {% if 'unread' in preferences_form.editable_fields %}\n          <div class=\"form-group radio-group\"\n               role=\"radiogroup\"\n               aria-labelledby=\"search-unread-label\">\n            <label id=\"search-unread-label\"\n                   class=\"form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}\">\n              Unread filter\n            </label>\n            {% for radio in preferences_form.unread %}\n              <label for=\"{{ radio.id_for_label }}\" class=\"form-radio form-inline\">\n                {{ radio.tag }}\n                <i class=\"form-icon\"></i>\n                {{ radio.choice_label }}\n              </label>\n            {% endfor %}\n          </div>\n        {% endif %}\n        <div class=\"actions\">\n          <button type=\"submit\" class=\"btn btn-sm btn-primary\" name=\"apply\">Apply</button>\n          {% if request.user.is_authenticated %}\n            <button type=\"submit\" class=\"btn btn-sm\" name=\"save\">Save as default</button>\n          {% endif %}\n        </div>\n        {% for hidden_field in preferences_form.hidden_fields %}{{ hidden_field }}{% endfor %}\n      </form>\n    </div>\n  </ld-dropdown>\n</div>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/tag_cloud.html",
    "content": "{% load shared %}\n{% htmlmin %}\n<div class=\"tag-cloud\">\n  {% if tag_cloud.has_selected_tags %}\n    <p class=\"selected-tags\">\n      {% for tag in tag_cloud.selected_tags %}\n        <a href=\"?{{ tag.query_string }}\" class=\"text-bold mr-2\">\n          <span>-{{ tag.name }}</span>\n        </a>\n      {% endfor %}\n    </p>\n  {% endif %}\n  <div class=\"unselected-tags\">\n    {% for group in tag_cloud.groups %}\n      <p class=\"group\">\n        {% for tag in group.tags %}\n          {# Highlight first char of first tag in group if grouping is enabled #}\n          {% if group.highlight_first_char and forloop.counter == 1 %}\n            <a href=\"?{{ tag.query_string }}\" class=\"mr-2\" data-is-tag-item>\n              <span class=\"highlight-char\">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>\n            </a>\n          {% else %}\n            {# Render tags normally #}\n            <a href=\"?{{ tag.query_string }}\" class=\"mr-2\" data-is-tag-item>\n              <span>{{ tag.name }}</span>\n            </a>\n          {% endif %}\n        {% endfor %}\n      </p>\n    {% endfor %}\n  </div>\n</div>\n{% endhtmlmin %}\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/tag_section.html",
    "content": "{% load static %}\n<section aria-labelledby=\"tags-heading\">\n  <div class=\"section-header no-wrap\">\n    <h2 id=\"tags-heading\">Tags</h2>\n    {% if user.is_authenticated %}\n      <ld-dropdown class=\"dropdown dropdown-right ml-auto\">\n        <button class=\"btn btn-noborder dropdown-toggle\" aria-label=\"Tags menu\">\n          <svg width=\"20\" height=\"20\">\n            <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#menu\"></use>\n          </svg>\n        </button>\n        <ul class=\"menu\" role=\"list\" tabindex=\"-1\">\n          <li class=\"menu-item\">\n            <a href=\"{% url 'linkding:tags.index' %}\" class=\"menu-link\">Manage tags</a>\n          </li>\n        </ul>\n      </ld-dropdown>\n    {% endif %}\n  </div>\n  <div id=\"tag-cloud-container\">{% include 'bookmarks/tag_cloud.html' %}</div>\n</section>\n"
  },
  {
    "path": "bookmarks/templates/bookmarks/user_section.html",
    "content": "{% load shared %}\n<section aria-labelledby=\"user-heading\">\n  <div class=\"section-header\">\n    <h2 id=\"user-heading\">User</h2>\n  </div>\n  <div>\n    <ld-form data-form-reset>\n      <form id=\"user-select\" action=\"\" method=\"get\">\n        {% for hidden_field in user_list.form.hidden_fields %}{{ hidden_field }}{% endfor %}\n        <div class=\"form-group\">\n          <div class=\"d-flex\">\n            {% formfield user_list.form.user data_submit_on_change=\"\" %}\n            <noscript>\n              <button type=\"submit\" class=\"btn btn-link ml-2\">Apply</button>\n            </noscript>\n          </div>\n        </div>\n      </form>\n    </ld-form>\n    <br>\n  </div>\n</section>\n"
  },
  {
    "path": "bookmarks/templates/bundles/edit.html",
    "content": "{% extends 'shared/layout.html' %}\n{% block head %}\n  {% with page_title=\"Edit bundle - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <div class=\"bundles-editor-page grid columns-md-1\">\n    <main aria-labelledby=\"main-heading\">\n      <div class=\"section-header\">\n        <h1 id=\"main-heading\">Edit bundle</h1>\n      </div>\n      {% include 'shared/messages.html' %}\n      <form id=\"bundle-form\"\n            action=\"{% url 'linkding:bundles.edit' bundle.id %}\"\n            method=\"post\"\n            novalidate>\n        {% csrf_token %}\n        {% include 'bundles/form.html' %}\n      </form>\n    </main>\n    <aside class=\"col-2\" aria-labelledby=\"preview-heading\">\n      <div class=\"section-header\">\n        <h2 id=\"preview-heading\">Preview</h2>\n      </div>\n      {% include 'bundles/preview.html' %}\n    </aside>\n  </div>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bundles/form.html",
    "content": "{% load shared %}\n<div class=\"form-group\">\n  {% formlabel form.name \"Name\" %}\n  {% formfield form.name %}\n  {{ form.name.errors }}\n</div>\n<div class=\"form-group\">\n  {% formlabel form.search \"Search terms\" %}\n  {% formfield form.search has_help=True %}\n  {{ form.search.errors }}\n  {% formhelp form.search %}\n    All of these search terms must be present in a bookmark to match.\n  {% endformhelp %}\n</div>\n<div class=\"form-group\">\n  {% formlabel form.any_tags \"Tags\" %}\n  {% formfield form.any_tags has_help=True %}\n  {% formhelp form.any_tags %}\n    At least one of these tags must be present in a bookmark to match.\n  {% endformhelp %}\n</div>\n<div class=\"form-group\">\n  {% formlabel form.all_tags \"Required tags\" %}\n  {% formfield form.all_tags has_help=True %}\n  {% formhelp form.all_tags %}\n    All of these tags must be present in a bookmark to match.\n  {% endformhelp %}\n</div>\n<div class=\"form-group\">\n  {% formlabel form.excluded_tags \"Excluded tags\" %}\n  {% formfield form.excluded_tags has_help=True %}\n  {% formhelp form.excluded_tags %}\n    None of these tags must be present in a bookmark to match.\n  {% endformhelp %}\n</div>\n<div class=\"form-group\">\n  {% formlabel form.filter_unread \"Reading State\" %}\n  {% formfield form.filter_unread has_help=True %}\n  {% formhelp form.filter_unread %}\n    Limit matches to unread or read bookmarks.\n  {% endformhelp %}\n</div>\n<div class=\"form-group\">\n  {% formlabel form.filter_shared \"Sharing State\" %}\n  {% formfield form.filter_shared has_help=True %}\n  {% formhelp form.filter_shared %}\n    Limit matches to shared or unshared bookmarks.\n  {% endformhelp %}\n</div>\n<div class=\"form-footer d-flex mt-4\">\n  <input type=\"submit\"\n         name=\"save\"\n         value=\"Save\"\n         class=\"btn btn-primary btn-wide\">\n  <a href=\"{% url 'linkding:bundles.index' %}\"\n     class=\"btn btn-wide ml-auto\">Cancel</a>\n  <a href=\"{% url 'linkding:bundles.preview' %}\"\n     data-turbo-frame=\"preview\"\n     class=\"d-none\"\n     id=\"preview-link\"></a>\n</div>\n<script>\n  (function init() {\n    const bundleForm = document.getElementById('bundle-form');\n    const previewLink = document.getElementById('preview-link');\n\n    let pendingUpdate;\n\n    function scheduleUpdate() {\n      if (pendingUpdate) {\n        clearTimeout(pendingUpdate);\n      }\n      pendingUpdate = setTimeout(() => {\n        // Ignore if link has been removed (e.g. form submit or navigation)\n        if (!previewLink.isConnected) {\n          return;\n        }\n\n        const baseUrl = previewLink.href.split('?')[0];\n        const params = new URLSearchParams();\n        const inputs = bundleForm.querySelectorAll('input[type=\"text\"]:not([name=\"csrfmiddlewaretoken\"]), textarea, select');\n\n        inputs.forEach(input => {\n          if (input.name && input.value.trim()) {\n            params.set(input.name, input.value.trim());\n          }\n        });\n\n        previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;\n        previewLink.click();\n      }, 500)\n    }\n\n    bundleForm.addEventListener('input', scheduleUpdate);\n  })();\n</script>\n"
  },
  {
    "path": "bookmarks/templates/bundles/index.html",
    "content": "{% extends \"shared/layout.html\" %}\n{% load static %}\n{% block head %}\n  {% with page_title=\"Bundles - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <main class=\"bundles-page crud-page\" aria-labelledby=\"main-heading\">\n    <div class=\"crud-header\">\n      <h1 id=\"main-heading\">Bundles</h1>\n      <a href=\"{% url 'linkding:bundles.new' %}\" class=\"btn\">Add bundle</a>\n    </div>\n    {% include 'shared/messages.html' %}\n    {% if bundles %}\n      <form action=\"{% url 'linkding:bundles.action' %}\" method=\"post\">\n        {% csrf_token %}\n        <table class=\"table crud-table\">\n          <thead>\n            <tr>\n              <th>Name</th>\n              <th class=\"actions\">\n                <span class=\"text-assistive\">Actions</span>\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {% for bundle in bundles %}\n              <tr data-bundle-id=\"{{ bundle.id }}\" draggable=\"true\">\n                <td>\n                  <div class=\"d-flex align-center\">\n                    <svg class=\"text-secondary mr-1\" width=\"16\" height=\"16\">\n                      <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#drag\"></use>\n                    </svg>\n                    <span>{{ bundle.name }}</span>\n                  </div>\n                </td>\n                <td class=\"actions\">\n                  <a class=\"btn btn-link\"\n                     href=\"{% url 'linkding:bundles.edit' bundle.id %}\">Edit</a>\n                  <button data-confirm\n                          type=\"submit\"\n                          name=\"remove_bundle\"\n                          value=\"{{ bundle.id }}\"\n                          class=\"btn btn-link\">Remove</button>\n                </td>\n              </tr>\n            {% endfor %}\n          </tbody>\n        </table>\n        <input type=\"submit\" name=\"move_bundle\" value=\"\" class=\"d-none\">\n        <input type=\"hidden\" name=\"move_position\" value=\"\">\n      </form>\n    {% else %}\n      <div class=\"empty\">\n        <p class=\"empty-title h5\">You have no bundles yet</p>\n        <p class=\"empty-subtitle\">Create your first bundle to get started</p>\n      </div>\n    {% endif %}\n  </main>\n  <script>\n    (function init() {\n      const tableBody = document.querySelector(\".crud-table tbody\");\n      if (!tableBody) return;\n\n      let draggedElement = null;\n\n      const rows = tableBody.querySelectorAll('tr');\n      rows.forEach((item) => {\n        item.addEventListener('dragstart', handleDragStart);\n        item.addEventListener('dragend', handleDragEnd);\n        item.addEventListener('dragover', handleDragOver);\n        item.addEventListener('dragenter', handleDragEnter);\n      });\n\n      function handleDragStart(e) {\n        draggedElement = this;\n\n        e.dataTransfer.effectAllowed = 'move';\n        e.dataTransfer.dropEffect = 'move';\n\n        this.classList.add('drag-start');\n        setTimeout(() => {\n          this.classList.remove('drag-start');\n          this.classList.add('dragging');\n        }, 0);\n      }\n\n      function handleDragEnd() {\n        this.classList.remove('dragging');\n\n        const moveBundleInput = document.querySelector('input[name=\"move_bundle\"]');\n        const movePositionInput = document.querySelector('input[name=\"move_position\"]');\n        moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');\n        movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);\n\n        const form = this.closest('form');\n        form.requestSubmit(moveBundleInput);\n\n        draggedElement = null;\n      }\n\n      function handleDragOver(e) {\n        if (e.preventDefault) {\n          e.preventDefault();\n        }\n        return false;\n      }\n\n      function handleDragEnter() {\n        if (this !== draggedElement) {\n          const listItems = Array.from(tableBody.children);\n          const draggedIndex = listItems.indexOf(draggedElement);\n          const currentIndex = listItems.indexOf(this);\n\n          if (draggedIndex < currentIndex) {\n            this.insertAdjacentElement('afterend', draggedElement);\n          } else {\n            this.insertAdjacentElement('beforebegin', draggedElement);\n          }\n        }\n      }\n    })();\n  </script>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bundles/new.html",
    "content": "{% extends 'shared/layout.html' %}\n{% block head %}\n  {% with page_title=\"New bundle - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <div class=\"bundles-editor-page grid columns-md-1\">\n    <main aria-labelledby=\"main-heading\">\n      <div class=\"section-header\">\n        <h1 id=\"main-heading\">New bundle</h1>\n      </div>\n      {% include 'shared/messages.html' %}\n      <form id=\"bundle-form\"\n            action=\"{% url 'linkding:bundles.new' %}\"\n            method=\"post\"\n            novalidate>\n        {% csrf_token %}\n        {% include 'bundles/form.html' %}\n      </form>\n    </main>\n    <aside class=\"col-2\" aria-labelledby=\"preview-heading\">\n      <div class=\"section-header\">\n        <h2 id=\"preview-heading\">Preview</h2>\n      </div>\n      {% include 'bundles/preview.html' %}\n    </aside>\n  </div>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/bundles/preview.html",
    "content": "<turbo-frame id=\"preview\">\n{% if bookmark_list.is_empty %}\n  <div>No bookmarks match the current bundle.</div>\n{% else %}\n  <div class=\"mb-4\">Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.</div>\n  {% with pagination_frame=\"preview\" %}\n    {% include 'bookmarks/bookmark_list.html' %}\n  {% endwith %}\n{% endif %}\n</turbo-frame>\n"
  },
  {
    "path": "bookmarks/templates/opensearch.xml",
    "content": "<OpenSearchDescription xmlns=\"http://a9.com/-/spec/opensearch/1.1/\" xmlns:moz=\"http://www.mozilla.org/2006/browser/search/\">\n    <ShortName>Linkding</ShortName>\n    <Description>Linkding</Description>\n    <InputEncoding>UTF-8</InputEncoding>\n    <Image width=\"16\" height=\"16\" type=\"image/x-icon\">{{base_url}}static/favicon.ico</Image>\n    <Url type=\"text/html\" template=\"{{ bookmarks_url }}?client=opensearch&amp;q={searchTerms}\"/>\n</OpenSearchDescription>\n"
  },
  {
    "path": "bookmarks/templates/registration/login.html",
    "content": "{% extends 'shared/layout.html' %}\n{% load shared %}\n{% block head %}\n  {% with page_title=\"Login - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <main class=\"auth-page\" aria-labelledby=\"main-heading\">\n    <div class=\"section-header\">\n      <h1 id=\"main-heading\">Login</h1>\n    </div>\n    {% if not disable_login %}\n      <form method=\"post\" action=\"{% url 'login' %}\">\n        {% csrf_token %}\n        {% if form.errors %}\n          <p class=\"form-input-hint is-error\">Your username and password didn't match. Please try again.</p>\n        {% endif %}\n        <div class=\"form-group\">\n          {% formlabel form.username 'Username' %}\n          {% formfield form.username class='form-input' %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.password 'Password' %}\n          {% formfield form.password class='form-input' %}\n        </div>\n        <input type=\"submit\" value=\"Login\" class=\"btn btn-primary width-100 mt-4\" />\n        <input type=\"hidden\" name=\"next\" value=\"{{ next }}\" />\n      </form>\n    {% endif %}\n    {% if enable_oidc %}\n      <a class=\"btn width-100 mt-4\"\n         href=\"{% url 'oidc_authentication_init' %}\"\n         data-turbo=\"false\">Login with OIDC</a>\n    {% endif %}\n  </main>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/registration/password_change_done.html",
    "content": "{% extends 'shared/layout.html' %}\n{% block head %}\n  {% with page_title=\"Password changed - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <main class=\"auth-page\" aria-labelledby=\"main-heading\">\n    <div class=\"section-header\">\n      <h1 id=\"main-heading\">Password Changed</h1>\n    </div>\n    <p class=\"text-success\">Your password was changed successfully.</p>\n  </main>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/registration/password_change_form.html",
    "content": "{% extends 'shared/layout.html' %}\n{% load shared %}\n{% block head %}\n  {% with page_title=\"Change password - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <main class=\"auth-page\" aria-labelledby=\"main-heading\">\n    <div class=\"section-header\">\n      <h1 id=\"main-heading\">Change Password</h1>\n    </div>\n    <form method=\"post\" action=\"{% url 'change_password' %}\">\n      {% csrf_token %}\n      <div class=\"form-group\">\n        {% formlabel form.old_password 'Old password' %}\n        {% formfield form.old_password class='form-input' %}\n        {{ form.old_password.errors }}\n      </div>\n      <div class=\"form-group\">\n        {% formlabel form.new_password1 'New password' %}\n        {% formfield form.new_password1 class='form-input' %}\n        {{ form.new_password1.errors }}\n      </div>\n      <div class=\"form-group\">\n        {% formlabel form.new_password2 'Confirm new password' %}\n        {% formfield form.new_password2 class='form-input' %}\n        {{ form.new_password2.errors }}\n      </div>\n      <input type=\"submit\"\n             value=\"Change Password\"\n             class=\"btn btn-primary width-100 mt-4\">\n    </form>\n  </main>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/settings/bookmarklet.js",
    "content": "(function () {\n  const bookmarkUrl = window.location;\n\n  let applicationUrl = '{{ application_url }}';\n  applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);\n  applicationUrl += '&auto_close';\n\n  window.open(applicationUrl);\n})();\n"
  },
  {
    "path": "bookmarks/templates/settings/bookmarklet_clientside.js",
    "content": "(function () {\n  const bookmarkUrl = window.location;\n  const title =\n    document.querySelector('title')?.textContent ||\n    document\n      .querySelector(`meta[property='og:title']`)\n      ?.getAttribute('content') ||\n    '';\n  const description =\n    document\n      .querySelector(`meta[name='description']`)\n      ?.getAttribute('content') ||\n    document\n      .querySelector(`meta[property='og:description']`)\n      ?.getAttribute(`content`) ||\n    '';\n\n  let applicationUrl = '{{ application_url }}';\n  applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);\n  applicationUrl += '&title=' + encodeURIComponent(title);\n  applicationUrl += '&description=' + encodeURIComponent(description);\n  applicationUrl += '&auto_close';\n\n  window.open(applicationUrl);\n})();\n"
  },
  {
    "path": "bookmarks/templates/settings/create_api_token_modal.html",
    "content": "<turbo-frame id=\"api-modal\">\n<form method=\"post\"\n      action=\"{% url 'linkding:settings.integrations.create_api_token' %}\"\n      data-turbo-frame=\"api-section\">\n  {% csrf_token %}\n  <ld-modal class=\"modal active\"\n            data-close-url=\"{% url 'linkding:settings.integrations' %}\"\n            data-turbo-frame=\"api-modal\">\n    <div class=\"modal-overlay\" data-close-modal></div>\n    <div class=\"modal-container\" role=\"dialog\" aria-modal=\"true\">\n      {% include 'shared/modal_header.html' with title=\"Create API Token\" %}\n      <div class=\"modal-body\">\n        <div class=\"form-group\">\n          <label class=\"form-label\" for=\"token-name\">Token name</label>\n          <input type=\"text\"\n                 class=\"form-input\"\n                 id=\"token-name\"\n                 name=\"name\"\n                 placeholder=\"e.g., Browser Extension, Mobile App\"\n                 value=\"API Token\"\n                 maxlength=\"128\">\n          <p class=\"form-input-hint\">A descriptive name to identify the purpose of the token</p>\n        </div>\n      </div>\n      <div class=\"modal-footer d-flex justify-between\">\n        <button type=\"button\" class=\"btn btn-wide\" data-close-modal>Cancel</button>\n        <button type=\"submit\" class=\"btn btn-primary\">Create Token</button>\n      </div>\n    </div>\n  </ld-modal>\n</form>\n</turbo-frame>\n"
  },
  {
    "path": "bookmarks/templates/settings/general.html",
    "content": "{% extends \"shared/layout.html\" %}\n{% load shared %}\n{% block head %}\n  {% with page_title=\"Settings - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <main class=\"settings-page\" aria-labelledby=\"main-heading\">\n    <h1 id=\"main-heading\">Settings</h1>\n    {# Profile section #}\n    {% if success_message %}<div class=\"toast toast-success mb-4\">{{ success_message }}</div>{% endif %}\n    {% if error_message %}<div class=\"toast toast-error mb-4\">{{ error_message }}</div>{% endif %}\n    <section aria-labelledby=\"profile-heading\">\n      <h2 id=\"profile-heading\">Profile</h2>\n      <p>\n        <a href=\"{% url 'change_password' %}\">Change password</a>\n      </p>\n      <form action=\"{% url 'linkding:settings.update' %}\"\n            method=\"post\"\n            novalidate\n            data-turbo=\"false\">\n        {% csrf_token %}\n        <div class=\"form-group\">\n          {% formlabel form.theme \"Theme\" %}\n          {% formfield form.theme has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.theme %}\n            Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.bookmark_date_display \"Bookmark date format\" %}\n          {% formfield form.bookmark_date_display has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.bookmark_date_display %}\n            Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can\n            be hidden.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.bookmark_description_display \"Bookmark description\" %}\n          {% formfield form.bookmark_description_display has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.bookmark_description_display %}\n            Whether to show bookmark descriptions and tags in the same line, or as separate blocks.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}\">\n          {% formlabel form.bookmark_description_max_lines \"Bookmark description max lines\" %}\n          {% formfield form.bookmark_description_max_lines has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.bookmark_description_max_lines %}\n            Limits the number of lines that are displayed for the bookmark description.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.display_url label=\"Show bookmark URL\" has_help=True %}\n          {% formhelp form.display_url %}\n            When enabled, this setting displays the bookmark URL below the title.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.permanent_notes label=\"Show notes permanently\" has_help=True %}\n          {% formhelp form.permanent_notes %}\n            Whether to show bookmark notes permanently, without having to toggle them individually.\n            Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          <span class=\"form-label\">Bookmark actions</span>\n          {% formfield form.display_view_bookmark_action label=\"View\" %}\n          {% formfield form.display_edit_bookmark_action label=\"Edit\" %}\n          {% formfield form.display_archive_bookmark_action label=\"Archive\" %}\n          {% formfield form.display_remove_bookmark_action label=\"Remove\" %}\n          <div class=\"form-input-hint\">Which actions to display for each bookmark.</div>\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.bookmark_link_target \"Open bookmarks in\" %}\n          {% formfield form.bookmark_link_target has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.bookmark_link_target %}\n            Whether to open bookmarks a new page or in the same page.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.items_per_page \"Items per page\" %}\n          {% formfield form.items_per_page has_help=True class=\"width-25 width-sm-100\" min=\"10\" %}\n          {{ form.items_per_page.errors }}\n          {% formhelp form.items_per_page %}\n            The number of bookmarks to display per page.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.sticky_pagination label=\"Sticky pagination\" has_help=True %}\n          {% formhelp form.sticky_pagination %}\n            When enabled, the pagination controls will stick to the bottom of the screen, so that they are always\n            visible without having to scroll to the end of the page first.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.collapse_side_panel label=\"Collapse side panel\" has_help=True %}\n          {% formhelp form.collapse_side_panel %}\n            When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.\n            Instead, the tags are shown in an expandable drawer.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.hide_bundles label=\"Hide bundles\" has_help=True %}\n          {% formhelp form.hide_bundles %}\n            Allows to hide the bundles in the side panel if you don't intend to use them.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.tag_search \"Tag search\" %}\n          {% formfield form.tag_search has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.tag_search %}\n            In strict mode, tags must be prefixed with a hash character (#).\n            In lax mode, tags can also be searched without the hash character.\n            Note that tags without the hash character are indistinguishable from search terms, which means the search\n            result will also include bookmarks where a search term matches otherwise.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.legacy_search label=\"Enable legacy search\" has_help=True %}\n          {% formhelp form.legacy_search %}\n            Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not).\n            If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search.\n            Please report any issues you encounter with the new search on <a href=\"https://github.com/sissbruecker/linkding/issues\"\n    target=\"_blank\">GitHub</a> so they can be addressed.\n            This option will be removed in a future version.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.tag_grouping \"Tag grouping\" %}\n          {% formfield form.tag_grouping has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.tag_grouping %}\n            In alphabetical mode, tags will be grouped by the first letter.\n            If disabled, tags will not be grouped.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          <details {% if form.auto_tagging_rules.value %}open{% endif %}>\n            <summary>\n              <span class=\"form-label d-inline-block\">Auto Tagging</span>\n            </summary>\n            <label for=\"{{ form.auto_tagging_rules.id_for_label }}\"\n                   class=\"text-assistive\">Auto Tagging</label>\n            <div>{% formfield form.auto_tagging_rules has_help=True class=\"monospace\" rows=\"6\" %}</div>\n          </details>\n          {% formhelp form.auto_tagging_rules %}\n            Automatically adds tags to bookmarks based on predefined rules.\n            Each line is a single rule that maps a URL to one or more tags. For example:\n            <pre>youtube.com video\nreddit.com/r/Music music reddit</pre>\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.enable_favicons label=\"Enable Favicons\" has_help=True %}\n          {% formhelp form.enable_favicons %}\n            Automatically loads favicons for bookmarked websites and displays them next to each bookmark.\n            Enabling this feature automatically downloads all missing favicons.\n            By default, this feature uses a <b>Google service</b> to download favicons.\n            If you don't want to use this service, check the\n            <a href=\"https://linkding.link/options/#ld_favicon_provider\"\n               target=\"_blank\">options documentation</a> on how to configure a custom favicon provider.\n            Icons are downloaded in the background, and it may take a while for them to show up.\n          {% endformhelp %}\n          {% if request.user_profile.enable_favicons and enable_refresh_favicons %}\n            <button class=\"btn mt-2\" name=\"refresh_favicons\">Refresh Favicons</button>\n          {% endif %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.enable_preview_images label=\"Enable Preview Images\" has_help=True %}\n          {% formhelp form.enable_preview_images %}\n            Automatically loads preview images for bookmarked websites and displays them next to each bookmark.\n            Enabling this feature automatically downloads all missing preview images.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.web_archive_integration \"Internet Archive integration\" %}\n          {% formfield form.web_archive_integration has_help=True class=\"width-25 width-sm-100\" %}\n          {% formhelp form.web_archive_integration %}\n            Enabling this feature will automatically create snapshots of bookmarked websites on the\n            <a href=\"https://web.archive.org/\" target=\"_blank\" rel=\"noopener\">Internet Archive Wayback Machine</a>.\n            This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in\n            case it goes offline or its content is modified.\n            Please consider donating to the <a href=\"https://archive.org/donate\" target=\"_blank\" rel=\"noopener\">Internet Archive</a> if you make use of this feature.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.enable_sharing label=\"Enable bookmark sharing\" has_help=True %}\n          {% formhelp form.enable_sharing %}\n            Allows to share bookmarks with other users, and to view shared bookmarks.\n            Disabling this feature will hide all previously shared bookmarks from other users.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.enable_public_sharing label=\"Enable public bookmark sharing\" has_help=True %}\n          {% formhelp form.enable_public_sharing %}\n            Makes shared bookmarks publicly accessible, without requiring a login.\n            That means that anyone with a link to this instance can view shared bookmarks via the <a href=\"{% url 'linkding:bookmarks.shared' %}\">shared bookmarks page</a>.\n          {% endformhelp %}\n        </div>\n        {% if has_snapshot_support %}\n          <div class=\"form-group\">\n            {% formfield form.enable_automatic_html_snapshots label=\"Automatically create HTML snapshots\" has_help=True %}\n            {% formhelp form.enable_automatic_html_snapshots %}\n              Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be\n              created manually in the details view of a bookmark.\n            {% endformhelp %}\n            <button class=\"btn mt-2\" name=\"create_missing_html_snapshots\">Create missing HTML snapshots</button>\n          </div>\n        {% endif %}\n        <div class=\"form-group\">\n          {% formfield form.default_mark_unread label=\"Create bookmarks as unread by default\" has_help=True %}\n          {% formhelp form.default_mark_unread %}\n            Sets the default state for the \"Mark as unread\" option when creating a new bookmark.\n            Setting this option will make all new bookmarks default to unread.\n            This can be overridden when creating each new bookmark.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          {% formfield form.default_mark_shared label=\"Create bookmarks as shared by default\" has_help=True %}\n          {% formhelp form.default_mark_shared %}\n            Sets the default state for the \"Share\" option when creating a new bookmark.\n            Setting this option will make all new bookmarks default to shared.\n            This can be overridden when creating each new bookmark.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          <details {% if form.custom_css.value %}open{% endif %}>\n            <summary>\n              <span class=\"form-label d-inline-block\">Custom CSS</span>\n            </summary>\n            <label for=\"{{ form.custom_css.id_for_label }}\" class=\"text-assistive\">Custom CSS</label>\n            <div>{% formfield form.custom_css has_help=True class=\"monospace\" rows=\"6\" %}</div>\n          </details>\n          {% formhelp form.custom_css %}\n            Allows to add custom CSS to the page.\n          {% endformhelp %}\n        </div>\n        <div class=\"form-group\">\n          <input type=\"submit\"\n                 name=\"update_profile\"\n                 value=\"Save\"\n                 class=\"btn btn-primary btn-wide mt-2\">\n        </div>\n      </form>\n    </section>\n    {# Global settings section #}\n    {% if global_settings_form %}\n      <section aria-labelledby=\"global-settings-heading\">\n        <h2 id=\"global-settings-heading\">Global settings</h2>\n        <form action=\"{% url 'linkding:settings.update' %}\"\n              method=\"post\"\n              novalidate\n              data-turbo=\"false\">\n          {% csrf_token %}\n          <div class=\"form-group\">\n            {% formlabel global_settings_form.landing_page \"Landing page\" %}\n            {% formfield global_settings_form.landing_page has_help=True class=\"width-25 width-sm-100\" %}\n            {% formhelp global_settings_form.landing_page %}\n              The page that unauthenticated users are redirected to when accessing the root URL.\n            {% endformhelp %}\n          </div>\n          <div class=\"form-group\">\n            {% formlabel global_settings_form.guest_profile_user \"Guest user profile\" %}\n            {% formfield global_settings_form.guest_profile_user has_help=True class=\"width-25 width-sm-100\" %}\n            {% formhelp global_settings_form.guest_profile_user %}\n              The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks\n              are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create\n              a dedicated user for this purpose. By default, a standard profile with fixed settings is used.\n            {% endformhelp %}\n          </div>\n          <div class=\"form-group\">\n            {% formfield global_settings_form.enable_link_prefetch label=\"Enable prefetching links on hover\" has_help=True %}\n            {% formhelp global_settings_form.enable_link_prefetch %}\n              Prefetches internal links when hovering over them. This can improve the perceived performance when\n              navigating application, but also increases the load on the server as well as bandwidth usage.\n            {% endformhelp %}\n          </div>\n          <div class=\"form-group\">\n            <input type=\"submit\"\n                   name=\"update_global_settings\"\n                   value=\"Save\"\n                   class=\"btn btn-primary btn-wide mt-2\">\n          </div>\n        </form>\n      </section>\n    {% endif %}\n    {# Import section #}\n    <section aria-labelledby=\"import-heading\">\n      <h2 id=\"import-heading\">Import</h2>\n      <p>\n        Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are\n        added and existing ones are updated.\n      </p>\n      <form method=\"post\"\n            enctype=\"multipart/form-data\"\n            action=\"{% url 'linkding:settings.import' %}\">\n        {% csrf_token %}\n        <div class=\"form-group\">\n          <label for=\"import_map_private_flag\" class=\"form-checkbox\">\n            <input type=\"checkbox\"\n                   id=\"import_map_private_flag\"\n                   name=\"map_private_flag\"\n                   aria-describedby=\"import_map_private_flag_help\">\n            <i class=\"form-icon\"></i> Import public bookmarks as shared\n          </label>\n          <div id=\"import_map_private_flag_help\" class=\"form-input-hint\">\n            When importing bookmarks from a service that supports marking bookmarks as public or private (using the\n            <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not\n            private as shared bookmarks.\n            Otherwise, all bookmarks will be imported as private bookmarks.\n          </div>\n        </div>\n        <div class=\"form-group\">\n          <div class=\"input-group width-75 width-md-100\">\n            <input class=\"form-input\" type=\"file\" name=\"import_file\">\n            <input type=\"submit\" class=\"input-group-btn btn btn-primary\" value=\"Upload\">\n          </div>\n        </div>\n      </form>\n    </section>\n    {# Export section #}\n    <section aria-labelledby=\"export-heading\">\n      <h2 id=\"export-heading\">Export</h2>\n      <p>Export all bookmarks in Netscape HTML format.</p>\n      <a class=\"btn btn-primary\"\n         target=\"_blank\"\n         href=\"{% url 'linkding:settings.export' %}\">Download (.html)</a>\n      {% if export_error %}\n        <div class=\"has-error\">\n          <p class=\"form-input-hint\">{{ export_error }}</p>\n        </div>\n      {% endif %}\n    </section>\n    {# About section #}\n    <section class=\"about\" aria-labelledby=\"about-heading\">\n      <h2 id=\"about-heading\">About</h2>\n      <table class=\"table\">\n        <tbody>\n          <tr>\n            <td>Version</td>\n            <td>{{ version_info }}</td>\n          </tr>\n          <tr>\n            <td style=\"vertical-align: top\">Links</td>\n            <td>\n              <div class=\"d-flex flex-column gap-2\">\n                <a href=\"https://github.com/sissbruecker/linkding/\" target=\"_blank\">GitHub</a>\n                <a href=\"https://linkding.link/\" target=\"_blank\">Documentation</a>\n                <a href=\"https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md\"\n                   target=\"_blank\">Changelog</a>\n              </div>\n            </td>\n          </tr>\n        </tbody>\n      </table>\n    </section>\n  </main>\n  <script>\n    (function init() {\n      const enableSharing = document.getElementById(\"{{ form.enable_sharing.id_for_label }}\");\n      const enablePublicSharing = document.getElementById(\"{{ form.enable_public_sharing.id_for_label }}\");\n      const defaultMarkShared = document.getElementById(\"{{ form.default_mark_shared.id_for_label }}\");\n      const bookmarkDescriptionDisplay = document.getElementById(\"{{ form.bookmark_description_display.id_for_label }}\");\n      const bookmarkDescriptionMaxLines = document.getElementById(\"{{ form.bookmark_description_max_lines.id_for_label }}\");\n\n      // Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled\n      function updateSharingOptions() {\n        if (enableSharing.checked) {\n          enablePublicSharing.disabled = false;\n          defaultMarkShared.disabled = false;\n        } else {\n          enablePublicSharing.disabled = true;\n          enablePublicSharing.checked = false;\n          defaultMarkShared.disabled = true;\n          defaultMarkShared.checked = false;\n        }\n      }\n\n      updateSharingOptions();\n      enableSharing.addEventListener(\"change\", updateSharingOptions);\n\n      // Automatically hide the bookmark description max lines input if the description display is set to inline\n      function updateBookmarkDescriptionMaxLines() {\n        if (bookmarkDescriptionDisplay.value === \"inline\") {\n          bookmarkDescriptionMaxLines.closest(\".form-group\").classList.add(\"d-hide\");\n        } else {\n          bookmarkDescriptionMaxLines.closest(\".form-group\").classList.remove(\"d-hide\");\n        }\n      }\n\n      updateBookmarkDescriptionMaxLines();\n      bookmarkDescriptionDisplay.addEventListener(\"change\", updateBookmarkDescriptionMaxLines);\n    })();\n  </script>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/settings/integrations.html",
    "content": "{% extends \"shared/layout.html\" %}\n{% block head %}\n  {% with page_title=\"Integrations - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <main class=\"settings-page\" aria-labelledby=\"main-heading\">\n    <h1 id=\"main-heading\">Integrations</h1>\n    <section aria-labelledby=\"browser-extension-heading\">\n      <h2 id=\"browser-extension-heading\">Browser Extension</h2>\n      <p>\n        The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The\n        extension is available in the official extension stores for:\n      </p>\n      <ul>\n        <li>\n          <a href=\"https://addons.mozilla.org/firefox/addon/linkding-extension/\"\n             target=\"_blank\">Firefox</a>\n        </li>\n        <li>\n          <a href=\"https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe\"\n             target=\"_blank\">Chrome</a>\n        </li>\n      </ul>\n      <p>\n        The extension is <a href=\"https://github.com/sissbruecker/linkding-extension\"\n    target=\"_blank\">open source</a>\n        as well, which enables you to build and manually load it into any browser that supports Chrome extensions.\n      </p>\n      <h2>Bookmarklet</h2>\n      <p>\n        The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding\n        application first. Here's how it works:\n      </p>\n      <ul>\n        <li>\n          Choose your preferred method for detecting website titles and descriptions below (<a href=\"https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect\"\n   target=\"_blank\">Help</a>)\n        </li>\n        <li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>\n        <li>Open the website that you want to bookmark</li>\n        <li>Click the bookmarklet in your browser's toolbar</li>\n        <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>\n        <li>After saving the bookmark, the linkding window closes, and you are back on your website</li>\n      </ul>\n      <div class=\"form-group radio-group\"\n           role=\"radiogroup\"\n           aria-labelledby=\"detection-method-label\">\n        <p id=\"detection-method-label\">Choose your preferred bookmarklet:</p>\n        <label for=\"detection-method-server\" class=\"form-radio\">\n          <input id=\"detection-method-server\"\n                 type=\"radio\"\n                 name=\"bookmarklet-type\"\n                 value=\"server\"\n                 checked>\n          <i class=\"form-icon\"></i>\n          Detect title and description on the server\n        </label>\n        <label for=\"detection-method-client\" class=\"form-radio\">\n          <input id=\"detection-method-client\"\n                 type=\"radio\"\n                 name=\"bookmarklet-type\"\n                 value=\"client\">\n          <i class=\"form-icon\"></i>\n          Detect title and description in the browser\n        </label>\n      </div>\n      <div class=\"bookmarklet-container\">\n        <a id=\"bookmarklet-server\"\n           href=\"javascript: {% include 'settings/bookmarklet.js' %}\"\n           data-turbo=\"false\"\n           class=\"btn btn-primary\">📎 Add bookmark</a>\n        <a id=\"bookmarklet-client\"\n           href=\"javascript: {% include 'settings/bookmarklet_clientside.js' %}\"\n           data-turbo=\"false\"\n           class=\"btn btn-primary\"\n           style=\"display: none\">📎 Add bookmark</a>\n      </div>\n      <script>\n        (function init() {\n          // Bookmarklet type toggle\n          const radioButtons = document.querySelectorAll('input[name=\"bookmarklet-type\"]');\n          const serverBookmarklet = document.getElementById('bookmarklet-server');\n          const clientBookmarklet = document.getElementById('bookmarklet-client');\n\n          function toggleBookmarklet() {\n            const selectedValue = document.querySelector('input[name=\"bookmarklet-type\"]:checked').value;\n            if (selectedValue === 'server') {\n              serverBookmarklet.style.display = 'inline-block';\n              clientBookmarklet.style.display = 'none';\n            } else {\n              serverBookmarklet.style.display = 'none';\n              clientBookmarklet.style.display = 'inline-block';\n            }\n          }\n\n          toggleBookmarklet();\n          radioButtons.forEach(function(radio) {\n            radio.addEventListener('change', toggleBookmarklet);\n          });\n        })();\n      </script>\n    </section>\n    <turbo-frame id=\"api-section\">\n    <section aria-labelledby=\"rest-api-heading\">\n      <h2 id=\"rest-api-heading\">REST API</h2>\n      {% if api_success_message %}<div class=\"toast toast-success mb-2\">{{ api_success_message }}</div>{% endif %}\n      {% if api_token_name and api_token_key %}\n        <div class=\"mt-4 mb-6\">\n          <p class=\"mb-2\">\n            <strong>Copy this token now, it will only be shown once:</strong>\n          </p>\n          <label class=\"text-assistive\" for=\"new-token-key\">New token key</label>\n          <div class=\"input-group\">\n            <input class=\"form-input\"\n                   value=\"{{ api_token_key }}\"\n                   readonly\n                   id=\"new-token-key\">\n            <button id=\"copy-new-token-key\" class=\"btn input-group-btn\" type=\"button\">Copy</button>\n          </div>\n        </div>\n      {% endif %}\n      <p>\n        API tokens can be used to authenticate 3rd-party applications against the REST API. <strong>Please treat\n        tokens as you would any other credential.</strong> Any party with access to a token can access and manage all\n        your bookmarks.\n      </p>\n      {% if api_tokens %}\n        <form method=\"post\"\n              action=\"{% url 'linkding:settings.integrations.delete_api_token' %}\"\n              data-turbo-frame=\"api-section\">\n          <table class=\"table crud-table mb-6\">\n            <thead>\n              <tr>\n                <th>Name</th>\n                <th>Created</th>\n                <th class=\"actions\">\n                  <span class=\"text-assistive\">Actions</span>\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {% for token in api_tokens %}\n                <tr>\n                  <td>{{ token.name }}</td>\n                  <td>{{ token.created|date:\"M d, Y H:i\" }}</td>\n                  <td class=\"actions\">\n                    {% csrf_token %}\n                    <button data-confirm\n                            name=\"token_id\"\n                            value=\"{{ token.id }}\"\n                            type=\"submit\"\n                            class=\"btn btn-link\">Delete</button>\n                  </td>\n                </tr>\n              {% endfor %}\n            </tbody>\n          </table>\n        </form>\n      {% endif %}\n      <a class=\"btn\"\n         href=\"{% url 'linkding:settings.integrations.create_api_token' %}\"\n         data-turbo-frame=\"api-modal\">Create API token</a>\n    </section>\n    <turbo-frame id=\"api-modal\"></turbo-frame>\n    <script>\n      (function init() {\n        // Copy new token key to clipboard\n        const copyButton = document.getElementById('copy-new-token-key');\n        if (copyButton) {\n          copyButton.addEventListener('click', () => {\n            const tokenInput = document.getElementById('new-token-key');\n            const tokenValue = tokenInput.value;\n            navigator.clipboard.writeText(tokenValue).then(() => {\n              copyButton.textContent = 'Copied!';\n              setTimeout(() => {\n                copyButton.textContent = 'Copy';\n              }, 2000);\n            }, (err) => {\n              console.error('Could not copy text: ', err);\n            });\n          });\n        }\n      })();\n    </script>\n    </turbo-frame>\n    <section aria-labelledby=\"rss-feeds-heading\">\n      <h2 id=\"rss-feeds-heading\">RSS Feeds</h2>\n      <p>The following URLs provide RSS feeds for your bookmarks:</p>\n      <ul style=\"list-style-position: outside;\">\n        <li>\n          <a target=\"_blank\" href=\"{{ all_feed_url }}\">All bookmarks</a>\n        </li>\n        <li>\n          <a target=\"_blank\" href=\"{{ unread_feed_url }}\">Unread bookmarks</a>\n        </li>\n        <li>\n          <a target=\"_blank\" href=\"{{ shared_feed_url }}\">Shared bookmarks</a>\n        </li>\n        <li>\n          <a target=\"_blank\" href=\"{{ public_shared_feed_url }}\">Public shared bookmarks</a>\n          <br>\n          <span class=\"text-small text-secondary\">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>\n        </li>\n      </ul>\n      <p>All URLs support the following URL parameters:</p>\n      <ul style=\"list-style-position: outside;\">\n        <li>\n          A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By\n          default, only the latest 100 matching bookmarks are included.\n        </li>\n        <li>\n          A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in\n          the bookmarks view and then copying the parameter from the URL.\n        </li>\n        <li>\n          An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread\n          bookmarks and <code>no</code> for read bookmarks.\n        </li>\n        <li>\n          A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for\n          shared bookmarks and <code>no</code> for unshared bookmarks.\n        </li>\n      </ul>\n      <p>\n        <strong>Please note that these URLs include an authentication token that should be treated like any other\n        credential.</strong>\n        Any party with access to these URLs can read all your bookmarks.\n        If you think that a URL was compromised you can delete the feed token for your user in the <a target=\"_blank\"\n    href=\"{% url 'admin:bookmarks_feedtoken_changelist' %}\">admin panel</a>.\n        After deleting the feed token, new URLs will be generated when you reload this settings page.\n      </p>\n    </section>\n  </main>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/shared/dev_tool.html",
    "content": "{% load shared %}\n{% if debug and request.user.is_authenticated %}\n  {{ request.user.profile|model_to_dict|json_script:\"json_profile\" }}\n  <ld-dev-tool id=\"dev-tool\"\n               data-csrf-token=\"{{ csrf_token }}\"\n               data-form-action=\"{% url 'linkding:settings.update' %}\"\n               data-turbo-permanent>\n  </ld-dev-tool>\n{% endif %}\n"
  },
  {
    "path": "bookmarks/templates/shared/error_list.html",
    "content": "{% load i18n %}\n{# Force rendering validation errors in English language to align with the rest of the app #}\n{% language 'en-us' %}\n  {% if errors %}\n    <ul class=\"{{ error_class }} form-input-hint is-error\"\n        {% if errors.field_id %}id=\"{{ errors.field_id }}_error\"{% endif %}>\n      {% for error in errors %}<li>{{ error }}</li>{% endfor %}\n    </ul>\n  {% endif %}\n{% endlanguage %}\n"
  },
  {
    "path": "bookmarks/templates/shared/head.html",
    "content": "{% load static %}\n<head>\n  <meta charset=\"UTF-8\">\n  <link rel=\"icon\" href=\"{% static 'favicon.ico' %}\" sizes=\"48x48\">\n  <link rel=\"icon\"\n        href=\"{% static 'favicon.svg' %}\"\n        sizes=\"any\"\n        type=\"image/svg+xml\">\n  <link rel=\"apple-touch-icon\"\n        sizes=\"180x180\"\n        href=\"{% static 'apple-touch-icon.png' %}\">\n  <link rel=\"mask-icon\"\n        href=\"{% static 'safari-pinned-tab.svg' %}\"\n        color=\"#5856e0\">\n  <link rel=\"manifest\" href=\"{% url 'linkding:manifest' %}\">\n  <link rel=\"search\"\n        type=\"application/opensearchdescription+xml\"\n        title=\"Linkding\"\n        href=\"{% url 'linkding:opensearch' %}\" />\n  <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n  <meta name=\"viewport\"\n        content=\"width=device-width, initial-scale=1.0, minimal-ui\">\n  <meta name=\"description\" content=\"Self-hosted bookmark service\">\n  <meta name=\"robots\" content=\"index,follow\">\n  <meta name=\"author\" content=\"Sascha Ißbrücker\">\n  <title>{{ page_title|default:'Linkding' }}</title>\n  {# Include specific theme variant based on user profile setting #}\n  {% if request.user_profile.theme == 'light' %}\n    <link href=\"{% static 'theme-light.css' %}?v={{ app_version }}\"\n          rel=\"stylesheet\"\n          type=\"text/css\" />\n    <meta name=\"theme-color\" content=\"#5856e0\">\n  {% elif request.user_profile.theme == 'dark' %}\n    <link href=\"{% static 'theme-dark.css' %}?v={{ app_version }}\"\n          rel=\"stylesheet\"\n          type=\"text/css\" />\n    <meta name=\"theme-color\" content=\"#161822\">\n  {% else %}\n    {# Use auto theme as fallback #}\n    <link href=\"{% static 'theme-dark.css' %}?v={{ app_version }}\"\n          rel=\"stylesheet\"\n          type=\"text/css\"\n          media=\"(prefers-color-scheme: dark)\" />\n    <link href=\"{% static 'theme-light.css' %}?v={{ app_version }}\"\n          rel=\"stylesheet\"\n          type=\"text/css\"\n          media=\"(prefers-color-scheme: light)\" />\n    <meta name=\"theme-color\"\n          media=\"(prefers-color-scheme: dark)\"\n          content=\"#161822\">\n    <meta name=\"theme-color\"\n          media=\"(prefers-color-scheme: light)\"\n          content=\"#5856e0\">\n  {% endif %}\n  {% if request.user_profile.custom_css %}\n    <link href=\"{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}\"\n          rel=\"stylesheet\"\n          type=\"text/css\" />\n  {% endif %}\n  <meta name=\"turbo-cache-control\" content=\"no-preview\">\n  {% if not request.global_settings.enable_link_prefetch %}<meta name=\"turbo-prefetch\" content=\"false\">{% endif %}\n  {% if rss_feed_url %}<link rel=\"alternate\" type=\"application/rss+xml\" href=\"{{ rss_feed_url }}\" />{% endif %}\n  <script src=\"{% static \"bundle.js\" %}?v={{ app_version }}\"></script>\n  {% if debug %}\n    <script src=\"{% static \"live-reload.js\" %}\"></script>\n  {% endif %}\n</head>\n"
  },
  {
    "path": "bookmarks/templates/shared/layout.html",
    "content": "{% load static %}\n<!DOCTYPE html>\n{# Use data attributes as storage for access in static scripts #}\n<html lang=\"en\" data-api-base-url=\"{% url 'linkding:api-root' %}\">\n  {% block head %}\n    {% include 'shared/head.html' %}\n  {% endblock %}\n  <body>\n    <header class=\"container\">\n      {% if has_toasts %}\n        <div class=\"message-list\">\n          <form action=\"{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}\"\n                method=\"post\">\n            {% csrf_token %}\n            {% for toast in toast_messages %}\n              <div class=\"toast d-flex\">\n                {{ toast.message }}\n                <button type=\"submit\"\n                        name=\"toast\"\n                        value=\"{{ toast.id }}\"\n                        class=\"btn btn-clear\"></button>\n              </div>\n            {% endfor %}\n          </form>\n        </div>\n      {% endif %}\n      <div class=\"d-flex justify-between\">\n        <a href=\"{% url 'linkding:root' %}\" class=\"app-link d-flex align-center\">\n          <img class=\"app-logo\" src=\"{% static 'logo.png' %}\" alt=\"Application logo\">\n          <span class=\"app-name\">LINKDING</span>\n        </a>\n        <nav>\n          {% if request.user.is_authenticated %}\n            {# Only show nav items menu when logged in #}\n            {% include 'shared/nav_menu.html' %}\n          {% else %}\n            {# Otherwise show login link #}\n            <a href=\"{% url 'login' %}\" class=\"btn btn-link\">Login</a>\n          {% endif %}\n        </nav>\n      </div>\n    </header>\n    <div class=\"content container\">\n      {% block content %}{% endblock %}\n    </div>\n    <div class=\"modals\">\n      {% block overlays %}{% endblock %}\n    </div>\n    {% include 'shared/dev_tool.html' %}\n  </body>\n</html>\n"
  },
  {
    "path": "bookmarks/templates/shared/messages.html",
    "content": "{% if messages %}\n  <div class=\"message-list\">\n    {% for message in messages %}<div class=\"toast toast-{{ message.tags }}\" role=\"alert\">{{ message }}</div>{% endfor %}\n  </div>\n{% endif %}\n"
  },
  {
    "path": "bookmarks/templates/shared/modal_header.html",
    "content": "{% load static %}\n<div class=\"modal-header\">\n  <h2 class=\"title\">{{ title }}</h2>\n  <button type=\"button\"\n          class=\"btn btn-noborder close\"\n          aria-label=\"Close dialog\"\n          data-close-modal>\n    <svg width=\"24\" height=\"24\">\n      <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#close\"></use>\n    </svg>\n  </button>\n</div>\n"
  },
  {
    "path": "bookmarks/templates/shared/nav_menu.html",
    "content": "{% load shared static %}\n{% htmlmin %}\n{# Basic menu list #}\n<div class=\"hide-md\">\n  <a href=\"{% url 'linkding:bookmarks.new' %}\"\n     class=\"btn btn-primary mr-2\">Add bookmark</a>\n  <ld-dropdown class=\"dropdown\">\n    <button class=\"btn btn-link dropdown-toggle\" tabindex=\"0\">Bookmarks</button>\n    <ul class=\"menu\" role=\"list\" tabindex=\"-1\">\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.index' %}\" class=\"menu-link\">Active</a>\n      </li>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.archived' %}\" class=\"menu-link\">Archived</a>\n      </li>\n      {% if request.user_profile.enable_sharing %}\n        <li class=\"menu-item\">\n          <a href=\"{% url 'linkding:bookmarks.shared' %}\" class=\"menu-link\">Shared</a>\n        </li>\n      {% endif %}\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.index' %}?unread=yes\"\n           class=\"menu-link\">Unread</a>\n      </li>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.index' %}?q=!untagged\"\n           class=\"menu-link\">Untagged</a>\n      </li>\n    </ul>\n  </ld-dropdown>\n  <ld-dropdown class=\"dropdown\">\n    <button class=\"btn btn-link dropdown-toggle\" tabindex=\"0\">Settings</button>\n    <ul class=\"menu\" role=\"list\" tabindex=\"-1\">\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:settings.general' %}\" class=\"menu-link\">General</a>\n      </li>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:settings.integrations' %}\" class=\"menu-link\">Integrations</a>\n      </li>\n      {% if request.user.is_superuser %}\n        <li class=\"menu-item\">\n          <a href=\"{% url 'admin:index' %}\" class=\"menu-link\" data-turbo=\"false\">Admin</a>\n        </li>\n      {% endif %}\n    </ul>\n  </ld-dropdown>\n  <form class=\"d-inline\"\n        action=\"{% url 'logout' %}\"\n        method=\"post\"\n        data-turbo=\"false\">\n    {% csrf_token %}\n    <button type=\"submit\" class=\"btn btn-link\">Logout</button>\n  </form>\n</div>\n{# Menu drop-down for smaller devices #}\n<div class=\"show-md\">\n  <a href=\"{% url 'linkding:bookmarks.new' %}\"\n     aria-label=\"Add bookmark\"\n     class=\"btn btn-primary\">\n    <svg width=\"24\" height=\"24\">\n      <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#plus\"></use>\n    </svg>\n  </a>\n  <ld-dropdown class=\"dropdown dropdown-right\">\n    <button class=\"btn btn-link dropdown-toggle\"\n            aria-label=\"Navigation menu\"\n            tabindex=\"0\">\n      <svg width=\"24\" height=\"24\">\n        <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#menu\"></use>\n      </svg>\n    </button>\n    <!-- menu component -->\n    <ul class=\"menu\" role=\"list\" tabindex=\"-1\">\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.index' %}\" class=\"menu-link\">Bookmarks</a>\n      </li>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.archived' %}\" class=\"menu-link\">Archived bookmarks</a>\n      </li>\n      {% if request.user_profile.enable_sharing %}\n        <li class=\"menu-item\">\n          <a href=\"{% url 'linkding:bookmarks.shared' %}\" class=\"menu-link\">Shared bookmarks</a>\n        </li>\n      {% endif %}\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.index' %}?unread=yes\"\n           class=\"menu-link\">Unread</a>\n      </li>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:bookmarks.index' %}?q=!untagged\"\n           class=\"menu-link\">Untagged</a>\n      </li>\n      <div class=\"divider\"></div>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:settings.general' %}\" class=\"menu-link\">Settings</a>\n      </li>\n      <li class=\"menu-item\">\n        <a href=\"{% url 'linkding:settings.integrations' %}\" class=\"menu-link\">Integrations</a>\n      </li>\n      {% if request.user.is_superuser %}\n        <li class=\"menu-item\">\n          <a href=\"{% url 'admin:index' %}\" class=\"menu-link\" data-turbo=\"false\">Admin</a>\n        </li>\n      {% endif %}\n      <div class=\"divider\"></div>\n      <li class=\"menu-item\">\n        <form class=\"d-inline\"\n              action=\"{% url 'logout' %}\"\n              method=\"post\"\n              data-turbo=\"false\">\n          {% csrf_token %}\n          <button type=\"submit\" class=\"btn btn-link menu-link\">Logout</button>\n        </form>\n      </li>\n    </ul>\n  </ld-dropdown>\n</div>\n{% endhtmlmin %}\n"
  },
  {
    "path": "bookmarks/templates/shared/pagination.html",
    "content": "{% load shared %}\n<ul class=\"pagination\">\n  {% if prev_link %}\n    <li class=\"page-item\">\n      <a href=\"{{ prev_link }}\"\n         tabindex=\"-1\"\n         data-turbo-frame=\"{{ pagination_frame }}\">Previous</a>\n    </li>\n  {% else %}\n    <li class=\"page-item disabled\">\n      <a href=\"#\" tabindex=\"-1\">Previous</a>\n    </li>\n  {% endif %}\n  {% for page_link in page_links %}\n    {% if page_link %}\n      <li class=\"page-item {% if page_link.active %}active{% endif %}\">\n        <a href=\"{{ page_link.link }}\" data-turbo-frame=\"{{ pagination_frame }}\">{{ page_link.number }}</a>\n      </li>\n    {% else %}\n      <li class=\"page-item\">\n        <span>...</span>\n      </li>\n    {% endif %}\n  {% endfor %}\n  {% if next_link %}\n    <li class=\"page-item\">\n      <a href=\"{{ next_link }}\"\n         tabindex=\"-1\"\n         data-turbo-frame=\"{{ pagination_frame }}\">Next</a>\n    </li>\n  {% else %}\n    <li class=\"page-item disabled\">\n      <a href=\"#\" tabindex=\"-1\">Next</a>\n    </li>\n  {% endif %}\n</ul>\n"
  },
  {
    "path": "bookmarks/templates/shared/top_frame.html",
    "content": "<html lang=\"en\">\n  {% include 'shared/head.html' %}\n  <body>\n    <!--content-->\n  </body>\n</html>\n"
  },
  {
    "path": "bookmarks/templates/tags/edit.html",
    "content": "<turbo-frame id=\"tag-modal\">\n<form method=\"post\"\n      action=\"{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}\"\n      data-turbo-frame=\"tag-main\"\n      novalidate>\n  {% csrf_token %}\n  <ld-modal class=\"modal tag-edit-modal active\"\n            data-close-url=\"{% url 'linkding:tags.index' %}?{{ request.GET.urlencode }}\"\n            data-turbo-frame=\"tag-modal\">\n    <div class=\"modal-overlay\" data-close-modal></div>\n    <div class=\"modal-container\" role=\"dialog\" aria-modal=\"true\">\n      {% include 'shared/modal_header.html' with title=\"Edit Tag\" %}\n      <div class=\"modal-body\">{% include 'tags/form.html' %}</div>\n      <div class=\"modal-footer d-flex justify-between\">\n        <button type=\"button\" class=\"btn btn-wide\" data-close-modal>Cancel</button>\n        <button type=\"submit\" class=\"btn btn-primary btn-wide\">Save</button>\n      </div>\n    </div>\n  </ld-modal>\n</form>\n</turbo-frame>\n"
  },
  {
    "path": "bookmarks/templates/tags/form.html",
    "content": "{% load shared %}\n<div class=\"form-group\">\n  {% formlabel form.name \"Name\" %}\n  {% formfield form.name has_help=True autofocus=True %}\n  {% formhelp form.name %}\n    Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).\n  {% endformhelp %}\n  {{ form.name.errors }}\n</div>\n"
  },
  {
    "path": "bookmarks/templates/tags/index.html",
    "content": "{% extends \"shared/layout.html\" %}\n{% load shared static pagination %}\n{% block head %}\n  {% with page_title=\"Tags - Linkding\" %}{{ block.super }}{% endwith %}\n{% endblock %}\n{% block content %}\n  <div class=\"tags-page crud-page\">\n    <turbo-frame id=\"tag-main\">\n    <main aria-labelledby=\"main-heading\">\n      <div class=\"crud-header\">\n        <h1 id=\"main-heading\">Tags</h1>\n        <div class=\"d-flex gap-2 ml-auto\">\n          <a href=\"{% url 'linkding:tags.new' %}\"\n             data-turbo-frame=\"tag-modal\"\n             class=\"btn\">Create Tag</a>\n          <a href=\"{% url 'linkding:tags.merge' %}\"\n             data-turbo-frame=\"tag-modal\"\n             class=\"btn\">Merge Tags</a>\n        </div>\n      </div>\n      {% include 'shared/messages.html' %}\n      {# Filters #}\n      <div class=\"crud-filters\">\n        <ld-form data-form-reset>\n          <form method=\"get\" class=\"mb-2\" data-turbo-frame=\"_top\">\n            <div class=\"form-group\">\n              <label class=\"form-label text-assistive\" for=\"search\">Search tags</label>\n              <div class=\"input-group\">\n                <input type=\"text\"\n                       id=\"search\"\n                       name=\"search\"\n                       value=\"{{ search }}\"\n                       placeholder=\"Search tags...\"\n                       class=\"form-input\">\n                <button type=\"submit\" class=\"btn input-group-btn\">Search</button>\n              </div>\n            </div>\n            <div class=\"form-group\">\n              <label class=\"form-label text-assistive\" for=\"sort\">Sort by</label>\n              <div class=\"input-group\">\n                <span class=\"input-group-addon text-secondary\">\n                  <svg width=\"20\" height=\"20\">\n                    <use href=\"{% static 'icons.svg' %}?v={{ app_version }}#sort\"></use>\n                  </svg>\n                </span>\n                <select id=\"sort\" name=\"sort\" class=\"form-select\" data-submit-on-change>\n                  <option value=\"name-asc\" {% if sort == \"name-asc\" %}selected{% endif %}>Name A-Z</option>\n                  <option value=\"name-desc\" {% if sort == \"name-desc\" %}selected{% endif %}>Name Z-A</option>\n                  <option value=\"count-asc\" {% if sort == \"count-asc\" %}selected{% endif %}>Fewest bookmarks</option>\n                  <option value=\"count-desc\" {% if sort == \"count-desc\" %}selected{% endif %}>Most bookmarks</option>\n                </select>\n              </div>\n            </div>\n            <div class=\"form-group\">\n              <label class=\"form-checkbox\">\n                <input type=\"checkbox\"\n                       name=\"unused\"\n                       value=\"true\"\n                       {% if unused_only %}checked{% endif %}\n                       data-submit-on-change>\n                <i class=\"form-icon\"></i> Show only unused tags\n              </label>\n            </div>\n          </form>\n        </ld-form>\n        {# Tags count #}\n        <p class=\"text-secondary text-small m-0\">\n          {% if search or unused_only %}\n            Showing {{ page.paginator.count }} of {{ total_tags }} tags\n          {% else %}\n            {{ total_tags }} tags total\n          {% endif %}\n        </p>\n      </div>\n      {# Tags List #}\n      {% if page.object_list %}\n        <form method=\"post\">\n          {% csrf_token %}\n          <table class=\"table crud-table\">\n            <thead>\n              <tr>\n                <th>Name</th>\n                <th style=\"width: 25%\">Bookmarks</th>\n                <th class=\"actions\">\n                  <span class=\"text-assistive\">Actions</span>\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {% for tag in page.object_list %}\n                <tr>\n                  <td>{{ tag.name }}</td>\n                  <td style=\"width: 25%\">\n                    <a class=\"btn btn-link\"\n                       href=\"{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}\">\n                      {{ tag.bookmark_count }}\n                    </a>\n                  </td>\n                  <td class=\"actions\">\n                    <a class=\"btn btn-link\"\n                       href=\"{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}\"\n                       data-turbo-frame=\"tag-modal\">Edit</a>\n                    <button type=\"submit\"\n                            name=\"delete_tag\"\n                            value=\"{{ tag.id }}\"\n                            class=\"btn btn-link text-error\"\n                            data-confirm>Remove</button>\n                  </td>\n                </tr>\n              {% endfor %}\n            </tbody>\n          </table>\n        </form>\n        {% pagination page %}\n      {% else %}\n        <div class=\"empty\">\n          {% if search or unused_only %}\n            <p class=\"empty-title h5\">No tags found</p>\n            <p class=\"empty-subtitle\">Try adjusting your search or filters</p>\n          {% else %}\n            <p class=\"empty-title h5\">You have no tags yet</p>\n            <p class=\"empty-subtitle\">Tags will appear here when you add bookmarks with tags</p>\n          {% endif %}\n        </div>\n      {% endif %}\n    </main>\n    <turbo-frame id=\"tag-modal\"></turbo-frame>\n    </turbo-frame>\n  </div>\n{% endblock %}\n"
  },
  {
    "path": "bookmarks/templates/tags/merge.html",
    "content": "{% load shared %}\n<turbo-frame id=\"tag-modal\">\n<form method=\"post\"\n      action=\"{% url 'linkding:tags.merge' %}\"\n      data-turbo-frame=\"_top\"\n      novalidate>\n  {% csrf_token %}\n  <ld-modal class=\"modal active\"\n            data-close-url=\"{% url 'linkding:tags.index' %}\"\n            data-turbo-frame=\"tag-modal\">\n    <div class=\"modal-overlay\" data-close-modal></div>\n    <div class=\"modal-container\" role=\"dialog\" aria-modal=\"true\">\n      {% include 'shared/modal_header.html' with title=\"Merge Tags\" %}\n      <div class=\"modal-body\">\n        <details class=\"mb-4\">\n          <summary>\n            <span class=\"text-bold mb-1\">How to merge tags</span>\n          </summary>\n          <ol>\n            <li>Enter the name of the tag you want to keep</li>\n            <li>Enter the names of tags to merge into the target tag</li>\n            <li>The target tag is added to all bookmarks that have any of the merge tags</li>\n            <li>The merged tags are deleted</li>\n          </ol>\n        </details>\n        <div class=\"form-group\">\n          {% formlabel form.target_tag \"Target tag\" %}\n          {% formfield form.target_tag has_help=True %}\n          {% formhelp form.target_tag %}\n            Enter the name of the tag you want to keep. The tags entered below will be merged into this one.\n          {% endformhelp %}\n          {{ form.target_tag.errors }}\n        </div>\n        <div class=\"form-group\">\n          {% formlabel form.merge_tags \"Tags to merge\" %}\n          {% formfield form.merge_tags has_help=True %}\n          {% formhelp form.merge_tags %}\n            Enter the names of tags to merge into the target tag, separated by spaces.\n            These tags will be deleted after merging.\n          {% endformhelp %}\n          {{ form.merge_tags.errors }}\n        </div>\n      </div>\n      <div class=\"modal-footer d-flex justify-between\">\n        <button type=\"button\" class=\"btn btn-wide\" data-close-modal>Cancel</button>\n        <button type=\"submit\" class=\"btn btn-primary btn-wide\">Merge Tags</button>\n      </div>\n    </div>\n  </ld-modal>\n</form>\n</turbo-frame>\n"
  },
  {
    "path": "bookmarks/templates/tags/new.html",
    "content": "<turbo-frame id=\"tag-modal\">\n<form method=\"post\"\n      action=\"{% url 'linkding:tags.new' %}\"\n      data-turbo-frame=\"_top\"\n      novalidate>\n  {% csrf_token %}\n  <ld-modal class=\"modal tag-edit-modal active\"\n            data-close-url=\"{% url 'linkding:tags.index' %}\"\n            data-turbo-frame=\"tag-modal\">\n    <div class=\"modal-overlay\" data-close-modal></div>\n    <div class=\"modal-container\" role=\"dialog\" aria-modal=\"true\">\n      {% include 'shared/modal_header.html' with title=\"Create Tag\" %}\n      <div class=\"modal-body\">{% include 'tags/form.html' %}</div>\n      <div class=\"modal-footer d-flex justify-between\">\n        <button type=\"button\" class=\"btn btn-wide\" data-close-modal>Cancel</button>\n        <button type=\"submit\" class=\"btn btn-primary btn-wide\">Save</button>\n      </div>\n    </div>\n  </ld-modal>\n</form>\n</turbo-frame>\n"
  },
  {
    "path": "bookmarks/templatetags/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/templatetags/bookmarks.py",
    "content": "from django import template\n\nfrom bookmarks.forms import BookmarkSearchForm\nfrom bookmarks.models import BookmarkSearch\n\nregister = template.Library()\n\n\n@register.inclusion_tag(\n    \"bookmarks/search.html\", name=\"bookmark_search\", takes_context=True\n)\ndef bookmark_search(context, search: BookmarkSearch, mode: str = \"\"):\n    search_form = BookmarkSearchForm(search, editable_fields=[\"q\"])\n\n    if mode == \"shared\":\n        preferences_form = BookmarkSearchForm(search, editable_fields=[\"sort\"])\n    else:\n        preferences_form = BookmarkSearchForm(\n            search, editable_fields=[\"sort\", \"shared\", \"unread\"]\n        )\n    return {\n        \"request\": context[\"request\"],\n        \"app_version\": context[\"app_version\"],\n        \"search\": search,\n        \"search_form\": search_form,\n        \"preferences_form\": preferences_form,\n        \"mode\": mode,\n    }\n"
  },
  {
    "path": "bookmarks/templatetags/pagination.py",
    "content": "from functools import reduce\n\nfrom django import template\nfrom django.core.paginator import Page\nfrom django.http import QueryDict\n\nNUM_ADJACENT_PAGES = 2\n\nregister = template.Library()\n\n\n@register.inclusion_tag(\"shared/pagination.html\", name=\"pagination\", takes_context=True)\ndef pagination(context, page: Page):\n    request = context[\"request\"]\n    pagination_frame = context.get(\"pagination_frame\", \"_top\")\n    base_url = request.path\n\n    # remove page number and details from query parameters\n    query_params = request.GET.copy()\n    query_params.pop(\"page\", None)\n    query_params.pop(\"details\", None)\n\n    prev_link = (\n        _generate_link(base_url, query_params, page.previous_page_number())\n        if page.has_previous()\n        else None\n    )\n    next_link = (\n        _generate_link(base_url, query_params, page.next_page_number())\n        if page.has_next()\n        else None\n    )\n\n    visible_page_numbers = get_visible_page_numbers(\n        page.number, page.paginator.num_pages\n    )\n    page_links = []\n    for page_number in visible_page_numbers:\n        if page_number == -1:\n            page_links.append(None)\n        else:\n            link = _generate_link(base_url, query_params, page_number)\n            page_links.append(\n                {\n                    \"active\": page_number == page.number,\n                    \"number\": page_number,\n                    \"link\": link,\n                }\n            )\n\n    return {\n        \"prev_link\": prev_link,\n        \"next_link\": next_link,\n        \"page_links\": page_links,\n        \"pagination_frame\": pagination_frame,\n    }\n\n\ndef get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:\n    \"\"\"\n    Generates a list of page indexes that should be rendered\n    The list can contain \"holes\" which indicate that a range of pages are truncated\n    Holes are indicated with a value of `-1`\n    :param current_page_number:\n    :param num_pages:\n    \"\"\"\n    visible_pages = set()\n\n    # Add adjacent pages around current page\n    visible_pages |= set(\n        range(\n            max(1, current_page_number - NUM_ADJACENT_PAGES),\n            min(num_pages, current_page_number + NUM_ADJACENT_PAGES) + 1,\n        )\n    )\n\n    # Add first page\n    visible_pages.add(1)\n\n    # Add last page\n    visible_pages.add(num_pages)\n\n    # Convert to sorted list\n    visible_pages = list(visible_pages)\n    visible_pages.sort()\n\n    def append_page(result: [int], page_number: int):\n        # Look for holes and insert a -1 as indicator\n        is_hole = len(result) > 0 and result[-1] < page_number - 1\n        if is_hole:\n            result.append(-1)\n        result.append(page_number)\n        return result\n\n    return reduce(append_page, visible_pages, [])\n\n\ndef _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:\n    query_params = query_params.copy()\n    query_params[\"page\"] = page_number\n    return f\"{base_url}?{query_params.urlencode()}\"\n"
  },
  {
    "path": "bookmarks/templatetags/shared.py",
    "content": "import re\n\nimport bleach\nimport markdown\nfrom bleach.linkifier import DEFAULT_CALLBACKS, Linker\nfrom bleach_allowlist import markdown_attrs, markdown_tags\nfrom django import template\nfrom django.forms.models import model_to_dict\nfrom django.utils.safestring import mark_safe\n\nfrom bookmarks import utils\nfrom bookmarks.widgets import FormCheckbox\n\nregister = template.Library()\n\n\n@register.simple_tag(takes_context=True)\ndef update_query_string(context, **kwargs):\n    query = context.request.GET.copy()\n\n    # Replace query params with the ones from tag parameters\n    for key in kwargs:\n        query.__setitem__(key, kwargs[key])\n\n    return query.urlencode()\n\n\n@register.simple_tag(takes_context=True)\ndef replace_query_param(context, **kwargs):\n    query = context.request.GET.copy()\n\n    # Create query param or replace existing\n    for key in kwargs:\n        value = kwargs[key]\n        query.__setitem__(key, value)\n\n    return query.urlencode()\n\n\n@register.filter(name=\"first_char\")\ndef first_char(text):\n    return text[0]\n\n\n@register.filter(name=\"remaining_chars\")\ndef remaining_chars(text, index):\n    return text[index:]\n\n\n@register.filter(name=\"humanize_absolute_date\")\ndef humanize_absolute_date(value):\n    if value in (None, \"\"):\n        return \"\"\n    return utils.humanize_absolute_date(value)\n\n\n@register.filter(name=\"humanize_relative_date\")\ndef humanize_relative_date(value):\n    if value in (None, \"\"):\n        return \"\"\n    return utils.humanize_relative_date(value)\n\n\n@register.filter(name=\"model_to_dict\")\ndef model_to_dict_filter(value):\n    result = model_to_dict(value)\n    return result\n\n\n@register.tag\ndef htmlmin(parser, token):\n    nodelist = parser.parse((\"endhtmlmin\",))\n    parser.delete_first_token()\n    return HtmlMinNode(nodelist)\n\n\nclass HtmlMinNode(template.Node):\n    def __init__(self, nodelist):\n        self.nodelist = nodelist\n\n    def render(self, context):\n        output = self.nodelist.render(context)\n\n        output = re.sub(r\"\\s+\", \" \", output)\n\n        return output\n\n\ndef schemeless_urls_to_https(attrs, _new):\n    href_key = (None, \"href\")\n    if href_key not in attrs:\n        return attrs\n\n    if attrs.get(\"_text\", \"\").startswith(\"http://\"):\n        # The original text explicitly specifies http://, so keep it\n        return attrs\n\n    attrs[href_key] = re.sub(r\"^http://\", \"https://\", attrs[href_key])\n    return attrs\n\n\nlinker = Linker(callbacks=[*DEFAULT_CALLBACKS, schemeless_urls_to_https])\n\n\n@register.simple_tag(name=\"markdown\", takes_context=True)\ndef render_markdown(context, markdown_text):\n    # naive approach to reusing the renderer for a single request\n    # works for bookmark list for now\n    if \"markdown_renderer\" not in context:\n        renderer = markdown.Markdown(extensions=[\"fenced_code\", \"nl2br\"])\n        context[\"markdown_renderer\"] = renderer\n    else:\n        renderer = context[\"markdown_renderer\"]\n\n    as_html = renderer.convert(markdown_text)\n    sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)\n    linkified_html = linker.linkify(sanitized_html)\n\n    return mark_safe(linkified_html)\n\n\ndef append_attr(widget, attr, value):\n    attrs = widget.attrs\n    if attrs.get(attr):\n        attrs[attr] += \" \" + value\n    else:\n        attrs[attr] = value\n\n\n@register.simple_tag\ndef formlabel(field, label_text):\n    return mark_safe(\n        f'<label for=\"{field.id_for_label}\" class=\"form-label\">{label_text}</label>'\n    )\n\n\n@register.simple_tag\ndef formfield(field, **kwargs):\n    widget = field.field.widget\n\n    label = kwargs.pop(\"label\", None)\n    if label and isinstance(widget, FormCheckbox):\n        widget.label = label\n\n    if kwargs.pop(\"has_help\", False):\n        append_attr(widget, \"aria-describedby\", field.auto_id + \"_help\")\n\n    has_errors = hasattr(field, \"errors\") and field.errors\n    if has_errors:\n        append_attr(widget, \"class\", \"is-error\")\n        append_attr(widget, \"aria-describedby\", field.auto_id + \"_error\")\n    if field.field.required and not has_errors:\n        append_attr(widget, \"aria-invalid\", \"false\")\n\n    for attr, value in kwargs.items():\n        attr = attr.replace(\"_\", \"-\")\n        if attr == \"class\":\n            append_attr(widget, \"class\", value)\n        else:\n            widget.attrs[attr] = value\n\n    return field.as_widget()\n\n\n@register.tag\ndef formhelp(parser, token):\n    try:\n        tag_name, field_var = token.split_contents()\n    except ValueError:\n        raise template.TemplateSyntaxError(\n            f\"{token.contents.split()[0]!r} tag requires a single argument (form field)\"\n        ) from None\n    nodelist = parser.parse((\"endformhelp\",))\n    parser.delete_first_token()\n    return FormHelpNode(nodelist, field_var)\n\n\nclass FormHelpNode(template.Node):\n    def __init__(self, nodelist, field_var):\n        self.nodelist = nodelist\n        self.field_var = template.Variable(field_var)\n\n    def render(self, context):\n        field = self.field_var.resolve(context)\n        content = self.nodelist.render(context)\n        return f'<div id=\"{field.auto_id}_help\" class=\"form-input-hint\">{content}</div>'\n"
  },
  {
    "path": "bookmarks/tests/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/tests/helpers.py",
    "content": "import gzip\nimport logging\nimport os\nimport random\nimport shutil\nimport tempfile\nfrom datetime import datetime\nfrom unittest import TestCase\n\nfrom bs4 import BeautifulSoup\nfrom django.conf import settings\nfrom django.test import override_settings\nfrom django.utils import timezone\nfrom django.utils.crypto import get_random_string\nfrom rest_framework import status\nfrom rest_framework.test import APITestCase\n\nfrom bookmarks.models import (\n    ApiToken,\n    Bookmark,\n    BookmarkAsset,\n    BookmarkBundle,\n    Tag,\n    User,\n)\n\n\nclass BookmarkFactoryMixin:\n    user = None\n\n    def setup_temp_assets_dir(self):\n        self.assets_dir = tempfile.mkdtemp()\n        self.settings_override = override_settings(LD_ASSET_FOLDER=self.assets_dir)\n        self.settings_override.enable()\n        self.addCleanup(self.cleanup_temp_assets_dir)\n\n    def cleanup_temp_assets_dir(self):\n        shutil.rmtree(self.assets_dir)\n        self.settings_override.disable()\n\n    def get_or_create_test_user(self):\n        if self.user is None:\n            self.user = User.objects.create_user(\n                \"testuser\", \"test@example.com\", \"password123\"\n            )\n\n        return self.user\n\n    def setup_superuser(self):\n        return User.objects.create_superuser(\n            \"superuser\", \"superuser@example.com\", \"password123\"\n        )\n\n    def setup_bookmark(\n        self,\n        is_archived: bool = False,\n        unread: bool = False,\n        shared: bool = False,\n        tags=None,\n        user: User = None,\n        url: str = \"\",\n        title: str = None,\n        description: str = \"\",\n        notes: str = \"\",\n        web_archive_snapshot_url: str = \"\",\n        favicon_file: str = \"\",\n        preview_image_file: str = \"\",\n        added: datetime = None,\n        modified: datetime = None,\n    ):\n        if title is None:\n            title = get_random_string(length=32)\n        if tags is None:\n            tags = []\n        if user is None:\n            user = self.get_or_create_test_user()\n        if not url:\n            unique_id = get_random_string(length=32)\n            url = \"https://example.com/\" + unique_id\n        if added is None:\n            added = timezone.now()\n        if modified is None:\n            modified = timezone.now()\n        bookmark = Bookmark(\n            url=url,\n            title=title,\n            description=description,\n            notes=notes,\n            date_added=added,\n            date_modified=modified,\n            owner=user,\n            is_archived=is_archived,\n            unread=unread,\n            shared=shared,\n            web_archive_snapshot_url=web_archive_snapshot_url,\n            favicon_file=favicon_file,\n            preview_image_file=preview_image_file,\n        )\n        bookmark.save()\n        for tag in tags:\n            bookmark.tags.add(tag)\n        bookmark.save()\n        return bookmark\n\n    def setup_numbered_bookmarks(\n        self,\n        count: int,\n        prefix: str = \"\",\n        suffix: str = \"\",\n        tag_prefix: str = \"\",\n        archived: bool = False,\n        unread: bool = False,\n        shared: bool = False,\n        with_tags: bool = False,\n        with_web_archive_snapshot_url: bool = False,\n        with_favicon_file: bool = False,\n        with_preview_image_file: bool = False,\n        user: User = None,\n    ):\n        user = user or self.get_or_create_test_user()\n        bookmarks = []\n\n        if not prefix:\n            if archived:\n                prefix = \"Archived Bookmark\"\n            elif shared:\n                prefix = \"Shared Bookmark\"\n            else:\n                prefix = \"Bookmark\"\n\n        if not tag_prefix:\n            if archived:\n                tag_prefix = \"Archived Tag\"\n            elif shared:\n                tag_prefix = \"Shared Tag\"\n            else:\n                tag_prefix = \"Tag\"\n\n        for i in range(1, count + 1):\n            title = f\"{prefix} {i}{suffix}\"\n            url = f\"https://example.com/{prefix}/{i}\"\n            tags = []\n            if with_tags:\n                tag_name = f\"{tag_prefix} {i}{suffix}\"\n                tags = [self.setup_tag(name=tag_name, user=user)]\n            web_archive_snapshot_url = \"\"\n            if with_web_archive_snapshot_url:\n                web_archive_snapshot_url = f\"https://web.archive.org/web/{i}\"\n            favicon_file = \"\"\n            if with_favicon_file:\n                favicon_file = f\"favicon_{i}.png\"\n            preview_image_file = \"\"\n            if with_preview_image_file:\n                preview_image_file = f\"preview_image_{i}.png\"\n            bookmark = self.setup_bookmark(\n                url=url,\n                title=title,\n                is_archived=archived,\n                unread=unread,\n                shared=shared,\n                tags=tags,\n                web_archive_snapshot_url=web_archive_snapshot_url,\n                favicon_file=favicon_file,\n                preview_image_file=preview_image_file,\n                user=user,\n            )\n            bookmarks.append(bookmark)\n\n        return bookmarks\n\n    def get_numbered_bookmark(self, title: str):\n        return Bookmark.objects.get(title=title)\n\n    def setup_bundle(\n        self,\n        user: User = None,\n        name: str = None,\n        search: str = \"\",\n        any_tags: str = \"\",\n        all_tags: str = \"\",\n        excluded_tags: str = \"\",\n        filter_unread: str = BookmarkBundle.FILTER_STATE_OFF,\n        filter_shared: str = BookmarkBundle.FILTER_STATE_OFF,\n        order: int = 0,\n    ):\n        if user is None:\n            user = self.get_or_create_test_user()\n        if not name:\n            name = get_random_string(length=32)\n        bundle = BookmarkBundle(\n            name=name,\n            owner=user,\n            date_created=timezone.now(),\n            search=search,\n            any_tags=any_tags,\n            all_tags=all_tags,\n            excluded_tags=excluded_tags,\n            filter_unread=filter_unread,\n            filter_shared=filter_shared,\n            order=order,\n        )\n        bundle.save()\n        return bundle\n\n    def setup_asset(\n        self,\n        bookmark: Bookmark,\n        date_created: datetime = None,\n        file: str = None,\n        file_size: int = None,\n        asset_type: str = BookmarkAsset.TYPE_SNAPSHOT,\n        content_type: str = \"image/html\",\n        display_name: str = None,\n        status: str = BookmarkAsset.STATUS_COMPLETE,\n        gzip: bool = False,\n    ):\n        if date_created is None:\n            date_created = timezone.now()\n        if not file:\n            file = get_random_string(length=32)\n        if not display_name:\n            display_name = file\n        asset = BookmarkAsset(\n            bookmark=bookmark,\n            date_created=date_created,\n            file=file,\n            file_size=file_size,\n            asset_type=asset_type,\n            content_type=content_type,\n            display_name=display_name,\n            status=status,\n            gzip=gzip,\n        )\n        asset.save()\n        return asset\n\n    def setup_asset_file(self, asset: BookmarkAsset, file_content: str = \"test\"):\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)\n        if asset.gzip:\n            with gzip.open(filepath, \"wb\") as f:\n                f.write(file_content.encode())\n        else:\n            with open(filepath, \"w\") as f:\n                f.write(file_content)\n\n    def read_asset_file(self, asset: BookmarkAsset):\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)\n\n        if asset.gzip:\n            with gzip.open(filepath, \"rb\") as f:\n                return f.read()\n        else:\n            with open(filepath, \"rb\") as f:\n                return f.read()\n\n    def get_asset_filesize(self, asset: BookmarkAsset):\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)\n        return os.path.getsize(filepath) if os.path.exists(filepath) else 0\n\n    def has_asset_file(self, asset: BookmarkAsset):\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)\n        return os.path.exists(filepath)\n\n    def setup_tag(self, user: User = None, name: str = \"\"):\n        if user is None:\n            user = self.get_or_create_test_user()\n        if not name:\n            name = get_random_string(length=32)\n        tag = Tag(name=name, date_added=timezone.now(), owner=user)\n        tag.save()\n        return tag\n\n    def setup_user(\n        self,\n        name: str = None,\n        enable_sharing: bool = False,\n        enable_public_sharing: bool = False,\n    ):\n        if not name:\n            name = get_random_string(length=32)\n        user = User.objects.create_user(name, \"user@example.com\", \"password123\")\n        user.profile.enable_sharing = enable_sharing\n        user.profile.enable_public_sharing = enable_public_sharing\n        user.profile.save()\n        return user\n\n    def setup_api_token(self, user: User = None, name: str = \"\"):\n        if user is None:\n            user = self.get_or_create_test_user()\n        if not name:\n            name = get_random_string(length=32)\n        token = ApiToken(user=user, name=name)\n        token.save()\n        return token\n\n    def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):\n        all_tags = []\n        for bookmark in bookmarks:\n            all_tags = all_tags + list(bookmark.tags.all())\n        return all_tags\n\n    def get_random_string(self, length: int = 32):\n        return get_random_string(length=length)\n\n\nclass HtmlTestMixin:\n    def make_soup(self, html: str):\n        return BeautifulSoup(html, features=\"html.parser\")\n\n\nclass BookmarkListTestMixin(TestCase, HtmlTestMixin):\n    def assertVisibleBookmarks(\n        self, response, bookmarks: list[Bookmark], link_target: str = \"_blank\"\n    ):\n        soup = self.make_soup(response.content.decode())\n        bookmark_list = soup.select_one(\n            f'ul.bookmark-list[data-bookmarks-total=\"{len(bookmarks)}\"]'\n        )\n        self.assertIsNotNone(bookmark_list)\n\n        bookmark_items = bookmark_list.select(\"ul.bookmark-list > li\")\n        self.assertEqual(len(bookmark_items), len(bookmarks))\n\n        for bookmark in bookmarks:\n            bookmark_item = bookmark_list.select_one(\n                f'ul.bookmark-list > li a[href=\"{bookmark.url}\"][target=\"{link_target}\"]'\n            )\n            self.assertIsNotNone(bookmark_item)\n\n    def assertInvisibleBookmarks(\n        self, response, bookmarks: list[Bookmark], link_target: str = \"_blank\"\n    ):\n        soup = self.make_soup(response.content.decode())\n\n        for bookmark in bookmarks:\n            bookmark_item = soup.select_one(\n                f'ul.bookmark-list > li a[href=\"{bookmark.url}\"][target=\"{link_target}\"]'\n            )\n            self.assertIsNone(bookmark_item)\n\n\nclass TagCloudTestMixin(TestCase, HtmlTestMixin):\n    def assertVisibleTags(self, response, tags: list[Tag]):\n        soup = self.make_soup(response.content.decode())\n        tag_cloud = soup.select_one(\"div.tag-cloud\")\n        self.assertIsNotNone(tag_cloud)\n\n        tag_items = tag_cloud.select(\"a[data-is-tag-item]\")\n        self.assertEqual(len(tag_items), len(tags))\n\n        tag_item_names = [tag_item.text.strip() for tag_item in tag_items]\n\n        for tag in tags:\n            self.assertTrue(tag.name in tag_item_names)\n\n    def assertInvisibleTags(self, response, tags: list[Tag]):\n        soup = self.make_soup(response.content.decode())\n        tag_items = soup.select(\"a[data-is-tag-item]\")\n\n        tag_item_names = [tag_item.text.strip() for tag_item in tag_items]\n\n        for tag in tags:\n            self.assertFalse(tag.name in tag_item_names)\n\n    def assertSelectedTags(self, response, tags: list[Tag]):\n        soup = self.make_soup(response.content.decode())\n        selected_tags = soup.select_one(\"p.selected-tags\")\n        self.assertIsNotNone(selected_tags)\n\n        tag_list = selected_tags.select(\"a\")\n        self.assertEqual(len(tag_list), len(tags))\n\n        for tag in tags:\n            self.assertTrue(\n                tag.name in selected_tags.text,\n                msg=f\"Selected tags do not contain: {tag.name}\",\n            )\n\n\nclass LinkdingApiTestCase(APITestCase):\n    def authenticate(self):\n        user = self.get_or_create_test_user()\n        self.api_token = ApiToken(user=user, name=\"Test Token\")\n        self.api_token.save()\n        self.client.credentials(HTTP_AUTHORIZATION=\"Token \" + self.api_token.key)\n\n    def get(self, url, expected_status_code=status.HTTP_200_OK):\n        response = self.client.get(url)\n        self.assertEqual(response.status_code, expected_status_code)\n        return response\n\n    def post(self, url, data=None, expected_status_code=status.HTTP_200_OK):\n        response = self.client.post(url, data, format=\"json\")\n        self.assertEqual(response.status_code, expected_status_code)\n        return response\n\n    def put(self, url, data=None, expected_status_code=status.HTTP_200_OK):\n        response = self.client.put(url, data, format=\"json\")\n        self.assertEqual(response.status_code, expected_status_code)\n        return response\n\n    def patch(self, url, data=None, expected_status_code=status.HTTP_200_OK):\n        response = self.client.patch(url, data, format=\"json\")\n        self.assertEqual(response.status_code, expected_status_code)\n        return response\n\n    def delete(self, url, expected_status_code=status.HTTP_200_OK):\n        response = self.client.delete(url)\n        self.assertEqual(response.status_code, expected_status_code)\n        return response\n\n\nclass BookmarkHtmlTag:\n    def __init__(\n        self,\n        href: str = \"\",\n        title: str = \"\",\n        description: str = \"\",\n        add_date: str = \"\",\n        last_modified: str = \"\",\n        tags: str = \"\",\n        to_read: bool = False,\n        private: bool = True,\n    ):\n        self.href = href\n        self.title = title\n        self.description = description\n        self.add_date = add_date\n        self.last_modified = last_modified\n        self.tags = tags\n        self.to_read = to_read\n        self.private = private\n\n\nclass ImportTestMixin:\n    def render_tag(self, tag: BookmarkHtmlTag):\n        return f\"\"\"\n        <DT>\n        <A {f'HREF=\"{tag.href}\"' if tag.href else \"\"}\n           {f'ADD_DATE=\"{tag.add_date}\"' if tag.add_date else \"\"}\n           {f'LAST_MODIFIED=\"{tag.last_modified}\"' if tag.last_modified else \"\"}\n           {f'TAGS=\"{tag.tags}\"' if tag.tags else \"\"}\n           TOREAD=\"{1 if tag.to_read else 0}\"\n           PRIVATE=\"{1 if tag.private else 0}\">\n           {tag.title if tag.title else \"\"}\n        </A>\n        {f\"<DD>{tag.description}\" if tag.description else \"\"}\n        \"\"\"\n\n    def render_html(self, tags: list[BookmarkHtmlTag] = None, tags_html: str = \"\"):\n        if tags:\n            rendered_tags = [self.render_tag(tag) for tag in tags]\n            tags_html = \"\\n\".join(rendered_tags)\n        return f\"\"\"\n        <!DOCTYPE NETSCAPE-Bookmark-file-1>\n        <META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n        <TITLE>Bookmarks</TITLE>\n        <H1>Bookmarks</H1>\n        <DL><p>\n        {tags_html}\n        </DL><p>\n        \"\"\"\n\n\n_words = [\n    \"quasi\",\n    \"consequatur\",\n    \"necessitatibus\",\n    \"debitis\",\n    \"quod\",\n    \"vero\",\n    \"qui\",\n    \"commodi\",\n    \"quod\",\n    \"odio\",\n    \"aliquam\",\n    \"veniam\",\n    \"architecto\",\n    \"consequatur\",\n    \"autem\",\n    \"qui\",\n    \"iste\",\n    \"asperiores\",\n    \"soluta\",\n    \"et\",\n]\n\n\ndef random_sentence(num_words: int = None, including_word: str = \"\"):\n    if num_words is None:\n        num_words = random.randint(5, 10)\n    selected_words = random.choices(_words, k=num_words)\n    if including_word:\n        selected_words.append(including_word)\n    random.shuffle(selected_words)\n\n    return \" \".join(selected_words)\n\n\ndef disable_logging(f):\n    def wrapper(*args):\n        logging.disable(logging.CRITICAL)\n        result = f(*args)\n        logging.disable(logging.NOTSET)\n\n        return result\n\n    return wrapper\n\n\ndef collapse_whitespace(text: str):\n    text = text.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n    return \" \".join(text.split())\n"
  },
  {
    "path": "bookmarks/tests/resources/simple_valid_import_file.html",
    "content": "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n\n<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n\n<TITLE>Bookmarks</TITLE>\n\n<H1>Bookmarks</H1>\n\n<DL><p>\n\n    <DT><A HREF=\"https://example.com/1\" ADD_DATE=\"1616337559\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"tag1\">test title 1</A>\n    <DD>test description 1\n\n    <DT><A HREF=\"https://example.com/2\" ADD_DATE=\"1616337559000\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"tag2\">test title 2</A>\n    <DD>test description 2\n\n    <DT><A HREF=\"https://example.com/3\" ADD_DATE=\"1616337559000000\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"tag3\">test title 3</A>\n    <DD>test description 3\n\n</DL><p>"
  },
  {
    "path": "bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html",
    "content": "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n\n<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n\n<TITLE>Bookmarks</TITLE>\n\n<H1>Bookmarks</H1>\n\n<DL><p>\n\n    <DT><A HREF=\"https://example.com/1\" ADD_DATE=\"invaliddate\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"tag1\">test title 1</A>\n    <DD>test description 1\n\n    <DT><A HREF=\"https://example.com/2\" ADD_DATE=\"1616337559\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"tag2\">test title 2</A>\n    <DD>test description 2\n\n    <DT><A HREF=\"https://example.com/3\" ADD_DATE=\"1616337559\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"tag3\">test title 3</A>\n    <DD>test description 3\n\n</DL><p>"
  },
  {
    "path": "bookmarks/tests/test_app_options.py",
    "content": "import importlib\nimport os\nfrom unittest import mock\n\nfrom django.test import TestCase\n\n\nclass AppOptionsTestCase(TestCase):\n    def setUp(self) -> None:\n        self.settings_module = importlib.import_module(\"bookmarks.settings.base\")\n\n    def test_empty_csrf_trusted_origins(self):\n        module = importlib.reload(self.settings_module)\n\n        self.assertFalse(hasattr(module, \"CSRF_TRUSTED_ORIGINS\"))\n\n    @mock.patch.dict(\n        os.environ, {\"LD_CSRF_TRUSTED_ORIGINS\": \"https://linkding.example.com\"}\n    )\n    def test_single_csrf_trusted_origin(self):\n        module = importlib.reload(self.settings_module)\n\n        self.assertTrue(hasattr(module, \"CSRF_TRUSTED_ORIGINS\"))\n        self.assertCountEqual(\n            module.CSRF_TRUSTED_ORIGINS, [\"https://linkding.example.com\"]\n        )\n\n    @mock.patch.dict(\n        os.environ,\n        {\n            \"LD_CSRF_TRUSTED_ORIGINS\": \"https://linkding.example.com,http://linkding.example.com\"\n        },\n    )\n    def test_multiple_csrf_trusted_origin(self):\n        module = importlib.reload(self.settings_module)\n\n        self.assertTrue(hasattr(module, \"CSRF_TRUSTED_ORIGINS\"))\n        self.assertCountEqual(\n            module.CSRF_TRUSTED_ORIGINS,\n            [\"https://linkding.example.com\", \"http://linkding.example.com\"],\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_assets_service.py",
    "content": "import datetime\nimport gzip\nimport os\nfrom datetime import timedelta\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom django.test import TestCase, override_settings\nfrom django.utils import timezone\n\nfrom bookmarks.models import BookmarkAsset\nfrom bookmarks.services import assets\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging\n\n\nclass AssetServiceTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.setup_temp_assets_dir()\n        self.get_or_create_test_user()\n\n        self.html_content = \"<html><body><h1>Hello, World!</h1></body></html>\"\n        self.pdf_content = b\"%PDF-1.4 test pdf content\"\n\n        self.mock_singlefile_create_snapshot_patcher = mock.patch(\n            \"bookmarks.services.singlefile.create_snapshot\",\n        )\n        self.mock_singlefile_create_snapshot = (\n            self.mock_singlefile_create_snapshot_patcher.start()\n        )\n        self.mock_singlefile_create_snapshot.side_effect = lambda url, filepath: (\n            Path(filepath).write_text(self.html_content)\n        )\n\n        # Mock detect_content_type to return text/html by default\n        self.mock_detect_content_type_patcher = mock.patch(\n            \"bookmarks.services.assets.detect_content_type\",\n        )\n        self.mock_detect_content_type = self.mock_detect_content_type_patcher.start()\n        self.mock_detect_content_type.return_value = \"text/html\"\n\n        # Mock is_pdf_content_type to return False by default\n        self.mock_is_pdf_content_type_patcher = mock.patch(\n            \"bookmarks.services.assets.is_pdf_content_type\",\n        )\n        self.mock_is_pdf_content_type = self.mock_is_pdf_content_type_patcher.start()\n        self.mock_is_pdf_content_type.return_value = False\n\n    def tearDown(self) -> None:\n        self.mock_singlefile_create_snapshot_patcher.stop()\n        self.mock_detect_content_type_patcher.stop()\n        self.mock_is_pdf_content_type_patcher.stop()\n\n    def get_saved_snapshot_file(self):\n        # look up first file in the asset folder\n        files = os.listdir(self.assets_dir)\n        if files:\n            return files[0]\n\n    def create_mock_pdf_response(self, content=None, content_length=None):\n        if content is None:\n            content = self.pdf_content\n        mock_response = mock.Mock()\n        mock_response.status_code = 200\n        mock_response.headers = {\"Content-Type\": \"application/pdf\"}\n        if content_length is not None:\n            mock_response.headers[\"Content-Length\"] = str(content_length)\n        mock_response.iter_content = mock.Mock(return_value=[content])\n        mock_response.raise_for_status = mock.Mock()\n        mock_response.__enter__ = mock.Mock(return_value=mock_response)\n        mock_response.__exit__ = mock.Mock(return_value=False)\n        return mock_response\n\n    def test_create_snapshot_asset(self):\n        bookmark = self.setup_bookmark()\n\n        asset = assets.create_snapshot_asset(bookmark)\n\n        self.assertIsNotNone(asset)\n        self.assertEqual(asset.bookmark, bookmark)\n        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)\n        self.assertEqual(asset.content_type, \"\")\n        self.assertEqual(asset.display_name, \"New snapshot\")\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)\n\n        # asset is not saved to the database\n        self.assertIsNone(asset.id)\n\n    def test_create_snapshot(self):\n        initial_modified = timezone.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(\n            url=\"https://example.com\", modified=initial_modified\n        )\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n        asset.date_created = timezone.datetime(\n            2023, 8, 11, 21, 45, 11, tzinfo=datetime.UTC\n        )\n\n        assets.create_snapshot(asset)\n\n        expected_temp_filename = \"snapshot_2023-08-11_214511_https___example.com.tmp\"\n        expected_temp_filepath = os.path.join(self.assets_dir, expected_temp_filename)\n        expected_filename = \"snapshot_2023-08-11_214511_https___example.com.html.gz\"\n        expected_filepath = os.path.join(self.assets_dir, expected_filename)\n\n        # should call singlefile.create_snapshot with the correct arguments\n        self.mock_singlefile_create_snapshot.assert_called_once_with(\n            \"https://example.com\",\n            expected_temp_filepath,\n        )\n\n        # should create gzip file in asset folder\n        self.assertTrue(os.path.exists(expected_filepath))\n\n        # gzip file should contain the correct content\n        with gzip.open(expected_filepath, \"rb\") as gz_file:\n            self.assertEqual(gz_file.read().decode(), self.html_content)\n\n        # should remove temporary file\n        self.assertFalse(os.path.exists(expected_temp_filepath))\n\n        # should update asset status and file\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)\n        self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)\n        self.assertIn(\"HTML snapshot from\", asset.display_name)\n        self.assertEqual(asset.file, expected_filename)\n        self.assertTrue(asset.gzip)\n\n        # should update bookmark modified date\n        bookmark.refresh_from_db()\n\n    def test_create_snapshot_failure(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n\n        self.mock_singlefile_create_snapshot.side_effect = RuntimeError(\n            \"Snapshot failed\"\n        )\n\n        with self.assertRaises(RuntimeError):\n            assets.create_snapshot(asset)\n\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)\n\n    def test_create_snapshot_truncates_asset_file_name(self):\n        # Create a bookmark with a very long URL\n        long_url = \"http://\" + \"a\" * 300 + \".com\"\n        bookmark = self.setup_bookmark(url=long_url)\n\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n        assets.create_snapshot(asset)\n\n        saved_file = self.get_saved_snapshot_file()\n\n        self.assertEqual(192, len(saved_file))\n        self.assertTrue(saved_file.startswith(\"snapshot_\"))\n        self.assertTrue(saved_file.endswith(\"aaaa.html.gz\"))\n\n    def test_create_pdf_snapshot(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com/doc.pdf\")\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n        asset.date_created = timezone.datetime(\n            2023, 8, 11, 21, 45, 11, tzinfo=datetime.UTC\n        )\n\n        self.mock_detect_content_type.return_value = \"application/pdf\"\n        self.mock_is_pdf_content_type.return_value = True\n\n        with mock.patch(\"bookmarks.services.assets.requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_pdf_response()\n            assets.create_snapshot(asset)\n\n        expected_filename = (\n            \"snapshot_2023-08-11_214511_https___example.com_doc.pdf.pdf.gz\"\n        )\n        expected_filepath = os.path.join(self.assets_dir, expected_filename)\n\n        # should create gzip file in asset folder\n        self.assertTrue(os.path.exists(expected_filepath))\n\n        # gzip file should contain the correct content\n        with gzip.open(expected_filepath, \"rb\") as gz_file:\n            self.assertEqual(gz_file.read(), self.pdf_content)\n\n        # should update asset status and file\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)\n        self.assertEqual(asset.file, expected_filename)\n        self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_PDF)\n        self.assertIn(\"PDF download from\", asset.display_name)\n        self.assertTrue(asset.gzip)\n\n        # should update bookmark\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, asset)\n\n    def test_create_snapshot_falls_back_to_singlefile_when_detection_fails(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n\n        self.mock_detect_content_type.return_value = None  # Detection failed\n\n        assets.create_snapshot(asset)\n\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)\n        self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)\n        self.mock_singlefile_create_snapshot.assert_called()\n\n    @override_settings(LD_SNAPSHOT_PDF_MAX_SIZE=100)\n    def test_create_pdf_snapshot_fails_when_content_length_exceeds_limit(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com/doc.pdf\")\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n\n        self.mock_detect_content_type.return_value = \"application/pdf\"\n        self.mock_is_pdf_content_type.return_value = True\n\n        with mock.patch(\"bookmarks.services.assets.requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_pdf_response(\n                content_length=1000  # Exceeds 100 byte limit\n            )\n\n            with self.assertRaises(assets.PdfTooLargeError):\n                assets.create_snapshot(asset)\n\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)\n\n    @override_settings(LD_SNAPSHOT_PDF_MAX_SIZE=100)\n    def test_create_pdf_snapshot_fails_when_download_exceeds_limit(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com/doc.pdf\")\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n\n        large_content = b\"x\" * 150  # Exceeds 100 byte limit\n\n        self.mock_detect_content_type.return_value = \"application/pdf\"\n        self.mock_is_pdf_content_type.return_value = True\n\n        with mock.patch(\"bookmarks.services.assets.requests.get\") as mock_get:\n            # Response without Content-Length header, will fail during streaming\n            mock_get.return_value = self.create_mock_pdf_response(content=large_content)\n\n            with self.assertRaises(assets.PdfTooLargeError):\n                assets.create_snapshot(asset)\n\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)\n\n    def test_create_pdf_snapshot_failure(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com/doc.pdf\")\n        asset = assets.create_snapshot_asset(bookmark)\n        asset.save()\n\n        self.mock_detect_content_type.return_value = \"application/pdf\"\n        self.mock_is_pdf_content_type.return_value = True\n\n        with mock.patch(\"bookmarks.services.assets.requests.get\") as mock_get:\n            import requests\n\n            mock_get.side_effect = requests.RequestException(\"Download failed\")\n\n            with self.assertRaises(requests.RequestException):\n                assets.create_snapshot(asset)\n\n        asset.refresh_from_db()\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)\n\n    def test_upload_snapshot(self):\n        initial_modified = timezone.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(\n            url=\"https://example.com\", modified=initial_modified\n        )\n        asset = assets.upload_snapshot(bookmark, self.html_content.encode())\n\n        # should create gzip file in asset folder\n        saved_file_name = self.get_saved_snapshot_file()\n        self.assertIsNotNone(saved_file_name)\n\n        # verify file name\n        self.assertTrue(saved_file_name.startswith(\"snapshot_\"))\n        self.assertTrue(saved_file_name.endswith(\"_https___example.com.html.gz\"))\n\n        # gzip file should contain the correct content\n        with gzip.open(os.path.join(self.assets_dir, saved_file_name), \"rb\") as gz_file:\n            self.assertEqual(gz_file.read().decode(), self.html_content)\n\n        # should create asset\n        self.assertIsNotNone(asset.id)\n        self.assertEqual(asset.bookmark, bookmark)\n        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)\n        self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)\n        self.assertIn(\"HTML snapshot from\", asset.display_name)\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)\n        self.assertEqual(asset.file, saved_file_name)\n        self.assertTrue(asset.gzip)\n\n        # should update bookmark modified date\n        bookmark.refresh_from_db()\n        self.assertGreater(bookmark.date_modified, initial_modified)\n\n    def test_upload_snapshot_failure(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n\n        # make gzip.open raise an exception\n        with mock.patch(\"gzip.open\") as mock_gzip_open:\n            mock_gzip_open.side_effect = OSError(\"File operation failed\")\n\n            with self.assertRaises(OSError):\n                assets.upload_snapshot(bookmark, b\"invalid content\")\n\n        # asset is not saved to the database\n        self.assertIsNone(BookmarkAsset.objects.first())\n\n    def test_upload_snapshot_truncates_asset_file_name(self):\n        # Create a bookmark with a very long URL\n        long_url = \"http://\" + \"a\" * 300 + \".com\"\n        bookmark = self.setup_bookmark(url=long_url)\n\n        assets.upload_snapshot(bookmark, self.html_content.encode())\n\n        saved_file = self.get_saved_snapshot_file()\n\n        self.assertEqual(192, len(saved_file))\n        self.assertTrue(saved_file.startswith(\"snapshot_\"))\n        self.assertTrue(saved_file.endswith(\"aaaa.html.gz\"))\n\n    @disable_logging\n    def test_upload_asset(self):\n        initial_modified = timezone.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(modified=initial_modified)\n        file_content = b\"test content\"\n        upload_file = SimpleUploadedFile(\n            \"test_file.txt\", file_content, content_type=\"text/plain\"\n        )\n\n        asset = assets.upload_asset(bookmark, upload_file)\n\n        # should create file in asset folder\n        saved_file_name = self.get_saved_snapshot_file()\n        self.assertIsNotNone(upload_file)\n\n        # verify file name\n        self.assertTrue(saved_file_name.startswith(\"upload_\"))\n        self.assertTrue(saved_file_name.endswith(\"_test_file.txt.gz\"))\n\n        # file should contain the correct content\n        self.assertEqual(self.read_asset_file(asset), file_content)\n\n        # should create asset\n        self.assertIsNotNone(asset.id)\n        self.assertEqual(asset.bookmark, bookmark)\n        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)\n        self.assertEqual(asset.content_type, upload_file.content_type)\n        self.assertEqual(asset.display_name, upload_file.name)\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)\n        self.assertEqual(asset.file, saved_file_name)\n        self.assertEqual(asset.file_size, self.get_asset_filesize(asset))\n        self.assertTrue(asset.gzip)\n\n        # should update bookmark modified date\n        bookmark.refresh_from_db()\n        self.assertGreater(bookmark.date_modified, initial_modified)\n\n    @disable_logging\n    def test_upload_gzip_asset(self):\n        initial_modified = timezone.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(modified=initial_modified)\n        file_content = gzip.compress(b\"<html>test content</html>\")\n        upload_file = SimpleUploadedFile(\n            \"test_file.html.gz\", file_content, content_type=\"application/gzip\"\n        )\n\n        asset = assets.upload_asset(bookmark, upload_file)\n\n        # should create file in asset folder\n        saved_file_name = self.get_saved_snapshot_file()\n        self.assertIsNotNone(upload_file)\n\n        # verify file name\n        self.assertTrue(saved_file_name.startswith(\"upload_\"))\n        self.assertTrue(saved_file_name.endswith(\"_test_file.html.gz\"))\n\n        # file should contain the correct content\n        self.assertEqual(self.read_asset_file(asset), file_content)\n\n        # should create asset\n        self.assertIsNotNone(asset.id)\n        self.assertEqual(asset.bookmark, bookmark)\n        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)\n        self.assertEqual(asset.content_type, \"application/gzip\")\n        self.assertEqual(asset.display_name, upload_file.name)\n        self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)\n        self.assertEqual(asset.file, saved_file_name)\n        self.assertEqual(asset.file_size, len(file_content))\n        self.assertFalse(asset.gzip)\n\n        # should update bookmark modified date\n        bookmark.refresh_from_db()\n        self.assertGreater(bookmark.date_modified, initial_modified)\n\n    @disable_logging\n    def test_upload_asset_truncates_asset_file_name(self):\n        # Create a bookmark with a very long URL\n        long_file_name = \"a\" * 300 + \".txt\"\n        bookmark = self.setup_bookmark()\n\n        file_content = b\"test content\"\n        upload_file = SimpleUploadedFile(\n            long_file_name, file_content, content_type=\"text/plain\"\n        )\n\n        assets.upload_asset(bookmark, upload_file)\n\n        saved_file = self.get_saved_snapshot_file()\n\n        self.assertEqual(192, len(saved_file))\n        self.assertTrue(saved_file.startswith(\"upload_\"))\n        self.assertTrue(saved_file.endswith(\"aaaa.txt.gz\"))\n\n    @disable_logging\n    def test_upload_asset_failure(self):\n        bookmark = self.setup_bookmark()\n        upload_file = SimpleUploadedFile(\"test_file.txt\", b\"test content\")\n\n        # make open raise an exception\n        with mock.patch(\"builtins.open\") as mock_open:\n            mock_open.side_effect = OSError(\"File operation failed\")\n\n            with self.assertRaises(OSError):\n                assets.upload_asset(bookmark, upload_file)\n\n        # asset is not saved to the database\n        self.assertIsNone(BookmarkAsset.objects.first())\n\n    def test_create_snapshot_updates_bookmark_latest_snapshot(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        first_asset = assets.create_snapshot_asset(bookmark)\n        first_asset.save()\n\n        assets.create_snapshot(first_asset)\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.latest_snapshot, first_asset)\n\n        second_asset = assets.create_snapshot_asset(bookmark)\n        second_asset.save()\n\n        assets.create_snapshot(second_asset)\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.latest_snapshot, second_asset)\n\n    def test_upload_snapshot_updates_bookmark_latest_snapshot(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n\n        first_asset = assets.upload_snapshot(bookmark, self.html_content.encode())\n\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, first_asset)\n\n        second_asset = assets.upload_snapshot(bookmark, self.html_content.encode())\n\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, second_asset)\n        self.assertNotEqual(bookmark.latest_snapshot, first_asset)\n\n    def test_create_snapshot_failure_does_not_update_latest_snapshot(self):\n        # Create a bookmark with an existing latest_snapshot\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)\n\n        # Create a new snapshot asset that will fail\n        failing_asset = assets.create_snapshot_asset(bookmark)\n        failing_asset.save()\n\n        # Make the snapshot creation fail\n        self.mock_singlefile_create_snapshot.side_effect = RuntimeError(\n            \"Snapshot creation failed\"\n        )\n\n        # Attempt to create a snapshot (which will fail)\n        with self.assertRaises(RuntimeError):\n            assets.create_snapshot(failing_asset)\n\n        # Verify that the bookmark's latest_snapshot is still the initial snapshot\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)\n\n    def test_upload_snapshot_failure_does_not_update_latest_snapshot(self):\n        # Create a bookmark with an existing latest_snapshot\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)\n\n        # Make the gzip.open function fail\n        with mock.patch(\"gzip.open\") as mock_gzip_open:\n            mock_gzip_open.side_effect = OSError(\"Upload failed\")\n\n            # Attempt to upload a snapshot (which will fail)\n            with self.assertRaises(OSError):\n                assets.upload_snapshot(bookmark, b\"New content\")\n\n        # Verify that the bookmark's latest_snapshot is still the initial snapshot\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)\n\n    def test_remove_latest_snapshot_updates_bookmark(self):\n        # Create a bookmark with multiple snapshots\n        bookmark = self.setup_bookmark()\n\n        # Create base time (1 hour ago)\n        base_time = timezone.now() - timedelta(hours=1)\n\n        # Create three snapshots with explicitly different dates\n        old_asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n            file=\"old_snapshot.html.gz\",\n            date_created=base_time,\n        )\n        self.setup_asset_file(old_asset)\n\n        middle_asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n            file=\"middle_snapshot.html.gz\",\n            date_created=base_time + timedelta(minutes=30),\n        )\n        self.setup_asset_file(middle_asset)\n\n        latest_asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n            file=\"latest_snapshot.html.gz\",\n            date_created=base_time + timedelta(minutes=60),\n        )\n        self.setup_asset_file(latest_asset)\n\n        # Set the latest asset as the bookmark's latest_snapshot\n        bookmark.latest_snapshot = latest_asset\n        bookmark.save()\n\n        # Delete the latest snapshot\n        assets.remove_asset(latest_asset)\n        bookmark.refresh_from_db()\n\n        # Verify that middle_asset is now the latest_snapshot\n        self.assertEqual(bookmark.latest_snapshot, middle_asset)\n\n        # Delete the middle snapshot\n        assets.remove_asset(middle_asset)\n        bookmark.refresh_from_db()\n\n        # Verify that old_asset is now the latest_snapshot\n        self.assertEqual(bookmark.latest_snapshot, old_asset)\n\n        # Delete the last snapshot\n        assets.remove_asset(old_asset)\n        bookmark.refresh_from_db()\n\n        # Verify that latest_snapshot is now None\n        self.assertIsNone(bookmark.latest_snapshot)\n\n    def test_remove_non_latest_snapshot_does_not_affect_bookmark(self):\n        # Create a bookmark with multiple snapshots\n        bookmark = self.setup_bookmark()\n\n        # Create base time (1 hour ago)\n        base_time = timezone.now() - timedelta(hours=1)\n\n        # Create two snapshots with explicitly different dates\n        old_asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n            file=\"old_snapshot.html.gz\",\n            date_created=base_time,\n        )\n        self.setup_asset_file(old_asset)\n\n        latest_asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n            file=\"latest_snapshot.html.gz\",\n            date_created=base_time + timedelta(minutes=30),\n        )\n        self.setup_asset_file(latest_asset)\n\n        # Set the latest asset as the bookmark's latest_snapshot\n        bookmark.latest_snapshot = latest_asset\n        bookmark.save()\n\n        # Delete the old snapshot (not the latest)\n        assets.remove_asset(old_asset)\n        bookmark.refresh_from_db()\n\n        # Verify that latest_snapshot hasn't changed\n        self.assertEqual(bookmark.latest_snapshot, latest_asset)\n\n    @disable_logging\n    def test_remove_asset(self):\n        initial_modified = timezone.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(modified=initial_modified)\n        file_content = b\"test content for removal\"\n        upload_file = SimpleUploadedFile(\n            \"test_remove_file.txt\", file_content, content_type=\"text/plain\"\n        )\n\n        asset = assets.upload_asset(bookmark, upload_file)\n        asset_filepath = os.path.join(self.assets_dir, asset.file)\n\n        # Verify asset and file exist\n        self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())\n        self.assertTrue(os.path.exists(asset_filepath))\n\n        bookmark.date_modified = initial_modified\n        bookmark.save()\n\n        # Remove the asset\n        assets.remove_asset(asset)\n\n        # Verify asset is removed from DB\n        self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())\n        # Verify file is removed from disk\n        self.assertFalse(os.path.exists(asset_filepath))\n\n        # Verify bookmark modified date is updated\n        bookmark.refresh_from_db()\n        self.assertGreater(bookmark.date_modified, initial_modified)\n"
  },
  {
    "path": "bookmarks/tests/test_auth_api.py",
    "content": "from django.urls import reverse\nfrom rest_framework import status\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase\n\n\nclass AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):\n    def authenticate(self, keyword):\n        self.api_token = self.setup_api_token()\n        self.client.credentials(HTTP_AUTHORIZATION=f\"{keyword} {self.api_token.key}\")\n\n    def test_auth_with_token_keyword(self):\n        self.authenticate(\"Token\")\n\n        url = reverse(\"linkding:user-profile\")\n        self.get(url, expected_status_code=status.HTTP_200_OK)\n\n    def test_auth_with_bearer_keyword(self):\n        self.authenticate(\"Bearer\")\n\n        url = reverse(\"linkding:user-profile\")\n        self.get(url, expected_status_code=status.HTTP_200_OK)\n\n    def test_auth_with_unknown_keyword(self):\n        self.authenticate(\"Key\")\n\n        url = reverse(\"linkding:user-profile\")\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n"
  },
  {
    "path": "bookmarks/tests/test_auth_proxy_support.py",
    "content": "from unittest.mock import PropertyMock, patch\n\nfrom django.test import TestCase, modify_settings\nfrom django.urls import reverse\n\nfrom bookmarks.middlewares import CustomRemoteUserMiddleware\nfrom bookmarks.models import User\n\n\nclass AuthProxySupportTest(TestCase):\n    # Reproducing configuration from the settings logic here\n    # ideally this test would just override the respective options\n    @modify_settings(\n        MIDDLEWARE={\"append\": \"bookmarks.middlewares.CustomRemoteUserMiddleware\"},\n        AUTHENTICATION_BACKENDS={\n            \"prepend\": \"django.contrib.auth.backends.RemoteUserBackend\"\n        },\n    )\n    def test_auth_proxy_authentication(self):\n        user = User.objects.create_user(\n            \"auth_proxy_user\", \"user@example.com\", \"password123\"\n        )\n\n        headers = {\"REMOTE_USER\": user.username}\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"), **headers)\n\n        self.assertEqual(response.status_code, 200)\n\n    # Reproducing configuration from the settings logic here\n    # ideally this test would just override the respective options\n    @modify_settings(\n        MIDDLEWARE={\"append\": \"bookmarks.middlewares.CustomRemoteUserMiddleware\"},\n        AUTHENTICATION_BACKENDS={\n            \"prepend\": \"django.contrib.auth.backends.RemoteUserBackend\"\n        },\n    )\n    def test_auth_proxy_with_custom_header(self):\n        with patch.object(\n            CustomRemoteUserMiddleware, \"header\", new_callable=PropertyMock\n        ) as mock:\n            mock.return_value = \"Custom-User\"\n            user = User.objects.create_user(\n                \"auth_proxy_user\", \"user@example.com\", \"password123\"\n            )\n\n            headers = {\"Custom-User\": user.username}\n            response = self.client.get(reverse(\"linkding:bookmarks.index\"), **headers)\n\n            self.assertEqual(response.status_code, 200)\n\n    def test_auth_proxy_is_disabled_by_default(self):\n        user = User.objects.create_user(\n            \"auth_proxy_user\", \"user@example.com\", \"password123\"\n        )\n\n        headers = {\"REMOTE_USER\": user.username}\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\"), **headers, follow=True\n        )\n\n        self.assertRedirects(response, \"/login/?next=%2Fbookmarks\")\n"
  },
  {
    "path": "bookmarks/tests/test_auto_tagging.py",
    "content": "from django.test import TestCase\n\nfrom bookmarks.services import auto_tagging\n\n\nclass AutoTaggingTestCase(TestCase):\n    def test_auto_tag_by_domain(self):\n        script = \"\"\"\n            example.com example\n            test.com test\n        \"\"\"\n        url = \"https://example.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"example\"})\n\n    def test_auto_tag_by_domain_handles_invalid_urls(self):\n        script = \"\"\"\n            example.com example\n            test.com test\n        \"\"\"\n\n        url = \"https://\"\n        tags = auto_tagging.get_tags(script, url)\n        self.assertEqual(tags, set([]))\n\n        url = \"example.com\"\n        tags = auto_tagging.get_tags(script, url)\n        self.assertEqual(tags, set([]))\n\n    def test_auto_tag_by_domain_works_with_port(self):\n        script = \"\"\"\n            example.com example\n            test.com test\n        \"\"\"\n        url = \"https://example.com:8080/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"example\"})\n\n    def test_auto_tag_by_domain_ignores_case(self):\n        script = \"\"\"\n            EXAMPLE.com example\n        \"\"\"\n        url = \"https://example.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"example\"})\n\n    def test_auto_tag_by_domain_should_add_all_tags(self):\n        script = \"\"\"\n            example.com one two three\n        \"\"\"\n        url = \"https://example.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"one\", \"two\", \"three\"})\n\n    def test_auto_tag_by_domain_work_with_idn_domains(self):\n        script = \"\"\"\n            रजिस्ट्री.भारत tag1\n        \"\"\"\n        url = \"https://www.xn--81bg3cc2b2bk5hb.xn--h2brj9c/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"tag1\"})\n\n        script = \"\"\"\n            xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1\n        \"\"\"\n        url = \"https://www.रजिस्ट्री.भारत/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"tag1\"})\n\n    def test_auto_tag_by_domain_and_path(self):\n        script = \"\"\"\n            example.com/one one\n            example.com/two two\n            test.com test\n        \"\"\"\n        url = \"https://example.com/one/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"one\"})\n\n    def test_auto_tag_by_domain_and_path_ignores_case(self):\n        script = \"\"\"\n            example.com/One one\n        \"\"\"\n        url = \"https://example.com/one/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"one\"})\n\n    def test_auto_tag_by_domain_and_path_matches_path_ltr(self):\n        script = \"\"\"\n            example.com/one one\n            example.com/two two\n            test.com test\n        \"\"\"\n        url = \"https://example.com/one/two\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"one\"})\n\n    def test_auto_tag_by_domain_ignores_domain_in_path(self):\n        script = \"\"\"\n            example.com example\n        \"\"\"\n        url = \"https://test.com/example.com\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, set([]))\n\n    def test_auto_tag_by_domain_includes_subdomains(self):\n        script = \"\"\"\n            example.com example\n            test.example.com test\n            some.example.com some\n        \"\"\"\n        url = \"https://test.example.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"example\", \"test\"})\n\n    def test_auto_tag_by_domain_matches_domain_rtl(self):\n        script = \"\"\"\n            example.com example\n        \"\"\"\n        url = \"https://example.com.bad-website.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, set([]))\n\n    def test_auto_tag_by_domain_ignores_schema(self):\n        script = \"\"\"\n            https://example.com/ https\n            http://example.com/ http\n        \"\"\"\n        url = \"http://example.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"https\", \"http\"})\n\n    def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):\n        script = \"\"\"\n            example.com\n        \"\"\"\n        url = \"https://example.com/\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, set([]))\n\n    def test_auto_tag_by_domain_path_and_qs(self):\n        script = \"\"\"\n            example.com/page?a=b tag1     # true, matches a=b\n            example.com/page?a=c&c=d tag2 # true, matches both a=c and c=d\n            example.com/page?c=d&l=p tag3 # false, l=p doesn't exists\n            example.com/page?a=bb tag4    # false bb != b\n            example.com/page?a=b&a=c tag5 # true, matches both a=b and a=c\n            example.com/page?a=B tag6     # true, matches a=b because case insensitive\n            example.com/page?A=b tag7     # true, matches a=b because case insensitive\n        \"\"\"\n        url = \"https://example.com/page/some?z=x&a=b&v=b&c=d&o=p&a=c\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"tag1\", \"tag2\", \"tag5\", \"tag6\", \"tag7\"})\n\n    def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):\n        script = \"\"\"\n            example.com/page?a= tag1\n            example.com/page?b= tag2\n        \"\"\"\n        url = \"https://example.com/page/some?a=value\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"tag1\"})\n\n    def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):\n        script = \"\"\"\n            example.com/page?a=йцу tag1\n            example.com/page?a=%D0%B9%D1%86%D1%83 tag2\n        \"\"\"\n        url = \"https://example.com/page?a=%D0%B9%D1%86%D1%83\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"tag1\", \"tag2\"})\n\n    def test_auto_tag_with_url_fragment(self):\n        script = \"\"\"\n            example.com/#/section/1 section1\n            example.com/#/section/2 section2\n        \"\"\"\n        url = \"https://example.com/#/section/1\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"section1\"})\n\n    def test_auto_tag_with_url_fragment_partial_match(self):\n        script = \"\"\"\n            example.com/#/section section\n        \"\"\"\n        url = \"https://example.com/#/section/1\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"section\"})\n\n    def test_auto_tag_with_url_fragment_ignores_case(self):\n        script = \"\"\"\n            example.com/#SECTION section\n        \"\"\"\n        url = \"https://example.com/#section\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"section\"})\n\n    def test_auto_tag_with_url_fragment_and_comment(self):\n        script = \"\"\"\n            example.com/#section1 section1 #This is a comment\n        \"\"\"\n        url = \"https://example.com/#section1\"\n\n        tags = auto_tagging.get_tags(script, url)\n\n        self.assertEqual(tags, {\"section1\"})\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_action_view.py",
    "content": "from unittest.mock import patch\n\nfrom django.contrib.auth.models import User\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom django.forms import model_to_dict\nfrom django.http import HttpResponse\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\n\nfrom bookmarks.models import Bookmark, BookmarkAsset\nfrom bookmarks.services import assets, tasks\nfrom bookmarks.tests.helpers import (\n    BookmarkFactoryMixin,\n    BookmarkListTestMixin,\n    TagCloudTestMixin,\n)\n\n\nclass BookmarkActionViewTestCase(\n    TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin\n):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):\n        self.assertEqual(len(bookmarks), Bookmark.objects.count())\n\n        for bookmark in bookmarks:\n            self.assertEqual(\n                model_to_dict(bookmark),\n                model_to_dict(Bookmark.objects.get(id=bookmark.id)),\n            )\n\n    def test_archive_should_archive_bookmark(self):\n        bookmark = self.setup_bookmark()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"archive\": [bookmark.id],\n            },\n        )\n\n        bookmark.refresh_from_db()\n\n        self.assertTrue(bookmark.is_archived)\n\n    def test_can_only_archive_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark = self.setup_bookmark(user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"archive\": [bookmark.id],\n            },\n        )\n\n        bookmark.refresh_from_db()\n\n        self.assertEqual(response.status_code, 404)\n        self.assertFalse(bookmark.is_archived)\n\n    def test_unarchive_should_unarchive_bookmark(self):\n        bookmark = self.setup_bookmark(is_archived=True)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"unarchive\": [bookmark.id],\n            },\n        )\n        bookmark.refresh_from_db()\n\n        self.assertFalse(bookmark.is_archived)\n\n    def test_unarchive_can_only_archive_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark = self.setup_bookmark(is_archived=True, user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"unarchive\": [bookmark.id],\n            },\n        )\n        bookmark.refresh_from_db()\n\n        self.assertEqual(response.status_code, 404)\n        self.assertTrue(bookmark.is_archived)\n\n    def test_delete_should_delete_bookmark(self):\n        bookmark = self.setup_bookmark()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"remove\": [bookmark.id],\n            },\n        )\n\n        self.assertEqual(Bookmark.objects.count(), 0)\n\n    def test_delete_can_only_delete_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark = self.setup_bookmark(user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"remove\": [bookmark.id],\n            },\n        )\n        self.assertEqual(response.status_code, 404)\n        self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())\n\n    def test_mark_as_read(self):\n        bookmark = self.setup_bookmark(unread=True)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"mark_as_read\": [bookmark.id],\n            },\n        )\n        bookmark.refresh_from_db()\n\n        self.assertFalse(bookmark.unread)\n\n    def test_unshare_should_unshare_bookmark(self):\n        bookmark = self.setup_bookmark(shared=True)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"unshare\": [bookmark.id],\n            },\n        )\n\n        bookmark.refresh_from_db()\n\n        self.assertFalse(bookmark.shared)\n\n    def test_can_only_unshare_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"unshare\": [bookmark.id],\n            },\n        )\n\n        bookmark.refresh_from_db()\n\n        self.assertEqual(response.status_code, 404)\n        self.assertTrue(bookmark.shared)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_html_snapshot(self):\n        bookmark = self.setup_bookmark()\n        with patch.object(tasks, \"_create_html_snapshot_task\"):\n            self.client.post(\n                reverse(\"linkding:bookmarks.index.action\"),\n                {\n                    \"create_html_snapshot\": [bookmark.id],\n                },\n            )\n            self.assertEqual(bookmark.bookmarkasset_set.count(), 1)\n            asset = bookmark.bookmarkasset_set.first()\n            self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_can_only_create_html_snapshot_for_own_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        with patch.object(tasks, \"_create_html_snapshot_task\"):\n            response = self.client.post(\n                reverse(\"linkding:bookmarks.index.action\"),\n                {\n                    \"create_html_snapshot\": [bookmark.id],\n                },\n            )\n            self.assertEqual(response.status_code, 404)\n            self.assertEqual(bookmark.bookmarkasset_set.count(), 0)\n\n    def test_upload_asset(self):\n        bookmark = self.setup_bookmark()\n        file_content = b\"file content\"\n        upload_file = SimpleUploadedFile(\"test.txt\", file_content)\n\n        with patch.object(assets, \"upload_asset\") as mock_upload_asset:\n            response = self.client.post(\n                reverse(\"linkding:bookmarks.index.action\"),\n                {\"upload_asset\": bookmark.id, \"upload_asset_file\": upload_file},\n            )\n            self.assertEqual(response.status_code, 302)\n\n            mock_upload_asset.assert_called_once()\n\n            args, _ = mock_upload_asset.call_args\n            self.assertEqual(args[0], bookmark)\n\n            upload_file = args[1]\n            self.assertEqual(upload_file.name, \"test.txt\")\n\n    def test_can_only_upload_asset_for_own_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        file_content = b\"file content\"\n        upload_file = SimpleUploadedFile(\"test.txt\", file_content)\n\n        with patch.object(assets, \"upload_asset\") as mock_upload_asset:\n            response = self.client.post(\n                reverse(\"linkding:bookmarks.index.action\"),\n                {\"upload_asset\": bookmark.id, \"upload_asset_file\": upload_file},\n            )\n            self.assertEqual(response.status_code, 404)\n\n            mock_upload_asset.assert_not_called()\n\n    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)\n    def test_upload_asset_disabled(self):\n        bookmark = self.setup_bookmark()\n        file_content = b\"file content\"\n        upload_file = SimpleUploadedFile(\"test.txt\", file_content)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\"upload_asset\": bookmark.id, \"upload_asset_file\": upload_file},\n        )\n        self.assertEqual(response.status_code, 403)\n\n    def test_upload_asset_without_file(self):\n        bookmark = self.setup_bookmark()\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\"upload_asset\": bookmark.id},\n        )\n        self.assertEqual(response.status_code, 400)\n\n    def test_remove_asset(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"), {\"remove_asset\": asset.id}\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())\n\n    def test_can_only_remove_own_asset(self):\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        asset = self.setup_asset(bookmark)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"), {\"remove_asset\": asset.id}\n        )\n        self.assertEqual(response.status_code, 404)\n        self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())\n\n    def test_update_state(self):\n        bookmark = self.setup_bookmark()\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"update_state\": bookmark.id,\n                \"is_archived\": \"on\",\n                \"unread\": \"on\",\n                \"shared\": \"on\",\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n\n        bookmark.refresh_from_db()\n        self.assertTrue(bookmark.unread)\n        self.assertTrue(bookmark.is_archived)\n        self.assertTrue(bookmark.shared)\n\n    def test_can_only_update_own_bookmark_state(self):\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"update_state\": bookmark.id,\n                \"is_archived\": \"on\",\n                \"unread\": \"on\",\n                \"shared\": \"on\",\n            },\n        )\n        self.assertEqual(response.status_code, 404)\n\n        bookmark.refresh_from_db()\n        self.assertFalse(bookmark.unread)\n        self.assertFalse(bookmark.is_archived)\n        self.assertFalse(bookmark.shared)\n\n    def test_bulk_archive(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_archive\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_can_only_bulk_archive_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(user=other_user)\n        bookmark2 = self.setup_bookmark(user=other_user)\n        bookmark3 = self.setup_bookmark(user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_archive\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_bulk_unarchive(self):\n        bookmark1 = self.setup_bookmark(is_archived=True)\n        bookmark2 = self.setup_bookmark(is_archived=True)\n        bookmark3 = self.setup_bookmark(is_archived=True)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived.action\"),\n            {\n                \"bulk_action\": [\"bulk_unarchive\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_can_only_bulk_unarchive_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(is_archived=True, user=other_user)\n        bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)\n        bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived.action\"),\n            {\n                \"bulk_action\": [\"bulk_unarchive\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_bulk_delete(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())\n\n    def test_can_only_bulk_delete_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(user=other_user)\n        bookmark2 = self.setup_bookmark(user=other_user)\n        bookmark3 = self.setup_bookmark(user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertIsNotNone(Bookmark.objects.filter(id=bookmark1.id).first())\n        self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())\n        self.assertIsNotNone(Bookmark.objects.filter(id=bookmark3.id).first())\n\n    def test_bulk_tag(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_tag\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_tag_string\": [f\"{tag1.name} {tag2.name}\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n\n    def test_can_only_bulk_tag_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(user=other_user)\n        bookmark2 = self.setup_bookmark(user=other_user)\n        bookmark3 = self.setup_bookmark(user=other_user)\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_tag\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_tag_string\": [f\"{tag1.name} {tag2.name}\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [])\n        self.assertCountEqual(bookmark2.tags.all(), [])\n        self.assertCountEqual(bookmark3.tags.all(), [])\n\n    def test_bulk_untag(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark2 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark3 = self.setup_bookmark(tags=[tag1, tag2])\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_untag\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_tag_string\": [f\"{tag1.name} {tag2.name}\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [])\n        self.assertCountEqual(bookmark2.tags.all(), [])\n        self.assertCountEqual(bookmark3.tags.all(), [])\n\n    def test_can_only_bulk_untag_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)\n        bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)\n        bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_untag\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_tag_string\": [f\"{tag1.name} {tag2.name}\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n\n    def test_bulk_mark_as_read(self):\n        bookmark1 = self.setup_bookmark(unread=True)\n        bookmark2 = self.setup_bookmark(unread=True)\n        bookmark3 = self.setup_bookmark(unread=True)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_read\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_can_only_bulk_mark_as_read_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(unread=True, user=other_user)\n        bookmark2 = self.setup_bookmark(unread=True, user=other_user)\n        bookmark3 = self.setup_bookmark(unread=True, user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_read\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_bulk_mark_as_unread(self):\n        bookmark1 = self.setup_bookmark(unread=False)\n        bookmark2 = self.setup_bookmark(unread=False)\n        bookmark3 = self.setup_bookmark(unread=False)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_unread\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_can_only_bulk_mark_as_unread_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(unread=False, user=other_user)\n        bookmark2 = self.setup_bookmark(unread=False, user=other_user)\n        bookmark3 = self.setup_bookmark(unread=False, user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_unread\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_bulk_share(self):\n        bookmark1 = self.setup_bookmark(shared=False)\n        bookmark2 = self.setup_bookmark(shared=False)\n        bookmark3 = self.setup_bookmark(shared=False)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_share\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_can_only_bulk_share_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(shared=False, user=other_user)\n        bookmark2 = self.setup_bookmark(shared=False, user=other_user)\n        bookmark3 = self.setup_bookmark(shared=False, user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_share\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_bulk_unshare(self):\n        bookmark1 = self.setup_bookmark(shared=True)\n        bookmark2 = self.setup_bookmark(shared=True)\n        bookmark3 = self.setup_bookmark(shared=True)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_unshare\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_can_only_bulk_unshare_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark1 = self.setup_bookmark(shared=True, user=other_user)\n        bookmark2 = self.setup_bookmark(shared=True, user=other_user)\n        bookmark3 = self.setup_bookmark(shared=True, user=other_user)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_unshare\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_bulk_select_across(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_archive\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_bulk_select_across_ignores_page(self):\n        self.setup_numbered_bookmarks(100)\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\") + \"?page=2\",\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(0, Bookmark.objects.count())\n\n    def setup_bulk_edit_scope_test_data(self):\n        # create a number of bookmarks with different states / visibility\n        self.setup_numbered_bookmarks(3, with_tags=True)\n        self.setup_numbered_bookmarks(3, with_tags=True, archived=True)\n        self.setup_numbered_bookmarks(\n            3,\n            shared=True,\n            prefix=\"Joe's Bookmark\",\n            user=self.setup_user(enable_sharing=True),\n        )\n\n    def test_index_action_bulk_select_across_only_affects_active_bookmarks(self):\n        self.setup_bulk_edit_scope_test_data()\n\n        self.assertIsNotNone(Bookmark.objects.filter(title=\"Bookmark 1\").first())\n        self.assertIsNotNone(Bookmark.objects.filter(title=\"Bookmark 2\").first())\n        self.assertIsNotNone(Bookmark.objects.filter(title=\"Bookmark 3\").first())\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(6, Bookmark.objects.count())\n        self.assertIsNone(Bookmark.objects.filter(title=\"Bookmark 1\").first())\n        self.assertIsNone(Bookmark.objects.filter(title=\"Bookmark 2\").first())\n        self.assertIsNone(Bookmark.objects.filter(title=\"Bookmark 3\").first())\n\n    def test_index_action_bulk_select_across_respects_query(self):\n        self.setup_numbered_bookmarks(3, prefix=\"foo\")\n        self.setup_numbered_bookmarks(3, prefix=\"bar\")\n\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"foo\").count())\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\") + \"?q=foo\",\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(0, Bookmark.objects.filter(title__startswith=\"foo\").count())\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"bar\").count())\n\n    def test_index_action_bulk_select_across_respects_bundle(self):\n        self.setup_numbered_bookmarks(3, prefix=\"foo\")\n        self.setup_numbered_bookmarks(3, prefix=\"bar\")\n\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"foo\").count())\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\") + f\"?bundle={bundle.id}\",\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(0, Bookmark.objects.filter(title__startswith=\"foo\").count())\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"bar\").count())\n\n    def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):\n        self.setup_bulk_edit_scope_test_data()\n\n        self.assertIsNotNone(\n            Bookmark.objects.filter(title=\"Archived Bookmark 1\").first()\n        )\n        self.assertIsNotNone(\n            Bookmark.objects.filter(title=\"Archived Bookmark 2\").first()\n        )\n        self.assertIsNotNone(\n            Bookmark.objects.filter(title=\"Archived Bookmark 3\").first()\n        )\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived.action\"),\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(6, Bookmark.objects.count())\n        self.assertIsNone(Bookmark.objects.filter(title=\"Archived Bookmark 1\").first())\n        self.assertIsNone(Bookmark.objects.filter(title=\"Archived Bookmark 2\").first())\n        self.assertIsNone(Bookmark.objects.filter(title=\"Archived Bookmark 3\").first())\n\n    def test_archived_action_bulk_select_across_respects_query(self):\n        self.setup_numbered_bookmarks(3, prefix=\"foo\", archived=True)\n        self.setup_numbered_bookmarks(3, prefix=\"bar\", archived=True)\n\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"foo\").count())\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived.action\") + \"?q=foo\",\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(0, Bookmark.objects.filter(title__startswith=\"foo\").count())\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"bar\").count())\n\n    def test_archived_action_bulk_select_across_respects_bundle(self):\n        self.setup_numbered_bookmarks(3, prefix=\"foo\", archived=True)\n        self.setup_numbered_bookmarks(3, prefix=\"bar\", archived=True)\n\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"foo\").count())\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived.action\") + f\"?bundle={bundle.id}\",\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n\n        self.assertEqual(0, Bookmark.objects.filter(title__startswith=\"foo\").count())\n        self.assertEqual(3, Bookmark.objects.filter(title__startswith=\"bar\").count())\n\n    def test_shared_action_bulk_select_across_not_supported(self):\n        self.setup_bulk_edit_scope_test_data()\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.shared.action\"),\n            {\n                \"bulk_action\": [\"bulk_delete\"],\n                \"bulk_execute\": [\"\"],\n                \"bulk_select_across\": [\"on\"],\n            },\n        )\n        self.assertEqual(response.status_code, 400)\n\n    def test_handles_empty_bookmark_id(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_archive\"],\n                \"bulk_execute\": [\"\"],\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bulk_action\": [\"bulk_archive\"],\n                \"bulk_execute\": [\"\"],\n                \"bookmark_id\": [],\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n\n        self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])\n\n    def test_empty_action_does_not_modify_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            {\n                \"bookmark_id\": [\n                    str(bookmark1.id),\n                    str(bookmark2.id),\n                    str(bookmark3.id),\n                ],\n            },\n        )\n\n        self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])\n\n    def test_index_action_redirects_to_index_with_query_params(self):\n        url = reverse(\"linkding:bookmarks.index.action\") + \"?q=foo&page=2\"\n        redirect_url = reverse(\"linkding:bookmarks.index\") + \"?q=foo&page=2\"\n        response = self.client.post(url)\n\n        self.assertRedirects(response, redirect_url)\n\n    def test_archived_action_redirects_to_archived_with_query_params(self):\n        url = reverse(\"linkding:bookmarks.archived.action\") + \"?q=foo&page=2\"\n        redirect_url = reverse(\"linkding:bookmarks.archived\") + \"?q=foo&page=2\"\n        response = self.client.post(url)\n\n        self.assertRedirects(response, redirect_url)\n\n    def test_shared_action_redirects_to_shared_with_query_params(self):\n        url = reverse(\"linkding:bookmarks.shared.action\") + \"?q=foo&page=2\"\n        redirect_url = reverse(\"linkding:bookmarks.shared\") + \"?q=foo&page=2\"\n        response = self.client.post(url)\n\n        self.assertRedirects(response, redirect_url)\n\n    def bookmark_update_fixture(self):\n        user = self.get_or_create_test_user()\n        profile = user.profile\n        profile.enable_sharing = True\n        profile.save()\n\n        return {\n            \"active\": self.setup_numbered_bookmarks(3),\n            \"archived\": self.setup_numbered_bookmarks(3, archived=True),\n            \"shared\": self.setup_numbered_bookmarks(3, shared=True),\n        }\n\n    def assertBookmarkUpdateResponse(self, response: HttpResponse):\n        self.assertEqual(response.status_code, 200)\n\n        html = response.content.decode(\"utf-8\")\n        soup = self.make_soup(html)\n\n        # bookmark list update\n        self.assertIsNotNone(\n            soup.select_one(\n                \"turbo-stream[action='update'][target='bookmark-list-container']\"\n            )\n        )\n\n        # tag cloud update\n        self.assertIsNotNone(\n            soup.select_one(\n                \"turbo-stream[action='update'][target='tag-cloud-container']\"\n            )\n        )\n\n        # update event\n        self.assertInHTML(\n            \"\"\"\n            <script>\n                document.dispatchEvent(new CustomEvent('bookmark-list-updated'));\n            </script>\n            \"\"\",\n            html,\n        )\n\n    def test_index_action_with_turbo_returns_bookmark_update(self):\n        fixture = self.bookmark_update_fixture()\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index.action\"),\n            HTTP_ACCEPT=\"text/vnd.turbo-stream.html\",\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(\n            fixture[\"active\"] + fixture[\"shared\"]\n        )\n        invisible_tags = self.get_tags_from_bookmarks(fixture[\"archived\"])\n\n        self.assertBookmarkUpdateResponse(response)\n        self.assertVisibleBookmarks(response, fixture[\"active\"] + fixture[\"shared\"])\n        self.assertInvisibleBookmarks(response, fixture[\"archived\"])\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_archived_action_with_turbo_returns_bookmark_update(self):\n        fixture = self.bookmark_update_fixture()\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.archived.action\"),\n            HTTP_ACCEPT=\"text/vnd.turbo-stream.html\",\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(fixture[\"archived\"])\n        invisible_tags = self.get_tags_from_bookmarks(\n            fixture[\"active\"] + fixture[\"shared\"]\n        )\n\n        self.assertBookmarkUpdateResponse(response)\n        self.assertVisibleBookmarks(response, fixture[\"archived\"])\n        self.assertInvisibleBookmarks(response, fixture[\"active\"] + fixture[\"shared\"])\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_shared_action_with_turbo_returns_bookmark_update(self):\n        fixture = self.bookmark_update_fixture()\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.shared.action\"),\n            HTTP_ACCEPT=\"text/vnd.turbo-stream.html\",\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(fixture[\"shared\"])\n        invisible_tags = self.get_tags_from_bookmarks(\n            fixture[\"active\"] + fixture[\"archived\"]\n        )\n\n        self.assertBookmarkUpdateResponse(response)\n        self.assertVisibleBookmarks(response, fixture[\"shared\"])\n        self.assertInvisibleBookmarks(response, fixture[\"active\"] + fixture[\"archived\"])\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_archived_view.py",
    "content": "import urllib.parse\n\nfrom django.contrib.auth.models import User\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkSearch, UserProfile\nfrom bookmarks.tests.helpers import (\n    BookmarkFactoryMixin,\n    BookmarkListTestMixin,\n    TagCloudTestMixin,\n)\n\n\nclass BookmarkArchivedViewTestCase(\n    TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin\n):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def assertEditLink(self, response, url):\n        html = response.content.decode()\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{url}\">Edit</a>        \n        \"\"\",\n            html,\n        )\n\n    def assertBulkActionForm(self, response, url: str):\n        soup = self.make_soup(response.content.decode())\n        form = soup.select_one(\"form.bookmark-actions\")\n        self.assertIsNotNone(form)\n        self.assertEqual(form.attrs[\"action\"], url)\n\n    def test_should_list_archived_and_user_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)\n        invisible_bookmarks = [\n            self.setup_bookmark(is_archived=False),\n            self.setup_bookmark(is_archived=True, user=other_user),\n        ]\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_bookmarks_matching_query(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, prefix=\"foo\", archived=True\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, prefix=\"bar\", archived=True\n        )\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\") + \"?q=foo\")\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_bookmarks_matching_bundle(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, prefix=\"foo\", archived=True\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, prefix=\"bar\", archived=True\n        )\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.archived\") + f\"?bundle={bundle.id}\"\n        )\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True\n        )\n        unarchived_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=False, tag_prefix=\"unarchived\"\n        )\n        other_user_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True, user=other_user, tag_prefix=\"otheruser\"\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)\n        invisible_tags = self.get_tags_from_bookmarks(\n            unarchived_bookmarks + other_user_bookmarks\n        )\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_bookmarks_matching_query(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True, prefix=\"foo\", tag_prefix=\"foo\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True, prefix=\"bar\", tag_prefix=\"bar\"\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)\n        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\") + \"?q=foo\")\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_bookmarks_matching_bundle(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True, prefix=\"foo\", tag_prefix=\"foo\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True, prefix=\"bar\", tag_prefix=\"bar\"\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)\n        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.archived\") + f\"?bundle={bundle.id}\"\n        )\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_bookmarks_and_tags_for_search_preferences(self):\n        user_profile = self.user.profile\n        user_profile.search_preferences = {\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        user_profile.save()\n\n        unread_bookmarks = self.setup_numbered_bookmarks(\n            3,\n            archived=True,\n            unread=True,\n            with_tags=True,\n            prefix=\"unread\",\n            tag_prefix=\"unread\",\n        )\n        read_bookmarks = self.setup_numbered_bookmarks(\n            3,\n            archived=True,\n            unread=False,\n            with_tags=True,\n            prefix=\"read\",\n            tag_prefix=\"read\",\n        )\n\n        unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)\n        read_tags = self.get_tags_from_bookmarks(read_bookmarks)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n        self.assertVisibleBookmarks(response, unread_bookmarks)\n        self.assertInvisibleBookmarks(response, read_bookmarks)\n        self.assertVisibleTags(response, unread_tags)\n        self.assertInvisibleTags(response, read_tags)\n\n    def test_should_display_selected_tags_from_query(self):\n        tags = [\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n        ]\n        self.setup_bookmark(is_archived=True, tags=tags)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.archived\")\n            + f\"?q=%23{tags[0].name}+%23{tags[1].name}\"\n        )\n\n        self.assertSelectedTags(response, [tags[0], tags[1]])\n\n    def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(\n        self,\n    ):\n        tags = [\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n        ]\n        self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.archived\")\n            + f\"?q={tags[0].name}+%23{tags[1].name.upper()}\"\n        )\n\n        self.assertSelectedTags(response, [tags[1]])\n\n    def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):\n        self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX\n        self.user.profile.save()\n\n        tags = [\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n        ]\n        self.setup_bookmark(tags=tags, is_archived=True)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.archived\")\n            + f\"?q={tags[0].name}+%23{tags[1].name.upper()}\"\n        )\n\n        self.assertSelectedTags(response, [tags[0], tags[1]])\n\n    def test_should_open_bookmarks_in_new_page_by_default(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks, \"_blank\")\n\n    def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):\n        user = self.get_or_create_test_user()\n        user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF\n        user.profile.save()\n\n        visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks, \"_self\")\n\n    def test_edit_link_return_url_respects_search_options(self):\n        bookmark = self.setup_bookmark(title=\"foo\", is_archived=True)\n        edit_url = reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        base_url = reverse(\"linkding:bookmarks.archived\")\n\n        # without query params\n        return_url = urllib.parse.quote(base_url)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url)\n        self.assertEditLink(response, url)\n\n        # with query\n        url_params = \"?q=foo\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n        # with query and sort and page\n        url_params = \"?q=foo&sort=title_asc&page=2\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n    def test_bulk_edit_respects_search_options(self):\n        action_url = reverse(\"linkding:bookmarks.archived.action\")\n        base_url = reverse(\"linkding:bookmarks.archived\")\n\n        # without params\n        url = f\"{action_url}\"\n\n        response = self.client.get(base_url)\n        self.assertBulkActionForm(response, url)\n\n        # with query\n        url_params = \"?q=foo\"\n        url = f\"{action_url}?q=foo\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertBulkActionForm(response, url)\n\n        # with query and sort\n        url_params = \"?q=foo&sort=title_asc\"\n        url = f\"{action_url}?q=foo&sort=title_asc\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertBulkActionForm(response, url)\n\n    def test_allowed_bulk_actions(self):\n        url = reverse(\"linkding:bookmarks.archived\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_unarchive\">Unarchive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_allowed_bulk_actions_with_html_snapshot_enabled(self):\n        url = reverse(\"linkding:bookmarks.archived\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_unarchive\">Unarchive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n            <option value=\"bulk_snapshot\">Create HTML snapshot</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    def test_allowed_bulk_actions_with_sharing_enabled(self):\n        user_profile = self.user.profile\n        user_profile.enable_sharing = True\n        user_profile.save()\n\n        url = reverse(\"linkding:bookmarks.archived\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_unarchive\">Unarchive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_share\">Share</option>\n            <option value=\"bulk_unshare\">Unshare</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):\n        user_profile = self.user.profile\n        user_profile.enable_sharing = True\n        user_profile.save()\n\n        url = reverse(\"linkding:bookmarks.archived\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_unarchive\">Unarchive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_share\">Share</option>\n            <option value=\"bulk_unshare\">Unshare</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n            <option value=\"bulk_snapshot\">Create HTML snapshot</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    def test_apply_search_preferences(self):\n        # no params\n        response = self.client.post(reverse(\"linkding:bookmarks.archived\"))\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(response.url, reverse(\"linkding:bookmarks.archived\"))\n\n        # some params\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"q\": \"foo\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url,\n            reverse(\"linkding:bookmarks.archived\") + \"?q=foo&sort=title_asc\",\n        )\n\n        # params with default value are removed\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"q\": \"foo\",\n                \"user\": \"\",\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.archived\") + \"?q=foo&unread=yes\"\n        )\n\n        # page is removed\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"q\": \"foo\",\n                \"page\": \"2\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url,\n            reverse(\"linkding:bookmarks.archived\") + \"?q=foo&sort=title_asc\",\n        )\n\n    def test_save_search_preferences(self):\n        user_profile = self.user.profile\n\n        # no params\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"save\": \"\",\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # with param\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"save\": \"\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # add a param\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"save\": \"\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # remove a param\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"save\": \"\",\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # ignores non-preferences\n        self.client.post(\n            reverse(\"linkding:bookmarks.archived\"),\n            {\n                \"save\": \"\",\n                \"q\": \"foo\",\n                \"user\": \"john\",\n                \"page\": \"3\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n    def test_url_encode_bookmark_actions_url(self):\n        url = reverse(\"linkding:bookmarks.archived\") + \"?q=%23foo\"\n        response = self.client.get(url)\n        html = response.content.decode()\n        soup = self.make_soup(html)\n        actions_form = soup.select(\"form.bookmark-actions\")[0]\n\n        self.assertEqual(\n            actions_form.attrs[\"action\"],\n            \"/bookmarks/archived/action?q=%23foo\",\n        )\n\n    def test_encode_search_params(self):\n        bookmark = self.setup_bookmark(description=\"alert('xss')\", is_archived=True)\n\n        url = reverse(\"linkding:bookmarks.archived\") + \"?q=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n        self.assertContains(response, bookmark.url)\n\n        url = reverse(\"linkding:bookmarks.archived\") + \"?sort=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.archived\") + \"?unread=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.archived\") + \"?shared=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.archived\") + \"?user=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.archived\") + \"?page=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n    def test_turbo_frame_details_modal_renders_details_modal_update(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\"linkding:bookmarks.archived\") + f\"?bookmark_id={bookmark.id}\"\n        response = self.client.get(url, headers={\"Turbo-Frame\": \"details-modal\"})\n\n        self.assertEqual(200, response.status_code)\n\n        soup = self.make_soup(response.content.decode())\n        self.assertIsNotNone(soup.select_one(\"turbo-frame#details-modal\"))\n        self.assertIsNone(soup.select_one(\"#bookmark-list-container\"))\n        self.assertIsNone(soup.select_one(\"#tag-cloud-container\"))\n\n    def test_does_not_include_rss_feed(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n        soup = self.make_soup(response.content.decode())\n\n        feed = soup.select_one('head link[type=\"application/rss+xml\"]')\n        self.assertIsNone(feed)\n\n    def test_hide_bundles_when_enabled_in_profile(self):\n        # visible by default\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n        html = response.content.decode()\n\n        self.assertInHTML('<h2 id=\"bundles-heading\">Bundles</h2>', html)\n\n        # hidden when disabled in profile\n        user_profile = self.get_or_create_test_user().profile\n        user_profile.hide_bundles = True\n        user_profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n        html = response.content.decode()\n\n        self.assertInHTML('<h2 id=\"bundles-heading\">Bundles</h2>', html, count=0)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_archived_view_performance.py",
    "content": "from django.db import connections\nfrom django.db.utils import DEFAULT_DB_ALIAS\nfrom django.test import TransactionTestCase\nfrom django.test.utils import CaptureQueriesContext\nfrom django.urls import reverse\n\nfrom bookmarks.models import GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BookmarkArchivedViewPerformanceTestCase(\n    TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin\n):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def get_connection(self):\n        return connections[DEFAULT_DB_ALIAS]\n\n    def test_should_not_increase_number_of_queries_per_bookmark(self):\n        # create global settings\n        GlobalSettings.get()\n\n        # create initial bookmarks\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            self.setup_bookmark(user=self.user, is_archived=True)\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n            html = response.content.decode(\"utf-8\")\n            soup = self.make_soup(html)\n            list_items = soup.select(\"ul.bookmark-list > li\")\n            self.assertEqual(len(list_items), num_initial_bookmarks)\n\n        number_of_queries = context.final_queries\n\n        # add more bookmarks\n        num_additional_bookmarks = 10\n        for _ in range(num_additional_bookmarks):\n            self.setup_bookmark(user=self.user, is_archived=True)\n\n        # assert num queries doesn't increase\n        with self.assertNumQueries(number_of_queries):\n            response = self.client.get(reverse(\"linkding:bookmarks.archived\"))\n            html = response.content.decode(\"utf-8\")\n            soup = self.make_soup(html)\n            list_items = soup.select(\"ul.bookmark-list > li\")\n            self.assertEqual(\n                len(list_items), num_initial_bookmarks + num_additional_bookmarks\n            )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_asset_view.py",
    "content": "import os\n\nfrom django.conf import settings\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkAsset\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.setup_temp_assets_dir()\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def setup_asset_file(self, filename):\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n        with open(filepath, \"w\") as f:\n            f.write(\"test\")\n\n    def setup_asset_with_file(self, bookmark):\n        filename = f\"temp_{bookmark.id}.html.gzip\"\n        self.setup_asset_file(filename)\n        asset = self.setup_asset(\n            bookmark=bookmark, file=filename, display_name=f\"Snapshot {bookmark.id}\"\n        )\n        return asset\n\n    def setup_asset_with_uploaded_file(self, bookmark, content_type=\"image/png\"):\n        filename = f\"temp_{bookmark.id}.png.gzip\"\n        self.setup_asset_file(filename)\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            file=filename,\n            asset_type=BookmarkAsset.TYPE_UPLOAD,\n            content_type=content_type,\n            display_name=f\"Uploaded file {bookmark.id}.png\",\n        )\n        return asset\n\n    def view_access_test(self, view_name: str):\n        # own bookmark\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 200)\n\n        # other user's bookmark\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # shared, sharing disabled\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # unshared, sharing enabled\n        profile = other_user.profile\n        profile.enable_sharing = True\n        profile.save()\n        bookmark = self.setup_bookmark(user=other_user, shared=False)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # shared, sharing enabled\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 200)\n\n    def view_access_guest_user_test(self, view_name: str):\n        self.client.logout()\n\n        # unshared, sharing disabled\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # shared, sharing disabled\n        bookmark = self.setup_bookmark(shared=True)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # unshared, sharing enabled\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n        bookmark = self.setup_bookmark(shared=False)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # shared, sharing enabled\n        bookmark = self.setup_bookmark(shared=True)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # unshared, public sharing enabled\n        profile.enable_public_sharing = True\n        profile.save()\n        bookmark = self.setup_bookmark(shared=False)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 404)\n\n        # shared, public sharing enabled\n        bookmark = self.setup_bookmark(shared=True)\n        asset = self.setup_asset_with_file(bookmark)\n\n        response = self.client.get(reverse(view_name, args=[asset.id]))\n        self.assertEqual(response.status_code, 200)\n\n    def test_view_access(self):\n        self.view_access_test(\"linkding:assets.view\")\n\n    def test_view_access_guest_user(self):\n        self.view_access_guest_user_test(\"linkding:assets.view\")\n\n    def test_reader_view_access(self):\n        self.view_access_test(\"linkding:assets.read\")\n\n    def test_reader_view_access_guest_user(self):\n        self.view_access_guest_user_test(\"linkding:assets.read\")\n\n    def test_snapshot_download_headers(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset_with_file(bookmark)\n        response = self.client.get(reverse(\"linkding:assets.view\", args=[asset.id]))\n\n        self.assertEqual(response[\"Content-Type\"], asset.content_type)\n        self.assertEqual(\n            response[\"Content-Disposition\"],\n            f'inline; filename=\"{asset.display_name}.html\"',\n        )\n        self.assertEqual(response[\"Content-Security-Policy\"], \"sandbox allow-scripts\")\n\n    def test_uploaded_file_download_headers(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset_with_uploaded_file(bookmark)\n        response = self.client.get(reverse(\"linkding:assets.view\", args=[asset.id]))\n\n        self.assertEqual(response[\"Content-Type\"], asset.content_type)\n        self.assertEqual(\n            response[\"Content-Disposition\"],\n            f'inline; filename=\"{asset.display_name}\"',\n        )\n        self.assertEqual(response[\"Content-Security-Policy\"], \"sandbox allow-scripts\")\n\n    def test_uploaded_video_download_headers(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset_with_uploaded_file(bookmark, content_type=\"video/mp4\")\n        response = self.client.get(reverse(\"linkding:assets.view\", args=[asset.id]))\n\n        self.assertEqual(response[\"Content-Type\"], asset.content_type)\n        self.assertEqual(\n            response[\"Content-Disposition\"],\n            f'inline; filename=\"{asset.display_name}\"',\n        )\n        self.assertEqual(\n            response[\"Content-Security-Policy\"], \"default-src 'none'; media-src 'self';\"\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_assets.py",
    "content": "import os\n\nfrom django.conf import settings\nfrom django.test import TestCase\n\nfrom bookmarks.models import BookmarkAsset\nfrom bookmarks.services import bookmarks\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.setup_temp_assets_dir()\n\n    def setup_asset_file(self, filename):\n        filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)\n        with open(filepath, \"w\") as f:\n            f.write(\"test\")\n\n    def setup_asset_with_file(self, bookmark):\n        filename = f\"temp_{bookmark.id}.html.gzip\"\n        self.setup_asset_file(filename)\n        asset = self.setup_asset(bookmark=bookmark, file=filename)\n        return asset\n\n    def test_delete_bookmark_deletes_asset_file(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset_with_file(bookmark)\n        self.assertTrue(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))\n        )\n\n        bookmark.delete()\n        self.assertFalse(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))\n        )\n\n    def test_bulk_delete_bookmarks_deletes_asset_files(self):\n        bookmark1 = self.setup_bookmark()\n        asset1 = self.setup_asset_with_file(bookmark1)\n        bookmark2 = self.setup_bookmark()\n        asset2 = self.setup_asset_with_file(bookmark2)\n        bookmark3 = self.setup_bookmark()\n        asset3 = self.setup_asset_with_file(bookmark3)\n        self.assertTrue(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))\n        )\n        self.assertTrue(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))\n        )\n        self.assertTrue(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))\n        )\n\n        bookmarks.delete_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertFalse(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))\n        )\n        self.assertFalse(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))\n        )\n        self.assertFalse(\n            os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))\n        )\n\n    def test_save_updates_file_size(self):\n        # File does not exist initially\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark=bookmark, file=\"temp.html.gz\")\n        self.assertIsNone(asset.file_size)\n\n        # Add file, save again\n        self.setup_asset_file(asset.file)\n        asset.save()\n        self.assertEqual(asset.file_size, 4)\n\n        # Create asset with initial file\n        asset = self.setup_asset(bookmark=bookmark, file=\"temp.html.gz\")\n        self.assertEqual(asset.file_size, 4)\n\n    def test_download_name_for_html_snapshot(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            content_type=BookmarkAsset.CONTENT_TYPE_HTML,\n            display_name=\"HTML snapshot from Jan 1, 2025\",\n        )\n        self.assertEqual(asset.download_name, \"HTML snapshot from Jan 1, 2025.html\")\n\n    def test_download_name_for_pdf_snapshot(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            content_type=BookmarkAsset.CONTENT_TYPE_PDF,\n            display_name=\"PDF download from Jan 1, 2025\",\n        )\n        self.assertEqual(asset.download_name, \"PDF download from Jan 1, 2025.pdf\")\n\n    def test_download_name_for_upload(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_UPLOAD,\n            content_type=\"text/plain\",\n            display_name=\"document.txt\",\n        )\n        self.assertEqual(asset.download_name, \"document.txt\")\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_assets_api.py",
    "content": "import io\n\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom django.test import override_settings\nfrom django.urls import reverse\nfrom rest_framework import status\n\nfrom bookmarks.models import BookmarkAsset\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase\n\n\nclass BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.setup_temp_assets_dir()\n\n    def assertAsset(self, asset: BookmarkAsset, data: dict):\n        self.assertEqual(asset.id, data[\"id\"])\n        self.assertEqual(asset.bookmark.id, data[\"bookmark\"])\n        self.assertEqual(\n            asset.date_created.isoformat().replace(\"+00:00\", \"Z\"), data[\"date_created\"]\n        )\n        self.assertEqual(asset.file_size, data[\"file_size\"])\n        self.assertEqual(asset.asset_type, data[\"asset_type\"])\n        self.assertEqual(asset.content_type, data[\"content_type\"])\n        self.assertEqual(asset.display_name, data[\"display_name\"])\n        self.assertEqual(asset.status, data[\"status\"])\n\n    def test_asset_list(self):\n        self.authenticate()\n\n        bookmark1 = self.setup_bookmark(url=\"https://example1.com\")\n        bookmark1_assets = [\n            self.setup_asset(bookmark=bookmark1),\n            self.setup_asset(bookmark=bookmark1),\n            self.setup_asset(bookmark=bookmark1),\n        ]\n\n        bookmark2 = self.setup_bookmark(url=\"https://example2.com\")\n        bookmark2_assets = [\n            self.setup_asset(bookmark=bookmark2),\n            self.setup_asset(bookmark=bookmark2),\n            self.setup_asset(bookmark=bookmark2),\n        ]\n\n        url = reverse(\n            \"linkding:bookmark_asset-list\", kwargs={\"bookmark_id\": bookmark1.id}\n        )\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertEqual(len(response.data[\"results\"]), 3)\n        self.assertAsset(bookmark1_assets[0], response.data[\"results\"][0])\n        self.assertAsset(bookmark1_assets[1], response.data[\"results\"][1])\n        self.assertAsset(bookmark1_assets[2], response.data[\"results\"][2])\n\n        url = reverse(\n            \"linkding:bookmark_asset-list\", kwargs={\"bookmark_id\": bookmark2.id}\n        )\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertEqual(len(response.data[\"results\"]), 3)\n        self.assertAsset(bookmark2_assets[0], response.data[\"results\"][0])\n        self.assertAsset(bookmark2_assets[1], response.data[\"results\"][1])\n        self.assertAsset(bookmark2_assets[2], response.data[\"results\"][2])\n\n    def test_asset_list_only_returns_assets_for_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        self.setup_asset(bookmark=bookmark)\n\n        url = reverse(\n            \"linkding:bookmark_asset-list\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_asset_list_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\n            \"linkding:bookmark_asset-list\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def test_asset_detail(self):\n        self.authenticate()\n\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_UPLOAD,\n            file=\"cats.png\",\n            file_size=1234,\n            content_type=\"image/png\",\n            display_name=\"cats.png\",\n            status=BookmarkAsset.STATUS_PENDING,\n            gzip=False,\n        )\n        url = reverse(\n            \"linkding:bookmark_asset-detail\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertAsset(asset, response.data)\n\n    def test_asset_detail_only_returns_asset_for_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        asset = self.setup_asset(bookmark=bookmark)\n\n        url = reverse(\n            \"linkding:bookmark_asset-detail\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_asset_detail_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark=bookmark)\n        url = reverse(\n            \"linkding:bookmark_asset-detail\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def test_asset_download_with_snapshot_asset(self):\n        self.authenticate()\n\n        file_content = \"\"\"\n            <html>\n            <head>\n                <title>Test</title>\n            </head>\n            <body>\n                <h1>Test</h1>\n            </body>\n        \"\"\"\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            display_name=\"Snapshot from today\",\n            content_type=\"text/html\",\n            gzip=True,\n        )\n        self.setup_asset_file(asset=asset, file_content=file_content)\n\n        url = reverse(\n            \"linkding:bookmark_asset-download\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertEqual(response[\"Content-Type\"], \"text/html\")\n        self.assertEqual(\n            response[\"Content-Disposition\"],\n            'attachment; filename=\"Snapshot from today.html\"',\n        )\n        content = b\"\".join(response.streaming_content).decode(\"utf-8\")\n        self.assertEqual(content, file_content)\n\n    def test_asset_download_with_uploaded_asset(self):\n        self.authenticate()\n\n        file_content = \"some file content\"\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_UPLOAD,\n            display_name=\"cats.png\",\n            content_type=\"image/png\",\n            gzip=False,\n        )\n        self.setup_asset_file(asset=asset, file_content=file_content)\n\n        url = reverse(\n            \"linkding:bookmark_asset-download\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertEqual(response[\"Content-Type\"], \"image/png\")\n        self.assertEqual(\n            response[\"Content-Disposition\"],\n            'attachment; filename=\"cats.png\"',\n        )\n        content = b\"\".join(response.streaming_content).decode(\"utf-8\")\n        self.assertEqual(content, file_content)\n\n    def test_asset_download_with_missing_file(self):\n        self.authenticate()\n\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_UPLOAD,\n            display_name=\"cats.png\",\n            content_type=\"image/png\",\n            gzip=False,\n        )\n\n        url = reverse(\n            \"linkding:bookmark_asset-download\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_asset_download_only_returns_asset_for_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        asset = self.setup_asset(bookmark=bookmark)\n\n        url = reverse(\n            \"linkding:bookmark_asset-download\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_asset_download_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark=bookmark)\n        url = reverse(\n            \"linkding:bookmark_asset-download\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def create_upload_body(self):\n        url = \"https://example.com\"\n        file_content = b\"dummy content\"\n        file = io.BytesIO(file_content)\n        file.name = \"snapshot.html\"\n\n        return {\"url\": url, \"file\": file}\n\n    def test_upload_asset(self):\n        self.authenticate()\n\n        bookmark = self.setup_bookmark()\n        url = reverse(\n            \"linkding:bookmark_asset-upload\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n        file_content = b\"test file content\"\n        file_name = \"test.txt\"\n        file = SimpleUploadedFile(file_name, file_content, content_type=\"text/plain\")\n\n        response = self.client.post(url, {\"file\": file}, format=\"multipart\")\n        self.assertEqual(response.status_code, status.HTTP_201_CREATED)\n\n        asset = BookmarkAsset.objects.get(id=response.data[\"id\"])\n        self.assertEqual(asset.bookmark, bookmark)\n        self.assertEqual(asset.display_name, file_name)\n        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)\n        self.assertEqual(asset.content_type, \"text/plain\")\n        self.assertEqual(asset.file_size, self.get_asset_filesize(asset))\n        self.assertTrue(asset.gzip)\n\n        content = self.read_asset_file(asset)\n        self.assertEqual(content, file_content)\n\n    def test_upload_asset_with_missing_file(self):\n        self.authenticate()\n\n        bookmark = self.setup_bookmark()\n        url = reverse(\n            \"linkding:bookmark_asset-upload\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n\n        response = self.client.post(url, {}, format=\"multipart\")\n        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)\n\n    def test_upload_asset_only_works_for_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        url = reverse(\n            \"linkding:bookmark_asset-upload\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n\n        response = self.client.post(url, {}, format=\"multipart\")\n        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)\n\n    def test_upload_asset_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\n            \"linkding:bookmark_asset-upload\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n\n        response = self.client.post(url, {}, format=\"multipart\")\n        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)\n\n    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)\n    def test_upload_asset_disabled(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n        url = reverse(\n            \"linkding:bookmark_asset-upload\", kwargs={\"bookmark_id\": bookmark.id}\n        )\n        response = self.client.post(url, {}, format=\"multipart\")\n        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)\n\n    def test_delete_asset(self):\n        self.authenticate()\n\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark=bookmark)\n        self.setup_asset_file(asset=asset)\n\n        url = reverse(\n            \"linkding:bookmark_asset-detail\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n\n        self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())\n        self.assertFalse(self.has_asset_file(asset))\n\n    def test_delete_asset_only_works_for_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        asset = self.setup_asset(bookmark=bookmark)\n\n        url = reverse(\n            \"linkding:bookmark_asset-detail\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_delete_asset_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark=bookmark)\n        url = reverse(\n            \"linkding:bookmark_asset-detail\",\n            kwargs={\"bookmark_id\": asset.bookmark.id, \"pk\": asset.id},\n        )\n        self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_details_modal.py",
    "content": "import datetime\nimport re\n\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\nfrom django.utils import formats, timezone\n\nfrom bookmarks.models import BookmarkAsset, UserProfile\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self):\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def get_details_form(self, soup, bookmark):\n        form_url = (\n            reverse(\"linkding:bookmarks.index.action\") + f\"?details={bookmark.id}\"\n        )\n        return soup.find(\"form\", {\"action\": form_url, \"enctype\": \"multipart/form-data\"})\n\n    def get_index_details_modal(self, bookmark):\n        url = reverse(\"linkding:bookmarks.index\") + f\"?details={bookmark.id}\"\n        response = self.client.get(url)\n        soup = self.make_soup(response.content.decode())\n        return soup.select_one(\"ld-details-modal\")\n\n    def get_shared_details_modal(self, bookmark):\n        url = reverse(\"linkding:bookmarks.shared\") + f\"?details={bookmark.id}\"\n        response = self.client.get(url)\n        soup = self.make_soup(response.content.decode())\n        return soup.select_one(\"ld-details-modal\")\n\n    def has_details_modal(self, response):\n        soup = self.make_soup(response.content.decode())\n        return soup.select_one(\"ld-details-modal\") is not None\n\n    def find_section_content(self, soup, section_name):\n        h3 = soup.find(\"h3\", string=section_name)\n        content = h3.find_next_sibling(\"div\") if h3 else None\n        return content\n\n    def get_section_content(self, soup, section_name):\n        content = self.find_section_content(soup, section_name)\n        self.assertIsNotNone(content)\n        return content\n\n    def find_weblink(self, soup, url):\n        return soup.find(\"a\", {\"class\": \"weblink\", \"href\": url})\n\n    def count_weblinks(self, soup):\n        return len(soup.find_all(\"a\", {\"class\": \"weblink\"}))\n\n    def find_asset(self, soup, asset):\n        return soup.find(\"div\", {\"data-asset-id\": asset.id})\n\n    def test_access(self):\n        # own bookmark\n        bookmark = self.setup_bookmark()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTrue(self.has_details_modal(response))\n\n        # other user's bookmark\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFalse(self.has_details_modal(response))\n\n        # non-existent bookmark - just returns without modal in response\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\") + \"?details=9999\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFalse(self.has_details_modal(response))\n\n        # guest user\n        self.client.logout()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFalse(self.has_details_modal(response))\n\n    def test_access_with_sharing(self):\n        # shared bookmark, sharing disabled\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(shared=True, user=other_user)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFalse(self.has_details_modal(response))\n\n        # shared bookmark, sharing enabled\n        profile = other_user.profile\n        profile.enable_sharing = True\n        profile.save()\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTrue(self.has_details_modal(response))\n\n        # shared bookmark, guest user, no public sharing\n        self.client.logout()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFalse(self.has_details_modal(response))\n\n        # shared bookmark, guest user, public sharing\n        profile.enable_public_sharing = True\n        profile.save()\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?details={bookmark.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTrue(self.has_details_modal(response))\n\n    def test_displays_title(self):\n        # with title\n        bookmark = self.setup_bookmark(title=\"Test title\")\n        soup = self.get_index_details_modal(bookmark)\n\n        title = soup.find(\"h2\")\n        self.assertIsNotNone(title)\n        self.assertEqual(title.text.strip(), bookmark.title)\n\n        # with URL only\n        bookmark = self.setup_bookmark(title=\"\")\n        soup = self.get_index_details_modal(bookmark)\n\n        title = soup.find(\"h2\")\n        self.assertIsNotNone(title)\n        self.assertEqual(title.text.strip(), bookmark.url)\n\n    def test_website_link(self):\n        # basics\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.url)\n        self.assertIsNotNone(link)\n        self.assertEqual(link[\"href\"], bookmark.url)\n        self.assertEqual(link.text.strip(), bookmark.url)\n\n        # favicons disabled\n        bookmark = self.setup_bookmark(favicon_file=\"example.png\")\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.url)\n        image = link.select_one(\"img\")\n        self.assertIsNone(image)\n\n        # favicons enabled, no favicon\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(favicon_file=\"\")\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.url)\n        image = link.select_one(\"img\")\n        self.assertIsNone(image)\n\n        # favicons enabled, favicon present\n        bookmark = self.setup_bookmark(favicon_file=\"example.png\")\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.url)\n        image = link.select_one(\"img\")\n        self.assertIsNotNone(image)\n        self.assertEqual(image[\"src\"], \"/static/example.png\")\n\n    def test_reader_mode_link(self):\n        # no latest snapshot\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        self.assertEqual(self.count_weblinks(soup), 2)\n\n        # snapshot is not complete\n        self.setup_asset(\n            bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_PENDING,\n        )\n        self.setup_asset(\n            bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_FAILURE,\n        )\n        soup = self.get_index_details_modal(bookmark)\n        self.assertEqual(self.count_weblinks(soup), 2)\n\n        # not a snapshot\n        self.setup_asset(\n            bookmark,\n            asset_type=\"upload\",\n            status=BookmarkAsset.STATUS_COMPLETE,\n        )\n        soup = self.get_index_details_modal(bookmark)\n        self.assertEqual(self.count_weblinks(soup), 2)\n\n        # snapshot is complete\n        asset = self.setup_asset(\n            bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n        )\n        soup = self.get_index_details_modal(bookmark)\n        self.assertEqual(self.count_weblinks(soup), 3)\n\n        reader_mode_url = reverse(\"linkding:assets.read\", args=[asset.id])\n        link = self.find_weblink(soup, reader_mode_url)\n        self.assertIsNotNone(link)\n\n    def test_internet_archive_link_with_snapshot_url(self):\n        bookmark = self.setup_bookmark(web_archive_snapshot_url=\"https://example.com/\")\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)\n        self.assertIsNotNone(link)\n        self.assertEqual(link[\"href\"], bookmark.web_archive_snapshot_url)\n        self.assertEqual(link.text.strip(), \"Internet Archive\")\n\n        # favicons disabled\n        bookmark = self.setup_bookmark(\n            web_archive_snapshot_url=\"https://example.com/\", favicon_file=\"example.png\"\n        )\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)\n        image = link.select_one(\"svg\")\n        self.assertIsNone(image)\n\n        # favicons enabled, no favicon\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(\n            web_archive_snapshot_url=\"https://example.com/\", favicon_file=\"\"\n        )\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)\n        image = link.select_one(\"svg\")\n        self.assertIsNone(image)\n\n        # favicons enabled, favicon present\n        bookmark = self.setup_bookmark(\n            web_archive_snapshot_url=\"https://example.com/\", favicon_file=\"example.png\"\n        )\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)\n        image = link.select_one(\"svg\")\n        self.assertIsNotNone(image)\n\n    def test_internet_archive_link_with_fallback_url(self):\n        date_added = timezone.datetime(2023, 8, 11, 21, 45, 11, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(url=\"https://example.com/\", added=date_added)\n        fallback_web_archive_url = (\n            \"https://web.archive.org/web/20230811214511/https://example.com/\"\n        )\n\n        soup = self.get_index_details_modal(bookmark)\n        link = self.find_weblink(soup, fallback_web_archive_url)\n        self.assertIsNotNone(link)\n        self.assertEqual(link[\"href\"], fallback_web_archive_url)\n        self.assertEqual(link.text.strip(), \"Internet Archive\")\n\n    def test_weblinks_respect_target_setting(self):\n        bookmark = self.setup_bookmark(web_archive_snapshot_url=\"https://example.com/\")\n\n        # target blank\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK\n        profile.save()\n\n        soup = self.get_index_details_modal(bookmark)\n\n        website_link = self.find_weblink(soup, bookmark.url)\n        self.assertIsNotNone(website_link)\n        self.assertEqual(website_link[\"target\"], UserProfile.BOOKMARK_LINK_TARGET_BLANK)\n\n        web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)\n        self.assertIsNotNone(web_archive_link)\n        self.assertEqual(\n            web_archive_link[\"target\"], UserProfile.BOOKMARK_LINK_TARGET_BLANK\n        )\n\n        # target self\n        profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF\n        profile.save()\n\n        soup = self.get_index_details_modal(bookmark)\n\n        website_link = self.find_weblink(soup, bookmark.url)\n        self.assertIsNotNone(website_link)\n        self.assertEqual(website_link[\"target\"], UserProfile.BOOKMARK_LINK_TARGET_SELF)\n\n        web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)\n        self.assertIsNotNone(web_archive_link)\n        self.assertEqual(\n            web_archive_link[\"target\"], UserProfile.BOOKMARK_LINK_TARGET_SELF\n        )\n\n    def test_preview_image(self):\n        # without image\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        image = soup.select_one(\"div.preview-image img\")\n        self.assertIsNone(image)\n\n        # with image\n        bookmark = self.setup_bookmark(preview_image_file=\"example.png\")\n        soup = self.get_index_details_modal(bookmark)\n        image = soup.select_one(\"div.preview-image img\")\n        self.assertIsNone(image)\n\n        # preview images enabled, no image\n        profile = self.get_or_create_test_user().profile\n        profile.enable_preview_images = True\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        image = soup.select_one(\"div.preview-image img\")\n        self.assertIsNone(image)\n\n        # preview images enabled, image present\n        bookmark = self.setup_bookmark(preview_image_file=\"example.png\")\n        soup = self.get_index_details_modal(bookmark)\n        image = soup.select_one(\"div.preview-image img\")\n        self.assertIsNotNone(image)\n        self.assertEqual(image[\"src\"], \"/static/example.png\")\n\n    def test_status(self):\n        # renders form\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n\n        form = self.get_details_form(soup, bookmark)\n        self.assertIsNotNone(form)\n        self.assertEqual(form[\"method\"], \"post\")\n\n        # sharing disabled\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Status\")\n\n        archived = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"is_archived\"})\n        self.assertIsNotNone(archived)\n        unread = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"unread\"})\n        self.assertIsNotNone(unread)\n        shared = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"shared\"})\n        self.assertIsNone(shared)\n\n        # sharing enabled\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Status\")\n\n        archived = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"is_archived\"})\n        self.assertIsNotNone(archived)\n        unread = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"unread\"})\n        self.assertIsNotNone(unread)\n        shared = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"shared\"})\n        self.assertIsNotNone(shared)\n\n        # unchecked\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Status\")\n\n        archived = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"is_archived\"})\n        self.assertFalse(archived.has_attr(\"checked\"))\n        unread = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"unread\"})\n        self.assertFalse(unread.has_attr(\"checked\"))\n        shared = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"shared\"})\n        self.assertFalse(shared.has_attr(\"checked\"))\n\n        # checked\n        bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Status\")\n\n        archived = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"is_archived\"})\n        self.assertTrue(archived.has_attr(\"checked\"))\n        unread = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"unread\"})\n        self.assertTrue(unread.has_attr(\"checked\"))\n        shared = section.find(\"input\", {\"type\": \"checkbox\", \"name\": \"shared\"})\n        self.assertTrue(shared.has_attr(\"checked\"))\n\n    def test_status_visibility(self):\n        # own bookmark\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        section = self.find_section_content(soup, \"Status\")\n        self.assertIsNotNone(section)\n\n        # other user's bookmark\n        other_user = self.setup_user(enable_sharing=True)\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n        soup = self.get_shared_details_modal(bookmark)\n        section = self.find_section_content(soup, \"Status\")\n        self.assertIsNone(section)\n\n        # guest user\n        self.client.logout()\n        other_user.profile.enable_public_sharing = True\n        other_user.profile.save()\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n        soup = self.get_shared_details_modal(bookmark)\n        section = self.find_section_content(soup, \"Status\")\n        self.assertIsNone(section)\n\n    def test_date_added(self):\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Date added\")\n\n        expected_date = formats.date_format(bookmark.date_added, \"DATETIME_FORMAT\")\n        date = section.find(\"span\", string=expected_date)\n        self.assertIsNotNone(date)\n\n    def test_tags(self):\n        # without tags\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n\n        section = self.find_section_content(soup, \"Tags\")\n        self.assertIsNone(section)\n\n        # with tags\n        bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])\n\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Tags\")\n\n        for tag in bookmark.tags.all():\n            tag_link = section.find(\"a\", string=f\"#{tag.name}\")\n            self.assertIsNotNone(tag_link)\n            expected_url = reverse(\"linkding:bookmarks.index\") + f\"?q=%23{tag.name}\"\n            self.assertEqual(tag_link[\"href\"], expected_url)\n\n    def test_description(self):\n        # without description\n        bookmark = self.setup_bookmark(description=\"\")\n        soup = self.get_index_details_modal(bookmark)\n\n        section = self.find_section_content(soup, \"Description\")\n        self.assertIsNone(section)\n\n        # with description\n        bookmark = self.setup_bookmark(description=\"Test description\")\n        soup = self.get_index_details_modal(bookmark)\n\n        section = self.get_section_content(soup, \"Description\")\n        self.assertEqual(section.text.strip(), bookmark.description)\n\n    def test_notes(self):\n        # without notes\n        bookmark = self.setup_bookmark()\n        soup = self.get_index_details_modal(bookmark)\n\n        section = self.find_section_content(soup, \"Notes\")\n        self.assertIsNone(section)\n\n        # with notes\n        bookmark = self.setup_bookmark(notes=\"Test notes\")\n        soup = self.get_index_details_modal(bookmark)\n\n        section = self.get_section_content(soup, \"Notes\")\n        self.assertEqual(section.decode_contents(), \"<p>Test notes</p>\")\n\n    def test_edit_link(self):\n        bookmark = self.setup_bookmark()\n\n        # with default return URL\n        soup = self.get_index_details_modal(bookmark)\n        edit_link = soup.find(\"a\", string=\"Edit\")\n        self.assertIsNotNone(edit_link)\n        expected_url = f\"/bookmarks/{bookmark.id}/edit?return_url=/bookmarks%3Fdetails%3D{bookmark.id}\"\n        self.assertEqual(expected_url, edit_link[\"href\"])\n\n    def test_delete_button(self):\n        bookmark = self.setup_bookmark()\n\n        modal = self.get_index_details_modal(bookmark)\n        delete_button = modal.find(\"button\", {\"type\": \"submit\", \"name\": \"remove\"})\n        self.assertIsNotNone(delete_button)\n        self.assertEqual(\"Delete\", delete_button.text.strip())\n        self.assertEqual(str(bookmark.id), delete_button[\"value\"])\n\n        form = delete_button.find_parent(\"form\")\n        self.assertIsNotNone(form)\n        expected_url = reverse(\"linkding:bookmarks.index.action\")\n        self.assertEqual(expected_url, form[\"action\"])\n\n    def test_actions_visibility(self):\n        # own bookmark\n        bookmark = self.setup_bookmark()\n\n        soup = self.get_index_details_modal(bookmark)\n        edit_link = soup.find(\"a\", string=\"Edit\")\n        delete_button = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"remove\"})\n        self.assertIsNotNone(edit_link)\n        self.assertIsNotNone(delete_button)\n\n        # with sharing\n        other_user = self.setup_user(enable_sharing=True)\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n\n        soup = self.get_shared_details_modal(bookmark)\n        edit_link = soup.find(\"a\", string=\"Edit\")\n        delete_button = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"remove\"})\n        self.assertIsNone(edit_link)\n        self.assertIsNone(delete_button)\n\n        # with public sharing\n        profile = other_user.profile\n        profile.enable_public_sharing = True\n        profile.save()\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n\n        soup = self.get_shared_details_modal(bookmark)\n        edit_link = soup.find(\"a\", string=\"Edit\")\n        delete_button = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"remove\"})\n        self.assertIsNone(edit_link)\n        self.assertIsNone(delete_button)\n\n        # guest user\n        self.client.logout()\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n\n        soup = self.get_shared_details_modal(bookmark)\n        edit_link = soup.find(\"a\", string=\"Edit\")\n        delete_button = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"remove\"})\n        self.assertIsNone(edit_link)\n        self.assertIsNone(delete_button)\n\n    def test_asset_list_visibility(self):\n        # no assets\n        bookmark = self.setup_bookmark()\n\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Files\")\n        asset_list = section.find(\"div\", {\"class\": \"assets\"})\n        self.assertIsNone(asset_list)\n\n        # with assets\n        bookmark = self.setup_bookmark()\n        self.setup_asset(bookmark)\n\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Files\")\n        asset_list = section.find(\"div\", {\"class\": \"assets\"})\n        self.assertIsNotNone(asset_list)\n\n    def test_asset_list(self):\n        bookmark = self.setup_bookmark()\n        assets = [\n            self.setup_asset(bookmark),\n            self.setup_asset(bookmark),\n            self.setup_asset(bookmark),\n        ]\n\n        soup = self.get_index_details_modal(bookmark)\n        section = self.get_section_content(soup, \"Files\")\n        asset_list = section.find(\"div\", {\"class\": \"assets\"})\n\n        for asset in assets:\n            asset_item = self.find_asset(asset_list, asset)\n            self.assertIsNotNone(asset_item)\n\n            asset_icon = asset_item.select_one(\".list-item-icon svg\")\n            self.assertIsNotNone(asset_icon)\n\n            asset_text = asset_item.select_one(\".list-item-text span\")\n            self.assertIsNotNone(asset_text)\n            self.assertIn(asset.display_name, asset_text.text)\n\n            view_url = reverse(\"linkding:assets.view\", args=[asset.id])\n            view_link = asset_item.find(\"a\", {\"href\": view_url})\n            self.assertIsNotNone(view_link)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_asset_list_actions_visibility(self):\n        # own bookmark\n        bookmark = self.setup_bookmark()\n\n        soup = self.get_index_details_modal(bookmark)\n        create_snapshot = soup.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"create_html_snapshot\"}\n        )\n        upload_asset = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"upload_asset\"})\n        self.assertIsNotNone(create_snapshot)\n        self.assertIsNotNone(upload_asset)\n\n        # with sharing\n        other_user = self.setup_user(enable_sharing=True)\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n\n        soup = self.get_shared_details_modal(bookmark)\n        create_snapshot = soup.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"create_html_snapshot\"}\n        )\n        upload_asset = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"upload_asset\"})\n        self.assertIsNone(create_snapshot)\n        self.assertIsNone(upload_asset)\n\n        # with public sharing\n        profile = other_user.profile\n        profile.enable_public_sharing = True\n        profile.save()\n\n        soup = self.get_shared_details_modal(bookmark)\n        create_snapshot = soup.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"create_html_snapshot\"}\n        )\n        upload_asset = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"upload_asset\"})\n        self.assertIsNone(create_snapshot)\n        self.assertIsNone(upload_asset)\n\n        # guest user\n        self.client.logout()\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n\n        soup = self.get_shared_details_modal(bookmark)\n        edit_link = soup.find(\"a\", string=\"Edit\")\n        delete_button = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"remove\"})\n        self.assertIsNone(edit_link)\n        self.assertIsNone(delete_button)\n\n    def test_asset_list_actions_visibility_without_snapshots_enabled(self):\n        bookmark = self.setup_bookmark()\n\n        soup = self.get_index_details_modal(bookmark)\n        create_snapshot = soup.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"create_html_snapshot\"}\n        )\n        upload_asset = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"upload_asset\"})\n        self.assertIsNone(create_snapshot)\n        self.assertIsNotNone(upload_asset)\n\n    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)\n    def test_asset_list_actions_visibility_with_uploads_disabled(self):\n        bookmark = self.setup_bookmark()\n\n        soup = self.get_index_details_modal(bookmark)\n        create_snapshot = soup.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"create_html_snapshot\"}\n        )\n        upload_asset = soup.find(\"button\", {\"type\": \"submit\", \"name\": \"upload_asset\"})\n        self.assertIsNone(create_snapshot)\n        self.assertIsNone(upload_asset)\n\n    def test_asset_without_file(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark)\n        asset.file = \"\"\n        asset.save()\n\n        soup = self.get_index_details_modal(bookmark)\n        asset_item = self.find_asset(soup, asset)\n        view_url = reverse(\"linkding:assets.view\", args=[asset.id])\n        view_link = asset_item.find(\"a\", {\"href\": view_url})\n        self.assertIsNone(view_link)\n\n    def test_asset_status(self):\n        bookmark = self.setup_bookmark()\n        pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)\n        failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)\n\n        soup = self.get_index_details_modal(bookmark)\n\n        asset_item = self.find_asset(soup, pending_asset)\n        asset_text = asset_item.select_one(\".list-item-text span\")\n        self.assertIn(\"(queued)\", asset_text.text)\n\n        asset_item = self.find_asset(soup, failed_asset)\n        asset_text = asset_item.select_one(\".list-item-text span\")\n        self.assertIn(\"(failed)\", asset_text.text)\n\n    def test_asset_file_size(self):\n        bookmark = self.setup_bookmark()\n        asset1 = self.setup_asset(bookmark, file_size=None)\n        asset2 = self.setup_asset(bookmark, file_size=54639)\n        asset3 = self.setup_asset(bookmark, file_size=11492020)\n\n        soup = self.get_index_details_modal(bookmark)\n\n        asset_item = self.find_asset(soup, asset1)\n        asset_text = asset_item.select_one(\".list-item-text\")\n        self.assertEqual(asset_text.text.strip(), asset1.display_name)\n\n        asset_item = self.find_asset(soup, asset2)\n        asset_text = asset_item.select_one(\".list-item-text\")\n        self.assertIn(\"53.4\\xa0KB\", asset_text.text)\n\n        asset_item = self.find_asset(soup, asset3)\n        asset_text = asset_item.select_one(\".list-item-text\")\n        self.assertIn(\"11.0\\xa0MB\", asset_text.text)\n\n    def test_asset_actions_visibility(self):\n        bookmark = self.setup_bookmark()\n\n        # with file\n        asset = self.setup_asset(bookmark)\n        soup = self.get_index_details_modal(bookmark)\n\n        asset_item = self.find_asset(soup, asset)\n        view_link = asset_item.find(\"a\", string=\"View\")\n        delete_button = asset_item.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"remove_asset\"}\n        )\n        self.assertIsNotNone(view_link)\n        self.assertIsNotNone(delete_button)\n\n        # without file\n        asset.file = \"\"\n        asset.save()\n        soup = self.get_index_details_modal(bookmark)\n\n        asset_item = self.find_asset(soup, asset)\n        view_link = asset_item.find(\"a\", string=\"View\")\n        delete_button = asset_item.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"remove_asset\"}\n        )\n        self.assertIsNone(view_link)\n        self.assertIsNotNone(delete_button)\n\n        # shared bookmark\n        other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        bookmark = self.setup_bookmark(shared=True, user=other_user)\n        asset = self.setup_asset(bookmark)\n        soup = self.get_index_details_modal(bookmark)\n\n        asset_item = self.find_asset(soup, asset)\n        view_link = asset_item.find(\"a\", string=\"View\")\n        delete_button = asset_item.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"remove_asset\"}\n        )\n        self.assertIsNotNone(view_link)\n        self.assertIsNone(delete_button)\n\n        # shared bookmark, guest user\n        self.client.logout()\n        soup = self.get_shared_details_modal(bookmark)\n\n        asset_item = self.find_asset(soup, asset)\n        view_link = asset_item.find(\"a\", string=\"View\")\n        delete_button = asset_item.find(\n            \"button\", {\"type\": \"submit\", \"name\": \"remove_asset\"}\n        )\n        self.assertIsNotNone(view_link)\n        self.assertIsNone(delete_button)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_snapshot_is_disabled_when_having_pending_asset(self):\n        bookmark = self.setup_bookmark()\n        asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)\n\n        # no pending asset\n        soup = self.get_index_details_modal(bookmark)\n        files_section = self.find_section_content(soup, \"Files\")\n        create_button = files_section.find(\n            \"button\", string=re.compile(\"Create HTML snapshot\")\n        )\n        self.assertFalse(create_button.has_attr(\"disabled\"))\n\n        # with pending asset\n        asset.status = BookmarkAsset.STATUS_PENDING\n        asset.save()\n\n        soup = self.get_index_details_modal(bookmark)\n        files_section = self.find_section_content(soup, \"Files\")\n        create_button = files_section.find(\n            \"button\", string=re.compile(\"Create HTML snapshot\")\n        )\n        self.assertTrue(create_button.has_attr(\"disabled\"))\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_edit_view.py",
    "content": "from django.contrib.auth.models import User\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import build_tag_string\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def create_form_data(self, overrides=None):\n        if overrides is None:\n            overrides = {}\n        form_data = {\n            \"url\": \"http://example.com/edited\",\n            \"tag_string\": \"editedtag1 editedtag2\",\n            \"title\": \"edited title\",\n            \"description\": \"edited description\",\n            \"notes\": \"edited notes\",\n            \"unread\": False,\n            \"shared\": False,\n        }\n        return {**form_data, **overrides}\n\n    def test_should_render_successfully(self):\n        bookmark = self.setup_bookmark()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n        self.assertEqual(response.status_code, 200)\n\n    def test_should_edit_bookmark(self):\n        bookmark = self.setup_bookmark()\n        form_data = self.create_form_data({\"id\": bookmark.id})\n\n        self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.owner, self.user)\n        self.assertEqual(bookmark.url, form_data[\"url\"])\n        self.assertEqual(bookmark.title, form_data[\"title\"])\n        self.assertEqual(bookmark.description, form_data[\"description\"])\n        self.assertEqual(bookmark.notes, form_data[\"notes\"])\n        self.assertEqual(bookmark.unread, form_data[\"unread\"])\n        self.assertEqual(bookmark.shared, form_data[\"shared\"])\n        self.assertEqual(bookmark.tags.count(), 2)\n        tags = bookmark.tags.order_by(\"name\").all()\n        self.assertEqual(tags[0].name, \"editedtag1\")\n        self.assertEqual(tags[1].name, \"editedtag2\")\n\n    def test_should_return_422_with_invalid_form(self):\n        bookmark = self.setup_bookmark()\n        form_data = self.create_form_data({\"id\": bookmark.id, \"url\": \"\"})\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n        self.assertEqual(response.status_code, 422)\n\n    def test_should_edit_unread_state(self):\n        bookmark = self.setup_bookmark()\n\n        form_data = self.create_form_data({\"id\": bookmark.id, \"unread\": True})\n        self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n        bookmark.refresh_from_db()\n        self.assertTrue(bookmark.unread)\n\n        form_data = self.create_form_data({\"id\": bookmark.id, \"unread\": False})\n        self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n        bookmark.refresh_from_db()\n        self.assertFalse(bookmark.unread)\n\n    def test_should_edit_shared_state(self):\n        bookmark = self.setup_bookmark()\n\n        form_data = self.create_form_data({\"id\": bookmark.id, \"shared\": True})\n        self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n        bookmark.refresh_from_db()\n        self.assertTrue(bookmark.shared)\n\n        form_data = self.create_form_data({\"id\": bookmark.id, \"shared\": False})\n        self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n        bookmark.refresh_from_db()\n        self.assertFalse(bookmark.shared)\n\n    def test_should_prefill_bookmark_form_fields(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark = self.setup_bookmark(\n            tags=[tag1, tag2],\n            title=\"edited title\",\n            description=\"edited description\",\n            notes=\"edited notes\",\n        )\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            f\"\"\"\n            <input type=\"text\" name=\"url\" aria-invalid=\"false\" autocomplete=\"off\" autofocus class=\"form-input\" required id=\"id_url\" value=\"{bookmark.url}\">\n            \"\"\",\n            html,\n        )\n\n        tag_string = build_tag_string(bookmark.tag_names, \" \")\n        self.assertInHTML(\n            f\"\"\"\n                <ld-tag-autocomplete input-id=\"id_tag_string\" input-name=\"tag_string\" input-value=\"{tag_string}\"\n                         input-aria-describedby=\"id_tag_string_help\">\n                </ld-tag-autocomplete>\n        \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n            <input type=\"text\" name=\"title\" value=\"{bookmark.title}\" maxlength=\"512\" autocomplete=\"off\" \n                    class=\"form-input\" id=\"id_title\">\n        \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n            <textarea name=\"description\" cols=\"40\" rows=\"3\" class=\"form-input\" id=\"id_description\">\n                {bookmark.description}\n            </textarea>\n        \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n            <textarea name=\"notes\" cols=\"40\" rows=\"8\" class=\"form-input\" id=\"id_notes\" aria-describedby=\"id_notes_help\">\n                {bookmark.notes}\n            </textarea>\n        \"\"\",\n            html,\n        )\n\n    def test_should_prevent_duplicate_urls(self):\n        edited_bookmark = self.setup_bookmark(url=\"http://example.com/edited\")\n        existing_bookmark = self.setup_bookmark(url=\"http://example.com/existing\")\n        other_user_bookmark = self.setup_bookmark(\n            url=\"http://example.com/other-user\", user=User.objects.create_user(\"other\")\n        )\n\n        # if the URL isn't modified it's not a duplicate\n        form_data = self.create_form_data({\"url\": edited_bookmark.url})\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[edited_bookmark.id]), form_data\n        )\n        self.assertEqual(response.status_code, 302)\n\n        # if the URL is already bookmarked by another user, it's not a duplicate\n        form_data = self.create_form_data({\"url\": other_user_bookmark.url})\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[edited_bookmark.id]), form_data\n        )\n        self.assertEqual(response.status_code, 302)\n\n        # if the URL is already bookmarked by the same user, it's a duplicate\n        form_data = self.create_form_data({\"url\": existing_bookmark.url})\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[edited_bookmark.id]), form_data\n        )\n        self.assertEqual(response.status_code, 422)\n        self.assertInHTML(\n            \"<li>A bookmark with this URL already exists.</li>\",\n            response.content.decode(),\n        )\n        edited_bookmark.refresh_from_db()\n        self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)\n\n    def test_should_prevent_duplicate_normalized_urls(self):\n        self.setup_bookmark(url=\"https://EXAMPLE.COM/path/?z=1&a=2\")\n\n        edited_bookmark = self.setup_bookmark(url=\"http://different.com\")\n\n        form_data = self.create_form_data({\"url\": \"https://example.com/path?a=2&z=1\"})\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[edited_bookmark.id]), form_data\n        )\n\n        self.assertEqual(response.status_code, 422)\n        self.assertInHTML(\n            \"<li>A bookmark with this URL already exists.</li>\",\n            response.content.decode(),\n        )\n\n        edited_bookmark.refresh_from_db()\n        self.assertEqual(edited_bookmark.url, \"http://different.com\")\n\n    def test_should_redirect_to_return_url(self):\n        bookmark = self.setup_bookmark()\n        form_data = self.create_form_data()\n\n        url = (\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n            + \"?return_url=\"\n            + reverse(\"linkding:bookmarks.close\")\n        )\n        response = self.client.post(url, form_data)\n\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.close\"))\n\n    def test_should_redirect_to_bookmark_index_by_default(self):\n        bookmark = self.setup_bookmark()\n        form_data = self.create_form_data()\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n    def test_should_not_redirect_to_external_url(self):\n        bookmark = self.setup_bookmark()\n\n        def post_with(return_url, follow=None):\n            form_data = self.create_form_data()\n            url = (\n                reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n                + f\"?return_url={return_url}\"\n            )\n            return self.client.post(url, form_data, follow=follow)\n\n        response = post_with(\"https://example.com\")\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n        response = post_with(\"//example.com\")\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n        response = post_with(\"://example.com\")\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n        response = post_with(\"/foo//example.com\", follow=True)\n        self.assertEqual(response.status_code, 404)\n\n    def test_can_only_edit_own_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        bookmark = self.setup_bookmark(user=other_user)\n        form_data = self.create_form_data({\"id\": bookmark.id})\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]), form_data\n        )\n        bookmark.refresh_from_db()\n        self.assertNotEqual(bookmark.url, form_data[\"url\"])\n        self.assertEqual(response.status_code, 404)\n\n    def test_should_respect_share_profile_setting(self):\n        bookmark = self.setup_bookmark()\n\n        self.user.profile.enable_sharing = False\n        self.user.profile.save()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <div class=\"form-checkbox\">\n                <input type=\"checkbox\" name=\"shared\" aria-describedby=\"id_shared_help\" id=\"id_shared\">\n                <i class=\"form-icon\"></i>\n                <label for=\"id_shared\">Share</label>\n            </div>\n            \"\"\",\n            html,\n            count=0,\n        )\n\n        self.user.profile.enable_sharing = True\n        self.user.profile.save()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <div class=\"form-checkbox\">\n                <input type=\"checkbox\" name=\"shared\" aria-describedby=\"id_shared_help\" id=\"id_shared\">\n                <i class=\"form-icon\"></i>\n                <label for=\"id_shared\">Share</label>\n            </div>\n            \"\"\",\n            html,\n            count=1,\n        )\n\n    def test_should_hide_notes_if_there_are_no_notes(self):\n        bookmark = self.setup_bookmark()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n\n        self.assertContains(response, '<details class=\"notes\">', count=1)\n\n    def test_should_show_notes_if_there_are_notes(self):\n        bookmark = self.setup_bookmark(notes=\"test notes\")\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n\n        self.assertContains(response, '<details class=\"notes\" open>', count=1)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_index_view.py",
    "content": "import urllib.parse\n\nfrom django.contrib.auth.models import User\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkSearch, UserProfile\nfrom bookmarks.tests.helpers import (\n    BookmarkFactoryMixin,\n    BookmarkListTestMixin,\n    TagCloudTestMixin,\n)\n\n\nclass BookmarkIndexViewTestCase(\n    TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin\n):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def assertEditLink(self, response, url):\n        html = response.content.decode()\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{url}\">Edit</a>        \n        \"\"\",\n            html,\n        )\n\n    def assertBulkActionForm(self, response, url: str):\n        soup = self.make_soup(response.content.decode())\n        form = soup.select_one(\"form.bookmark-actions\")\n        self.assertIsNotNone(form)\n        self.assertEqual(form.attrs[\"action\"], url)\n\n    def assertVisibleBundles(self, soup, bundles):\n        bundle_list = soup.select_one(\"ul.bundle-menu\")\n        self.assertIsNotNone(bundle_list)\n\n        list_items = bundle_list.select(\"li.bundle-menu-item\")\n        self.assertEqual(len(list_items), len(bundles))\n\n        for index, list_item in enumerate(list_items):\n            bundle = bundles[index]\n            link = list_item.select_one(\"a\")\n            href = link.attrs[\"href\"]\n\n            self.assertEqual(bundle.name, list_item.text.strip())\n            self.assertEqual(f\"?bundle={bundle.id}\", href)\n\n    def test_should_list_unarchived_and_user_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        visible_bookmarks = self.setup_numbered_bookmarks(3)\n        invisible_bookmarks = [\n            self.setup_bookmark(is_archived=True),\n            self.setup_bookmark(user=other_user),\n        ]\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_bookmarks_matching_query(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(3, prefix=\"foo\")\n        invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix=\"bar\")\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\") + \"?q=foo\")\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_bookmarks_matching_bundle(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(3, prefix=\"foo\")\n        invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix=\"bar\")\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\") + f\"?bundle={bundle.id}\"\n        )\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True)\n        archived_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, archived=True, tag_prefix=\"archived\"\n        )\n        other_user_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, user=other_user, tag_prefix=\"otheruser\"\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)\n        invisible_tags = self.get_tags_from_bookmarks(\n            archived_bookmarks + other_user_bookmarks\n        )\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_bookmarks_matching_query(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, prefix=\"foo\", tag_prefix=\"foo\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, prefix=\"bar\", tag_prefix=\"bar\"\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)\n        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\") + \"?q=foo\")\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_bookmarks_matching_bundle(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, prefix=\"foo\", tag_prefix=\"foo\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, with_tags=True, prefix=\"bar\", tag_prefix=\"bar\"\n        )\n\n        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)\n        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\") + f\"?bundle={bundle.id}\"\n        )\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_bookmarks_and_tags_for_search_preferences(self):\n        user_profile = self.user.profile\n        user_profile.search_preferences = {\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        user_profile.save()\n\n        unread_bookmarks = self.setup_numbered_bookmarks(\n            3, unread=True, with_tags=True, prefix=\"unread\", tag_prefix=\"unread\"\n        )\n        read_bookmarks = self.setup_numbered_bookmarks(\n            3, unread=False, with_tags=True, prefix=\"read\", tag_prefix=\"read\"\n        )\n\n        unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)\n        read_tags = self.get_tags_from_bookmarks(read_bookmarks)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        self.assertVisibleBookmarks(response, unread_bookmarks)\n        self.assertInvisibleBookmarks(response, read_bookmarks)\n        self.assertVisibleTags(response, unread_tags)\n        self.assertInvisibleTags(response, read_tags)\n\n    def test_should_display_selected_tags_from_query(self):\n        tags = [\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\")\n            + f\"?q=%23{tags[0].name}+%23{tags[1].name.upper()}\"\n        )\n\n        self.assertSelectedTags(response, [tags[0], tags[1]])\n\n    def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(\n        self,\n    ):\n        tags = [\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n        ]\n        self.setup_bookmark(title=tags[0].name, tags=tags)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\")\n            + f\"?q={tags[0].name}+%23{tags[1].name.upper()}\"\n        )\n\n        self.assertSelectedTags(response, [tags[1]])\n\n    def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):\n        self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX\n        self.user.profile.save()\n\n        tags = [\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n            self.setup_tag(),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.index\")\n            + f\"?q={tags[0].name}+%23{tags[1].name.upper()}\"\n        )\n\n        self.assertSelectedTags(response, [tags[0], tags[1]])\n\n    def test_should_open_bookmarks_in_new_page_by_default(self):\n        visible_bookmarks = self.setup_numbered_bookmarks(3)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks, \"_blank\")\n\n    def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):\n        user = self.get_or_create_test_user()\n        user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF\n        user.profile.save()\n\n        visible_bookmarks = self.setup_numbered_bookmarks(3)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks, \"_self\")\n\n    def test_edit_link_return_url_respects_search_options(self):\n        bookmark = self.setup_bookmark(title=\"foo\")\n        edit_url = reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        base_url = reverse(\"linkding:bookmarks.index\")\n\n        # without query params\n        return_url = urllib.parse.quote(base_url)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url)\n        self.assertEditLink(response, url)\n\n        # with query\n        url_params = \"?q=foo\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n        # with query and sort and page\n        url_params = \"?q=foo&sort=title_asc&page=2\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n    def test_bulk_edit_respects_search_options(self):\n        action_url = reverse(\"linkding:bookmarks.index.action\")\n        base_url = reverse(\"linkding:bookmarks.index\")\n\n        # without params\n        url = f\"{action_url}\"\n\n        response = self.client.get(base_url)\n        self.assertBulkActionForm(response, url)\n\n        # with query\n        url_params = \"?q=foo\"\n        url = f\"{action_url}?q=foo\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertBulkActionForm(response, url)\n\n        # with query and sort\n        url_params = \"?q=foo&sort=title_asc\"\n        url = f\"{action_url}?q=foo&sort=title_asc\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertBulkActionForm(response, url)\n\n    def test_allowed_bulk_actions(self):\n        url = reverse(\"linkding:bookmarks.index\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_archive\">Archive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_allowed_bulk_actions_with_html_snapshot_enabled(self):\n        url = reverse(\"linkding:bookmarks.index\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_archive\">Archive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n            <option value=\"bulk_snapshot\">Create HTML snapshot</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    def test_allowed_bulk_actions_with_sharing_enabled(self):\n        user_profile = self.user.profile\n        user_profile.enable_sharing = True\n        user_profile.save()\n\n        url = reverse(\"linkding:bookmarks.index\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_archive\">Archive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_share\">Share</option>\n            <option value=\"bulk_unshare\">Unshare</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):\n        user_profile = self.user.profile\n        user_profile.enable_sharing = True\n        user_profile.save()\n\n        url = reverse(\"linkding:bookmarks.index\")\n        response = self.client.get(url)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n          <select name=\"bulk_action\" class=\"form-select select-sm\">\n            <option value=\"bulk_archive\">Archive</option>\n            <option value=\"bulk_delete\">Delete</option>\n            <option value=\"bulk_tag\">Add tags</option>\n            <option value=\"bulk_untag\">Remove tags</option>\n            <option value=\"bulk_read\">Mark as read</option>\n            <option value=\"bulk_unread\">Mark as unread</option>\n            <option value=\"bulk_share\">Share</option>\n            <option value=\"bulk_unshare\">Unshare</option>\n            <option value=\"bulk_refresh\">Refresh from website</option>\n            <option value=\"bulk_snapshot\">Create HTML snapshot</option>\n          </select>\n        \"\"\",\n            html,\n        )\n\n    def test_apply_search_preferences(self):\n        # no params\n        response = self.client.post(reverse(\"linkding:bookmarks.index\"))\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(response.url, reverse(\"linkding:bookmarks.index\"))\n\n        # some params\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"q\": \"foo\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.index\") + \"?q=foo&sort=title_asc\"\n        )\n\n        # params with default value are removed\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"q\": \"foo\",\n                \"user\": \"\",\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.index\") + \"?q=foo&unread=yes\"\n        )\n\n        # page is removed\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"q\": \"foo\",\n                \"page\": \"2\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.index\") + \"?q=foo&sort=title_asc\"\n        )\n\n    def test_save_search_preferences(self):\n        user_profile = self.user.profile\n\n        # no params\n        self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"save\": \"\",\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # with param\n        self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"save\": \"\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # add a param\n        self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"save\": \"\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # remove a param\n        self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"save\": \"\",\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # ignores non-preferences\n        self.client.post(\n            reverse(\"linkding:bookmarks.index\"),\n            {\n                \"save\": \"\",\n                \"q\": \"foo\",\n                \"user\": \"john\",\n                \"page\": \"3\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n    def test_url_encode_bookmark_actions_url(self):\n        url = reverse(\"linkding:bookmarks.index\") + \"?q=%23foo\"\n        response = self.client.get(url)\n        html = response.content.decode()\n        soup = self.make_soup(html)\n        actions_form = soup.select(\"form.bookmark-actions\")[0]\n\n        self.assertEqual(\n            actions_form.attrs[\"action\"],\n            \"/bookmarks/action?q=%23foo\",\n        )\n\n    def test_encode_search_params(self):\n        bookmark = self.setup_bookmark(description=\"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?q=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n        self.assertContains(response, bookmark.url)\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?sort=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?unread=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?shared=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?user=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?page=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n    def test_turbo_frame_details_modal_renders_details_modal_update(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\"linkding:bookmarks.index\") + f\"?bookmark_id={bookmark.id}\"\n        response = self.client.get(url, headers={\"Turbo-Frame\": \"details-modal\"})\n\n        self.assertEqual(200, response.status_code)\n\n        soup = self.make_soup(response.content.decode())\n        self.assertIsNotNone(soup.select_one(\"turbo-frame#details-modal\"))\n        self.assertIsNone(soup.select_one(\"#bookmark-list-container\"))\n        self.assertIsNone(soup.select_one(\"#tag-cloud-container\"))\n\n    def test_does_not_include_rss_feed(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        soup = self.make_soup(response.content.decode())\n\n        feed = soup.select_one('head link[type=\"application/rss+xml\"]')\n        self.assertIsNone(feed)\n\n    def test_list_bundles(self):\n        books = self.setup_bundle(name=\"Books bundle\", order=3)\n        music = self.setup_bundle(name=\"Music bundle\", order=1)\n        tools = self.setup_bundle(name=\"Tools bundle\", order=2)\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n        soup = self.make_soup(html)\n\n        self.assertVisibleBundles(soup, [music, tools, books])\n\n    def test_list_bundles_only_shows_user_owned_bundles(self):\n        user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]\n        other_user = self.setup_user()\n        self.setup_bundle(user=other_user)\n        self.setup_bundle(user=other_user)\n        self.setup_bundle(user=other_user)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n        soup = self.make_soup(html)\n\n        self.assertVisibleBundles(soup, user_bundles)\n\n    def test_hide_bundles_when_enabled_in_profile(self):\n        # visible by default\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML('<h2 id=\"bundles-heading\">Bundles</h2>', html)\n\n        # hidden when disabled in profile\n        user_profile = self.get_or_create_test_user().profile\n        user_profile.hide_bundles = True\n        user_profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML('<h2 id=\"bundles-heading\">Bundles</h2>', html, count=0)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_index_view_performance.py",
    "content": "from django.db import connections\nfrom django.db.utils import DEFAULT_DB_ALIAS\nfrom django.test import TransactionTestCase\nfrom django.test.utils import CaptureQueriesContext\nfrom django.urls import reverse\n\nfrom bookmarks.models import GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BookmarkIndexViewPerformanceTestCase(\n    TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin\n):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def get_connection(self):\n        return connections[DEFAULT_DB_ALIAS]\n\n    def test_should_not_increase_number_of_queries_per_bookmark(self):\n        # create global settings\n        GlobalSettings.get()\n\n        # create initial bookmarks\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            self.setup_bookmark(user=self.user)\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n            html = response.content.decode(\"utf-8\")\n            soup = self.make_soup(html)\n            list_items = soup.select(\"ul.bookmark-list > li\")\n            self.assertEqual(len(list_items), num_initial_bookmarks)\n\n        number_of_queries = context.final_queries\n\n        # add more bookmarks\n        num_additional_bookmarks = 10\n        for _ in range(num_additional_bookmarks):\n            self.setup_bookmark(user=self.user)\n\n        # assert num queries doesn't increase\n        with self.assertNumQueries(number_of_queries):\n            response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n            html = response.content.decode(\"utf-8\")\n            soup = self.make_soup(html)\n            list_items = soup.select(\"ul.bookmark-list > li\")\n            self.assertEqual(\n                len(list_items), num_initial_bookmarks + num_additional_bookmarks\n            )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_new_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Bookmark\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def create_form_data(self, overrides=None):\n        if overrides is None:\n            overrides = {}\n        form_data = {\n            \"url\": \"http://example.com\",\n            \"tag_string\": \"tag1 tag2\",\n            \"title\": \"test title\",\n            \"description\": \"test description\",\n            \"notes\": \"test notes\",\n            \"unread\": False,\n            \"shared\": False,\n            \"auto_close\": \"\",\n        }\n        return {**form_data, **overrides}\n\n    def test_should_create_new_bookmark(self):\n        form_data = self.create_form_data()\n\n        self.client.post(reverse(\"linkding:bookmarks.new\"), form_data)\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n\n        bookmark = Bookmark.objects.first()\n        self.assertEqual(bookmark.owner, self.user)\n        self.assertEqual(bookmark.url, form_data[\"url\"])\n        self.assertEqual(bookmark.title, form_data[\"title\"])\n        self.assertEqual(bookmark.description, form_data[\"description\"])\n        self.assertEqual(bookmark.notes, form_data[\"notes\"])\n        self.assertEqual(bookmark.unread, form_data[\"unread\"])\n        self.assertEqual(bookmark.shared, form_data[\"shared\"])\n        self.assertEqual(bookmark.tags.count(), 2)\n        tags = bookmark.tags.order_by(\"name\").all()\n        self.assertEqual(tags[0].name, \"tag1\")\n        self.assertEqual(tags[1].name, \"tag2\")\n\n    def test_should_return_422_with_invalid_form(self):\n        form_data = self.create_form_data({\"url\": \"\"})\n        response = self.client.post(reverse(\"linkding:bookmarks.new\"), form_data)\n        self.assertEqual(response.status_code, 422)\n\n    def test_should_create_new_unread_bookmark(self):\n        form_data = self.create_form_data({\"unread\": True})\n\n        self.client.post(reverse(\"linkding:bookmarks.new\"), form_data)\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n\n        bookmark = Bookmark.objects.first()\n        self.assertTrue(bookmark.unread)\n\n    def test_should_create_new_shared_bookmark(self):\n        form_data = self.create_form_data({\"shared\": True})\n\n        self.client.post(reverse(\"linkding:bookmarks.new\"), form_data)\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n\n        bookmark = Bookmark.objects.first()\n        self.assertTrue(bookmark.shared)\n\n    def test_should_prefill_url_from_url_parameter(self):\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.new\") + \"?url=http://example.com\"\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <input type=\"text\" name=\"url\" aria-invalid=\"false\" autocomplete=\"off\" autofocus class=\"form-input\" required id=\"id_url\" value=\"http://example.com\">\n            \"\"\",\n            html,\n        )\n\n    def test_should_prefill_title_from_url_parameter(self):\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.new\") + \"?title=Example%20Title\"\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"text\" name=\"title\" value=\"Example Title\" '\n            'class=\"form-input\" maxlength=\"512\" autocomplete=\"off\" '\n            'id=\"id_title\">',\n            html,\n        )\n\n    def test_should_prefill_description_from_url_parameter(self):\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.new\")\n            + \"?description=Example%20Site%20Description\"\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<textarea name=\"description\" class=\"form-input\" cols=\"40\" '\n            'rows=\"3\" id=\"id_description\">Example Site Description</textarea>',\n            html,\n        )\n\n    def test_should_prefill_tags_from_url_parameter(self):\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.new\") + \"?tags=tag1%20tag2%20tag3\"\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n                <ld-tag-autocomplete input-id=\"id_tag_string\" input-name=\"tag_string\" input-value=\"tag1 tag2 tag3\"\n                         input-aria-describedby=\"id_tag_string_help\">\n                </ld-tag-autocomplete>\n            \"\"\",\n            html,\n        )\n\n    def test_should_prefill_notes_from_url_parameter(self):\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.new\")\n            + \"?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29\"\n        )\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <details class=\"notes\" open=\"\">\n                <summary>\n                    <span class=\"form-label d-inline-block\">Notes</span>\n                </summary>\n                <label for=\"id_notes\" class=\"text-assistive\">Notes</label>\n                <textarea name=\"notes\" cols=\"40\" rows=\"8\" class=\"form-input\" id=\"id_notes\" aria-describedby=\"id_notes_help\">**Find** more info [here](http://example.com)</textarea>\n                <div id=\"id_notes_help\" class=\"form-input-hint\">\n                    Additional notes, supports Markdown.\n                </div>\n            </details>\n            \"\"\",\n            html,\n        )\n\n    def test_should_enable_auto_close_when_specified_in_url_parameter(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.new\") + \"?auto_close\")\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"hidden\" name=\"auto_close\" value=\"True\" id=\"id_auto_close\">',\n            html,\n        )\n\n    def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"hidden\" name=\"auto_close\" value=\"False\" id=\"id_auto_close\">',\n            html,\n        )\n\n    def test_should_redirect_to_index_view(self):\n        form_data = self.create_form_data()\n\n        response = self.client.post(reverse(\"linkding:bookmarks.new\"), form_data)\n\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n    def test_should_not_redirect_to_external_url(self):\n        form_data = self.create_form_data()\n\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.new\") + \"?return_url=https://example.com\",\n            form_data,\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n    def test_auto_close_should_redirect_to_close_view(self):\n        form_data = self.create_form_data({\"auto_close\": \"True\"})\n\n        response = self.client.post(reverse(\"linkding:bookmarks.new\"), form_data)\n\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.close\"))\n\n    def test_should_respect_share_profile_setting(self):\n        self.user.profile.enable_sharing = False\n        self.user.profile.save()\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <div class=\"form-checkbox\">\n                <input type=\"checkbox\" name=\"shared\" aria-describedby=\"id_shared_help\" id=\"id_shared\">\n                <i class=\"form-icon\"></i>\n                <label for=\"id_shared\">Share</label>\n            </div>          \n            \"\"\",\n            html,\n            count=0,\n        )\n\n        self.user.profile.enable_sharing = True\n        self.user.profile.save()\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <div class=\"form-checkbox\">\n                <input type=\"checkbox\" name=\"shared\" aria-describedby=\"id_shared_help\" id=\"id_shared\">\n                <i class=\"form-icon\"></i>\n                <label for=\"id_shared\">Share</label>\n            </div>              \n            \"\"\",\n            html,\n            count=1,\n        )\n\n    def test_should_show_respective_share_hint(self):\n        self.user.profile.enable_sharing = True\n        self.user.profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n        self.assertInHTML(\n            \"\"\"\n              <div id=\"id_shared_help\" class=\"form-input-hint\">\n                  Share this bookmark with other registered users.\n              </div>\n            \"\"\",\n            html,\n        )\n\n        self.user.profile.enable_public_sharing = True\n        self.user.profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n        self.assertInHTML(\n            \"\"\"\n              <div id=\"id_shared_help\" class=\"form-input-hint\">\n                  Share this bookmark with other registered users and anonymous users.\n              </div>\n            \"\"\",\n            html,\n        )\n\n    def test_should_hide_notes_if_there_are_no_notes(self):\n        bookmark = self.setup_bookmark()\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        )\n\n        self.assertContains(response, '<details class=\"notes\">', count=1)\n\n    def test_should_not_check_unread_by_default(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"checkbox\" name=\"unread\" id=\"id_unread\" aria-describedby=\"id_unread_help\">',\n            html,\n        )\n\n    def test_should_check_unread_when_configured_in_profile(self):\n        self.user.profile.default_mark_unread = True\n        self.user.profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"checkbox\" name=\"unread\" id=\"id_unread\" checked=\"\" aria-describedby=\"id_unread_help\">',\n            html,\n        )\n\n    def test_should_not_check_shared_by_default(self):\n        self.user.profile.enable_sharing = True\n        self.user.profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"checkbox\" name=\"shared\" id=\"id_shared\" aria-describedby=\"id_shared_help\">',\n            html,\n        )\n\n    def test_should_check_shared_when_configured_in_profile(self):\n        self.user.profile.enable_sharing = True\n        self.user.profile.default_mark_shared = True\n        self.user.profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.new\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<input type=\"checkbox\" name=\"shared\" id=\"id_shared\" checked=\"\" aria-describedby=\"id_shared_help\">',\n            html,\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_previews.py",
    "content": "import os\nimport shutil\nimport tempfile\n\nfrom django.conf import settings\nfrom django.test import TestCase, override_settings\n\nfrom bookmarks.services import bookmarks\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.temp_dir = tempfile.mkdtemp()\n        self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)\n        self.override.enable()\n\n    def tearDown(self):\n        self.override.disable()\n        shutil.rmtree(self.temp_dir)\n\n    def setup_preview_file(self, filename):\n        filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)\n        with open(filepath, \"w\") as f:\n            f.write(\"test\")\n\n    def setup_bookmark_with_preview(self):\n        bookmark = self.setup_bookmark()\n        bookmark.preview_image_file = f\"preview_{bookmark.id}.jpg\"\n        bookmark.save()\n        self.setup_preview_file(bookmark.preview_image_file)\n        return bookmark\n\n    def assertPreviewImageExists(self, bookmark):\n        self.assertTrue(\n            os.path.exists(\n                os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)\n            )\n        )\n\n    def assertPreviewImageDoesNotExist(self, bookmark):\n        self.assertFalse(\n            os.path.exists(\n                os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)\n            )\n        )\n\n    def test_delete_bookmark_deletes_preview_image(self):\n        bookmark = self.setup_bookmark_with_preview()\n        self.assertPreviewImageExists(bookmark)\n\n        bookmark.delete()\n        self.assertPreviewImageDoesNotExist(bookmark)\n\n    def test_bulk_delete_bookmarks_deletes_preview_images(self):\n        bookmark1 = self.setup_bookmark_with_preview()\n        bookmark2 = self.setup_bookmark_with_preview()\n        bookmark3 = self.setup_bookmark_with_preview()\n\n        self.assertPreviewImageExists(bookmark1)\n        self.assertPreviewImageExists(bookmark2)\n        self.assertPreviewImageExists(bookmark3)\n\n        bookmarks.delete_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertPreviewImageDoesNotExist(bookmark1)\n        self.assertPreviewImageDoesNotExist(bookmark2)\n        self.assertPreviewImageDoesNotExist(bookmark3)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_search_form.py",
    "content": "from django.test import TestCase\n\nfrom bookmarks.forms import BookmarkSearchForm\nfrom bookmarks.models import BookmarkSearch\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):\n    def test_initial_values(self):\n        # no params\n        search = BookmarkSearch()\n        form = BookmarkSearchForm(search)\n        self.assertEqual(form[\"q\"].initial, \"\")\n        self.assertEqual(form[\"user\"].initial, \"\")\n        self.assertEqual(form[\"bundle\"].initial, None)\n        self.assertEqual(form[\"sort\"].initial, BookmarkSearch.SORT_ADDED_DESC)\n        self.assertEqual(form[\"shared\"].initial, BookmarkSearch.FILTER_SHARED_OFF)\n        self.assertEqual(form[\"unread\"].initial, BookmarkSearch.FILTER_UNREAD_OFF)\n\n        # with params\n        bundle = self.setup_bundle()\n        search = BookmarkSearch(\n            q=\"search query\",\n            sort=BookmarkSearch.SORT_ADDED_ASC,\n            user=\"user123\",\n            bundle=bundle,\n            shared=BookmarkSearch.FILTER_SHARED_SHARED,\n            unread=BookmarkSearch.FILTER_UNREAD_YES,\n        )\n        form = BookmarkSearchForm(search)\n        self.assertEqual(form[\"q\"].initial, \"search query\")\n        self.assertEqual(form[\"user\"].initial, \"user123\")\n        self.assertEqual(form[\"bundle\"].initial, bundle.id)\n        self.assertEqual(form[\"sort\"].initial, BookmarkSearch.SORT_ADDED_ASC)\n        self.assertEqual(form[\"shared\"].initial, BookmarkSearch.FILTER_SHARED_SHARED)\n        self.assertEqual(form[\"unread\"].initial, BookmarkSearch.FILTER_UNREAD_YES)\n\n    def test_user_options(self):\n        users = [\n            self.setup_user(\"user1\"),\n            self.setup_user(\"user2\"),\n            self.setup_user(\"user3\"),\n        ]\n        search = BookmarkSearch()\n        form = BookmarkSearchForm(search, users=users)\n\n        self.assertCountEqual(\n            form[\"user\"].field.choices,\n            [\n                (\"\", \"Everyone\"),\n                (\"user1\", \"user1\"),\n                (\"user2\", \"user2\"),\n                (\"user3\", \"user3\"),\n            ],\n        )\n\n    def test_hidden_fields(self):\n        # no modified params\n        search = BookmarkSearch()\n        form = BookmarkSearchForm(search)\n        self.assertEqual(len(form.hidden_fields()), 0)\n\n        # some modified params\n        search = BookmarkSearch(q=\"search query\", sort=BookmarkSearch.SORT_ADDED_ASC)\n        form = BookmarkSearchForm(search)\n        self.assertCountEqual(form.hidden_fields(), [form[\"q\"], form[\"sort\"]])\n\n        # all modified params\n        bundle = self.setup_bundle()\n        search = BookmarkSearch(\n            q=\"search query\",\n            sort=BookmarkSearch.SORT_ADDED_ASC,\n            user=\"user123\",\n            bundle=bundle,\n            shared=BookmarkSearch.FILTER_SHARED_SHARED,\n            unread=BookmarkSearch.FILTER_UNREAD_YES,\n        )\n        form = BookmarkSearchForm(search)\n        self.assertCountEqual(\n            form.hidden_fields(),\n            [\n                form[\"q\"],\n                form[\"sort\"],\n                form[\"user\"],\n                form[\"bundle\"],\n                form[\"shared\"],\n                form[\"unread\"],\n            ],\n        )\n\n        # some modified params are editable fields\n        search = BookmarkSearch(\n            q=\"search query\", sort=BookmarkSearch.SORT_ADDED_ASC, user=\"user123\"\n        )\n        form = BookmarkSearchForm(search, editable_fields=[\"q\", \"user\"])\n        self.assertCountEqual(form.hidden_fields(), [form[\"sort\"]])\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_search_model.py",
    "content": "from django.http import QueryDict\nfrom django.test import TestCase\n\nfrom bookmarks.models import BookmarkSearch\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass MockRequest:\n    def __init__(self, user):\n        self.user = user\n\n\nclass BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):\n    def test_from_request(self):\n        # no params\n        query_dict = QueryDict()\n\n        search = BookmarkSearch.from_request(None, query_dict)\n        self.assertEqual(search.q, \"\")\n        self.assertEqual(search.user, \"\")\n        self.assertEqual(search.bundle, None)\n        self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)\n        self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)\n        self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)\n\n        # some params\n        query_dict = QueryDict(\"q=search query&user=user123\")\n\n        bookmark_search = BookmarkSearch.from_request(None, query_dict)\n        self.assertEqual(bookmark_search.q, \"search query\")\n        self.assertEqual(bookmark_search.user, \"user123\")\n        self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)\n        self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)\n        self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)\n\n        # all params\n        bundle = self.setup_bundle()\n        request = MockRequest(self.get_or_create_test_user())\n        query_dict = QueryDict(\n            f\"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes\"\n        )\n\n        search = BookmarkSearch.from_request(request, query_dict)\n        self.assertEqual(search.q, \"search query\")\n        self.assertEqual(search.user, \"user123\")\n        self.assertEqual(search.bundle, bundle)\n        self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)\n        self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)\n        self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)\n\n        # respects preferences\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        query_dict = QueryDict(\"q=search query\")\n\n        search = BookmarkSearch.from_request(None, query_dict, preferences)\n        self.assertEqual(search.q, \"search query\")\n        self.assertEqual(search.user, \"\")\n        self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)\n        self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)\n        self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)\n\n        # query overrides preferences\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"shared\": BookmarkSearch.FILTER_SHARED_SHARED,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        query_dict = QueryDict(\"sort=title_desc&shared=no&unread=off\")\n\n        search = BookmarkSearch.from_request(None, query_dict, preferences)\n        self.assertEqual(search.q, \"\")\n        self.assertEqual(search.user, \"\")\n        self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)\n        self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)\n        self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)\n\n    def test_from_request_ignores_invalid_bundle_param(self):\n        self.setup_bundle()\n\n        # bundle does not exist\n        request = MockRequest(self.get_or_create_test_user())\n        query_dict = QueryDict(\"bundle=99999\")\n        search = BookmarkSearch.from_request(request, query_dict)\n        self.assertIsNone(search.bundle)\n\n        # bundle belongs to another user\n        other_user = self.setup_user()\n        bundle = self.setup_bundle(user=other_user)\n        query_dict = QueryDict(f\"bundle={bundle.id}\")\n        search = BookmarkSearch.from_request(request, query_dict)\n        self.assertIsNone(search.bundle)\n\n    def test_query_params(self):\n        # no params\n        search = BookmarkSearch()\n        self.assertEqual(search.query_params, {})\n\n        # params are default values\n        search = BookmarkSearch(\n            q=\"\", sort=BookmarkSearch.SORT_ADDED_DESC, user=\"\", bundle=None, shared=\"\"\n        )\n        self.assertEqual(search.query_params, {})\n\n        # some modified params\n        search = BookmarkSearch(q=\"search query\", sort=BookmarkSearch.SORT_ADDED_ASC)\n        self.assertEqual(\n            search.query_params,\n            {\"q\": \"search query\", \"sort\": BookmarkSearch.SORT_ADDED_ASC},\n        )\n\n        # all modified params\n        bundle = self.setup_bundle()\n        search = BookmarkSearch(\n            q=\"search query\",\n            sort=BookmarkSearch.SORT_ADDED_ASC,\n            user=\"user123\",\n            bundle=bundle,\n            shared=BookmarkSearch.FILTER_SHARED_SHARED,\n            unread=BookmarkSearch.FILTER_UNREAD_YES,\n        )\n        self.assertEqual(\n            search.query_params,\n            {\n                \"q\": \"search query\",\n                \"sort\": BookmarkSearch.SORT_ADDED_ASC,\n                \"user\": \"user123\",\n                \"bundle\": bundle.id,\n                \"shared\": BookmarkSearch.FILTER_SHARED_SHARED,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # preferences are not query params if they match default\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        search = BookmarkSearch(preferences=preferences)\n        self.assertEqual(search.query_params, {})\n\n        # param is not a query param if it matches the preference\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        search = BookmarkSearch(\n            sort=BookmarkSearch.SORT_TITLE_ASC,\n            unread=BookmarkSearch.FILTER_UNREAD_YES,\n            preferences=preferences,\n        )\n        self.assertEqual(search.query_params, {})\n\n        # overriding preferences is a query param\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"shared\": BookmarkSearch.FILTER_SHARED_SHARED,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        search = BookmarkSearch(\n            sort=BookmarkSearch.SORT_TITLE_DESC,\n            shared=BookmarkSearch.FILTER_SHARED_UNSHARED,\n            unread=BookmarkSearch.FILTER_UNREAD_OFF,\n            preferences=preferences,\n        )\n        self.assertEqual(\n            search.query_params,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_UNSHARED,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n    def test_modified_params(self):\n        # no params\n        bookmark_search = BookmarkSearch()\n        modified_params = bookmark_search.modified_params\n        self.assertEqual(len(modified_params), 0)\n\n        # params are default values\n        bookmark_search = BookmarkSearch(\n            q=\"\", sort=BookmarkSearch.SORT_ADDED_DESC, user=\"\", shared=\"\"\n        )\n        modified_params = bookmark_search.modified_params\n        self.assertEqual(len(modified_params), 0)\n\n        # some modified params\n        bookmark_search = BookmarkSearch(\n            q=\"search query\", sort=BookmarkSearch.SORT_ADDED_ASC\n        )\n        modified_params = bookmark_search.modified_params\n        self.assertCountEqual(modified_params, [\"q\", \"sort\"])\n\n        # all modified params\n        bundle = self.setup_bundle()\n        bookmark_search = BookmarkSearch(\n            q=\"search query\",\n            sort=BookmarkSearch.SORT_ADDED_ASC,\n            user=\"user123\",\n            bundle=bundle,\n            shared=BookmarkSearch.FILTER_SHARED_SHARED,\n            unread=BookmarkSearch.FILTER_UNREAD_YES,\n        )\n        modified_params = bookmark_search.modified_params\n        self.assertCountEqual(\n            modified_params, [\"q\", \"sort\", \"user\", \"bundle\", \"shared\", \"unread\"]\n        )\n\n        # preferences are not modified params\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        bookmark_search = BookmarkSearch(preferences=preferences)\n        modified_params = bookmark_search.modified_params\n        self.assertEqual(len(modified_params), 0)\n\n        # param is not modified if it matches the preference\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        bookmark_search = BookmarkSearch(\n            sort=BookmarkSearch.SORT_TITLE_ASC,\n            unread=BookmarkSearch.FILTER_UNREAD_YES,\n            preferences=preferences,\n        )\n        modified_params = bookmark_search.modified_params\n        self.assertEqual(len(modified_params), 0)\n\n        # overriding preferences is a modified param\n        preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"shared\": BookmarkSearch.FILTER_SHARED_SHARED,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        bookmark_search = BookmarkSearch(\n            sort=BookmarkSearch.SORT_TITLE_DESC,\n            shared=BookmarkSearch.FILTER_SHARED_UNSHARED,\n            unread=BookmarkSearch.FILTER_UNREAD_OFF,\n            preferences=preferences,\n        )\n        modified_params = bookmark_search.modified_params\n        self.assertCountEqual(modified_params, [\"sort\", \"shared\", \"unread\"])\n\n    def test_has_modifications(self):\n        # no params\n        bookmark_search = BookmarkSearch()\n        self.assertFalse(bookmark_search.has_modifications)\n\n        # params are default values\n        bookmark_search = BookmarkSearch(\n            q=\"\", sort=BookmarkSearch.SORT_ADDED_DESC, user=\"\", shared=\"\"\n        )\n        self.assertFalse(bookmark_search.has_modifications)\n\n        # modified params\n        bookmark_search = BookmarkSearch(\n            q=\"search query\", sort=BookmarkSearch.SORT_ADDED_ASC\n        )\n        self.assertTrue(bookmark_search.has_modifications)\n\n    def test_preferences_dict(self):\n        # no params\n        bookmark_search = BookmarkSearch()\n        self.assertEqual(\n            bookmark_search.preferences_dict,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # with params\n        bookmark_search = BookmarkSearch(\n            sort=BookmarkSearch.SORT_TITLE_DESC, unread=BookmarkSearch.FILTER_UNREAD_YES\n        )\n        self.assertEqual(\n            bookmark_search.preferences_dict,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # only returns preferences\n        bundle = self.setup_bundle()\n        bookmark_search = BookmarkSearch(\n            q=\"search query\", user=\"user123\", bundle=bundle\n        )\n        self.assertEqual(\n            bookmark_search.preferences_dict,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_search_tag.py",
    "content": "from bs4 import BeautifulSoup\nfrom django.template import RequestContext, Template\nfrom django.test import RequestFactory, TestCase\n\nfrom bookmarks.models import BookmarkSearch\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def render_template(self, url: str, mode: str = \"\"):\n        rf = RequestFactory()\n        request = rf.get(url)\n        request.user = self.get_or_create_test_user()\n        request.user_profile = self.get_or_create_test_user().profile\n        search = BookmarkSearch.from_request(request, request.GET)\n        context = RequestContext(\n            request,\n            {\n                \"request\": request,\n                \"search\": search,\n                \"mode\": mode,\n            },\n        )\n        template_to_render = Template(\n            \"{% load bookmarks %} {% bookmark_search search mode %}\"\n        )\n        return template_to_render.render(context)\n\n    def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):\n        element = form.select_one(f'input[name=\"{name}\"][type=\"hidden\"]')\n        self.assertIsNotNone(element)\n\n        if value is not None:\n            self.assertEqual(element[\"value\"], value)\n\n    def assertNoHiddenInput(self, form: BeautifulSoup, name: str):\n        element = form.select_one(f'input[name=\"{name}\"][type=\"hidden\"]')\n        self.assertIsNone(element)\n\n    def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):\n        element = form.select_one(f'ld-search-autocomplete[input-name=\"{name}\"]')\n        self.assertIsNotNone(element)\n\n        if value is not None:\n            self.assertEqual(element[\"input-value\"], value)\n\n    def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):\n        select = form.select_one(f'select[name=\"{name}\"]')\n        self.assertIsNotNone(select)\n\n        if value is not None:\n            options = select.select(\"option\")\n            for option in options:\n                if option[\"value\"] == value:\n                    self.assertTrue(option.has_attr(\"selected\"))\n                else:\n                    self.assertFalse(option.has_attr(\"selected\"))\n\n    def assertRadioGroup(self, form: BeautifulSoup, name: str, value: str = None):\n        radios = form.select(f'input[name=\"{name}\"][type=\"radio\"]')\n        self.assertTrue(len(radios) > 0)\n\n        if value is not None:\n            for radio in radios:\n                if radio[\"value\"] == value:\n                    self.assertTrue(radio.has_attr(\"checked\"))\n                else:\n                    self.assertFalse(radio.has_attr(\"checked\"))\n\n    def assertNoRadioGroup(self, form: BeautifulSoup, name: str):\n        radios = form.select(f'input[name=\"{name}\"][type=\"radio\"]')\n        self.assertTrue(len(radios) == 0)\n\n    def assertUnmodifiedLabel(self, html: str, text: str):\n        soup = self.make_soup(html)\n        label = soup.find(\"label\", string=lambda s: s and s.strip() == text)\n        self.assertEqual(label[\"class\"], [\"form-label\"])\n\n    def assertModifiedLabel(self, html: str, text: str):\n        soup = self.make_soup(html)\n        label = soup.find(\"label\", string=lambda s: s and s.strip() == text)\n        self.assertEqual(label[\"class\"], [\"form-label\", \"text-bold\"])\n\n    def test_search_form_inputs(self):\n        # Without params\n        url = \"/test\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        search_form = soup.select_one(\"form#search\")\n\n        self.assertSearchInput(search_form, \"q\")\n        self.assertNoHiddenInput(search_form, \"user\")\n        self.assertNoHiddenInput(search_form, \"sort\")\n        self.assertNoHiddenInput(search_form, \"shared\")\n        self.assertNoHiddenInput(search_form, \"unread\")\n\n        # With params\n        url = \"/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        search_form = soup.select_one(\"form#search\")\n\n        self.assertSearchInput(search_form, \"q\", \"foo\")\n        self.assertHiddenInput(search_form, \"user\", \"john\")\n        self.assertHiddenInput(search_form, \"sort\", BookmarkSearch.SORT_TITLE_ASC)\n        self.assertHiddenInput(\n            search_form, \"shared\", BookmarkSearch.FILTER_SHARED_SHARED\n        )\n        self.assertHiddenInput(search_form, \"unread\", BookmarkSearch.FILTER_UNREAD_YES)\n\n    def test_preferences_form_inputs(self):\n        # Without params\n        url = \"/test\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        preferences_form = soup.select_one(\"form#search_preferences\")\n\n        self.assertNoHiddenInput(preferences_form, \"q\")\n        self.assertNoHiddenInput(preferences_form, \"user\")\n        self.assertNoHiddenInput(preferences_form, \"sort\")\n        self.assertNoHiddenInput(preferences_form, \"shared\")\n        self.assertNoHiddenInput(preferences_form, \"unread\")\n\n        self.assertSelect(preferences_form, \"sort\", BookmarkSearch.SORT_ADDED_DESC)\n        self.assertRadioGroup(\n            preferences_form, \"shared\", BookmarkSearch.FILTER_SHARED_OFF\n        )\n        self.assertRadioGroup(\n            preferences_form, \"unread\", BookmarkSearch.FILTER_UNREAD_OFF\n        )\n\n        # With params\n        url = \"/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        preferences_form = soup.select_one(\"form#search_preferences\")\n\n        self.assertHiddenInput(preferences_form, \"q\", \"foo\")\n        self.assertHiddenInput(preferences_form, \"user\", \"john\")\n        self.assertNoHiddenInput(preferences_form, \"sort\")\n        self.assertNoHiddenInput(preferences_form, \"shared\")\n        self.assertNoHiddenInput(preferences_form, \"unread\")\n\n        self.assertSelect(preferences_form, \"sort\", BookmarkSearch.SORT_TITLE_ASC)\n        self.assertRadioGroup(\n            preferences_form, \"shared\", BookmarkSearch.FILTER_SHARED_SHARED\n        )\n        self.assertRadioGroup(\n            preferences_form, \"unread\", BookmarkSearch.FILTER_UNREAD_YES\n        )\n\n    def test_preferences_form_inputs_shared_mode(self):\n        # Without params\n        url = \"/test\"\n        rendered_template = self.render_template(url, mode=\"shared\")\n        soup = self.make_soup(rendered_template)\n        preferences_form = soup.select_one(\"form#search_preferences\")\n\n        self.assertNoHiddenInput(preferences_form, \"q\")\n        self.assertNoHiddenInput(preferences_form, \"user\")\n        self.assertNoHiddenInput(preferences_form, \"sort\")\n        self.assertNoHiddenInput(preferences_form, \"shared\")\n        self.assertNoHiddenInput(preferences_form, \"unread\")\n\n        self.assertSelect(preferences_form, \"sort\", BookmarkSearch.SORT_ADDED_DESC)\n        self.assertNoRadioGroup(preferences_form, \"shared\")\n        self.assertNoRadioGroup(preferences_form, \"unread\")\n\n        # With params\n        url = \"/test?q=foo&user=john&sort=title_asc\"\n        rendered_template = self.render_template(url, mode=\"shared\")\n        soup = self.make_soup(rendered_template)\n        preferences_form = soup.select_one(\"form#search_preferences\")\n\n        self.assertHiddenInput(preferences_form, \"q\", \"foo\")\n        self.assertHiddenInput(preferences_form, \"user\", \"john\")\n        self.assertNoHiddenInput(preferences_form, \"sort\")\n        self.assertNoHiddenInput(preferences_form, \"shared\")\n        self.assertNoHiddenInput(preferences_form, \"unread\")\n\n        self.assertSelect(preferences_form, \"sort\", BookmarkSearch.SORT_TITLE_ASC)\n        self.assertNoRadioGroup(preferences_form, \"shared\")\n        self.assertNoRadioGroup(preferences_form, \"unread\")\n\n    def test_modified_indicator(self):\n        # Without modifications\n        url = \"/test\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        button = soup.select_one(\"button[aria-label='Search preferences']\")\n\n        self.assertNotIn(\"badge\", button[\"class\"])\n\n        # With modifications\n        url = \"/test?sort=title_asc\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        button = soup.select_one(\"button[aria-label='Search preferences']\")\n\n        self.assertIn(\"badge\", button[\"class\"])\n\n        # Ignores non-preferences modifications\n        url = \"/test?q=foo&user=john\"\n        rendered_template = self.render_template(url)\n        soup = self.make_soup(rendered_template)\n        button = soup.select_one(\"button[aria-label='Search preferences']\")\n\n        self.assertNotIn(\"badge\", button[\"class\"])\n\n    def test_modified_labels(self):\n        # Without modifications\n        url = \"/test\"\n        rendered_template = self.render_template(url)\n\n        self.assertUnmodifiedLabel(rendered_template, \"Sort by\")\n        self.assertUnmodifiedLabel(rendered_template, \"Shared filter\")\n        self.assertUnmodifiedLabel(rendered_template, \"Unread filter\")\n\n        # Modified sort\n        url = \"/test?sort=title_asc\"\n        rendered_template = self.render_template(url)\n        self.assertModifiedLabel(rendered_template, \"Sort by\")\n        self.assertUnmodifiedLabel(rendered_template, \"Shared filter\")\n        self.assertUnmodifiedLabel(rendered_template, \"Unread filter\")\n\n        # Modified shared\n        url = \"/test?shared=yes\"\n        rendered_template = self.render_template(url)\n        self.assertUnmodifiedLabel(rendered_template, \"Sort by\")\n        self.assertModifiedLabel(rendered_template, \"Shared filter\")\n        self.assertUnmodifiedLabel(rendered_template, \"Unread filter\")\n\n        # Modified unread\n        url = \"/test?unread=yes\"\n        rendered_template = self.render_template(url)\n        self.assertUnmodifiedLabel(rendered_template, \"Sort by\")\n        self.assertUnmodifiedLabel(rendered_template, \"Shared filter\")\n        self.assertModifiedLabel(rendered_template, \"Unread filter\")\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_shared_view.py",
    "content": "import urllib.parse\n\nfrom django.contrib.auth.models import User\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Bookmark, BookmarkSearch, UserProfile\nfrom bookmarks.tests.helpers import (\n    BookmarkFactoryMixin,\n    BookmarkListTestMixin,\n    TagCloudTestMixin,\n)\n\n\nclass BookmarkSharedViewTestCase(\n    TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin\n):\n    def authenticate(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def assertBookmarkCount(\n        self, html: str, bookmark: Bookmark, count: int, link_target: str = \"_blank\"\n    ):\n        self.assertInHTML(\n            f'<a href=\"{bookmark.url}\" target=\"{link_target}\" rel=\"noopener\">{bookmark.resolved_title}</a>',\n            html,\n            count=count,\n        )\n\n    def assertVisibleUserOptions(self, response, users: list[User]):\n        html = response.content.decode()\n\n        user_options = ['<option value=\"\" selected=\"\">Everyone</option>']\n        for user in users:\n            user_options.append(\n                f'<option value=\"{user.username}\">{user.username}</option>'\n            )\n        user_select_html = f\"\"\"\n        <select name=\"user\" class=\"form-select\" id=\"id_user\" data-submit-on-change>\n            {\"\".join(user_options)}\n        </select>\n        \"\"\"\n\n        self.assertInHTML(user_select_html, html)\n\n    def assertEditLink(self, response, url):\n        html = response.content.decode()\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{url}\">Edit</a>        \n        \"\"\",\n            html,\n        )\n\n    def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(\n        self,\n    ):\n        self.authenticate()\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        user4 = self.setup_user(enable_sharing=False)\n\n        visible_bookmarks = [\n            self.setup_bookmark(shared=True, user=user1),\n            self.setup_bookmark(shared=True, user=user2),\n            self.setup_bookmark(shared=True, user=user3),\n        ]\n        invisible_bookmarks = [\n            self.setup_bookmark(shared=False, user=user1),\n            self.setup_bookmark(shared=False, user=user2),\n            self.setup_bookmark(shared=False, user=user3),\n            self.setup_bookmark(shared=True, user=user4),\n        ]\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_shared_bookmarks_from_selected_user(self):\n        self.authenticate()\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n\n        visible_bookmarks = [\n            self.setup_bookmark(shared=True, user=user1),\n        ]\n        invisible_bookmarks = [\n            self.setup_bookmark(shared=True, user=user2),\n            self.setup_bookmark(shared=True, user=user3),\n        ]\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?user=\" + user1.username\n        response = self.client.get(url)\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_bookmarks_matching_query(self):\n        self.authenticate()\n        user = self.setup_user(enable_sharing=True)\n\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, shared=True, user=user, prefix=\"foo\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\") + \"?q=foo\")\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_bookmarks_matching_bundle(self):\n        self.authenticate()\n        user = self.setup_user(enable_sharing=True)\n\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, shared=True, user=user, prefix=\"foo\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)\n\n        bundle = self.setup_bundle(search=\"foo\")\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?bundle={bundle.id}\"\n        )\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_only_publicly_shared_bookmarks_without_login(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        visible_bookmarks = self.setup_numbered_bookmarks(\n            3, shared=True, user=user1, prefix=\"user1\"\n        )\n        invisible_bookmarks = self.setup_numbered_bookmarks(\n            3, shared=True, user=user2, prefix=\"user2\"\n        )\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks)\n        self.assertInvisibleBookmarks(response, invisible_bookmarks)\n\n    def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(\n        self,\n    ):\n        self.authenticate()\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        user4 = self.setup_user(enable_sharing=False)\n        visible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n        invisible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n            self.setup_tag(user=user4),\n        ]\n\n        self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])\n        self.setup_bookmark(shared=True, user=user2, tags=[visible_tags[1]])\n        self.setup_bookmark(shared=True, user=user3, tags=[visible_tags[2]])\n\n        self.setup_bookmark(shared=False, user=user1, tags=[invisible_tags[0]])\n        self.setup_bookmark(shared=False, user=user2, tags=[invisible_tags[1]])\n        self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]])\n        self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]])\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_shared_bookmarks_from_selected_user(self):\n        self.authenticate()\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        visible_tags = [\n            self.setup_tag(user=user1),\n        ]\n        invisible_tags = [\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n\n        self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])\n        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])\n        self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]])\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?user=\" + user1.username\n        response = self.client.get(url)\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_bookmarks_matching_query(self):\n        self.authenticate()\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        visible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n        invisible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n\n        self.setup_bookmark(\n            shared=True, user=user1, title=\"searchvalue\", tags=[visible_tags[0]]\n        )\n        self.setup_bookmark(\n            shared=True, user=user2, title=\"searchvalue\", tags=[visible_tags[1]]\n        )\n        self.setup_bookmark(\n            shared=True, user=user3, title=\"searchvalue\", tags=[visible_tags[2]]\n        )\n\n        self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])\n        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])\n        self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + \"?q=searchvalue\"\n        )\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_tags_for_bookmarks_matching_bundle(self):\n        self.authenticate()\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        visible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n        invisible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n\n        self.setup_bookmark(\n            shared=True, user=user1, title=\"searchvalue\", tags=[visible_tags[0]]\n        )\n        self.setup_bookmark(\n            shared=True, user=user2, title=\"searchvalue\", tags=[visible_tags[1]]\n        )\n        self.setup_bookmark(\n            shared=True, user=user3, title=\"searchvalue\", tags=[visible_tags[2]]\n        )\n\n        self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])\n        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])\n        self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])\n\n        bundle = self.setup_bundle(search=\"searchvalue\")\n\n        response = self.client.get(\n            reverse(\"linkding:bookmarks.shared\") + f\"?bundle={bundle.id}\"\n        )\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        visible_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user1),\n        ]\n        invisible_tags = [\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user2),\n        ]\n\n        self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])\n        self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[1]])\n\n        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])\n        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n\n        self.assertVisibleTags(response, visible_tags)\n        self.assertInvisibleTags(response, invisible_tags)\n\n    def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):\n        self.authenticate()\n        expected_visible_users = [\n            self.setup_user(name=\"user_a\", enable_sharing=True),\n            self.setup_user(name=\"user_b\", enable_sharing=True),\n        ]\n        self.setup_bookmark(shared=True, user=expected_visible_users[0])\n        self.setup_bookmark(shared=True, user=expected_visible_users[1])\n\n        self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True))\n        self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False))\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n        self.assertVisibleUserOptions(response, expected_visible_users)\n\n    def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):\n        # users with public sharing enabled\n        expected_visible_users = [\n            self.setup_user(\n                name=\"user_a\", enable_sharing=True, enable_public_sharing=True\n            ),\n            self.setup_user(\n                name=\"user_b\", enable_sharing=True, enable_public_sharing=True\n            ),\n        ]\n        self.setup_bookmark(shared=True, user=expected_visible_users[0])\n        self.setup_bookmark(shared=True, user=expected_visible_users[1])\n\n        # users with public sharing disabled\n        self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))\n        self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n        self.assertVisibleUserOptions(response, expected_visible_users)\n\n    def test_should_list_bookmarks_and_tags_for_search_preferences(self):\n        self.authenticate()\n        other_user = self.setup_user(enable_sharing=True)\n\n        user_profile = self.get_or_create_test_user().profile\n        user_profile.search_preferences = {\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        user_profile.save()\n\n        unread_bookmarks = self.setup_numbered_bookmarks(\n            3,\n            shared=True,\n            unread=True,\n            with_tags=True,\n            prefix=\"unread\",\n            tag_prefix=\"unread\",\n            user=other_user,\n        )\n        read_bookmarks = self.setup_numbered_bookmarks(\n            3,\n            shared=True,\n            unread=False,\n            with_tags=True,\n            prefix=\"read\",\n            tag_prefix=\"read\",\n            user=other_user,\n        )\n\n        unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)\n        read_tags = self.get_tags_from_bookmarks(read_bookmarks)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n        self.assertVisibleBookmarks(response, unread_bookmarks)\n        self.assertInvisibleBookmarks(response, read_bookmarks)\n        self.assertVisibleTags(response, unread_tags)\n        self.assertInvisibleTags(response, read_tags)\n\n    def test_should_open_bookmarks_in_new_page_by_default(self):\n        self.authenticate()\n        user = self.get_or_create_test_user()\n        user.profile.enable_sharing = True\n        user.profile.save()\n        visible_bookmarks = [\n            self.setup_bookmark(shared=True),\n            self.setup_bookmark(shared=True),\n            self.setup_bookmark(shared=True),\n        ]\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks, \"_blank\")\n\n    def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):\n        self.authenticate()\n        user = self.get_or_create_test_user()\n        user.profile.enable_sharing = True\n        user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF\n        user.profile.save()\n\n        visible_bookmarks = [\n            self.setup_bookmark(shared=True),\n            self.setup_bookmark(shared=True),\n            self.setup_bookmark(shared=True),\n        ]\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n\n        self.assertVisibleBookmarks(response, visible_bookmarks, \"_self\")\n\n    def test_edit_link_return_url_respects_search_options(self):\n        self.authenticate()\n        user = self.get_or_create_test_user()\n        user.profile.enable_sharing = True\n        user.profile.save()\n\n        bookmark = self.setup_bookmark(title=\"foo\", shared=True, user=user)\n        edit_url = reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        base_url = reverse(\"linkding:bookmarks.shared\")\n\n        # without query params\n        return_url = urllib.parse.quote(base_url)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url)\n        self.assertEditLink(response, url)\n\n        # with query\n        url_params = \"?q=foo\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n        # with query and user\n        url_params = f\"?q=foo&user={user.username}\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n        # with query and sort and page\n        url_params = \"?q=foo&sort=title_asc&page=2\"\n        return_url = urllib.parse.quote(base_url + url_params)\n        url = f\"{edit_url}?return_url={return_url}\"\n\n        response = self.client.get(base_url + url_params)\n        self.assertEditLink(response, url)\n\n    def test_apply_search_preferences(self):\n        # no params\n        response = self.client.post(reverse(\"linkding:bookmarks.shared\"))\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(response.url, reverse(\"linkding:bookmarks.shared\"))\n\n        # some params\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"q\": \"foo\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.shared\") + \"?q=foo&sort=title_asc\"\n        )\n\n        # params with default value are removed\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"q\": \"foo\",\n                \"user\": \"\",\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.shared\") + \"?q=foo&unread=yes\"\n        )\n\n        # page is removed\n        response = self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"q\": \"foo\",\n                \"page\": \"2\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(\n            response.url, reverse(\"linkding:bookmarks.shared\") + \"?q=foo&sort=title_asc\"\n        )\n\n    def test_save_search_preferences(self):\n        self.authenticate()\n        user_profile = self.user.profile\n\n        # no params\n        self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"save\": \"\",\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # with param\n        self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"save\": \"\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n        # add a param\n        self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"save\": \"\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # remove a param\n        self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"save\": \"\",\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_ADDED_DESC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n            },\n        )\n\n        # ignores non-preferences\n        self.client.post(\n            reverse(\"linkding:bookmarks.shared\"),\n            {\n                \"save\": \"\",\n                \"q\": \"foo\",\n                \"user\": \"john\",\n                \"page\": \"3\",\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            },\n        )\n        user_profile.refresh_from_db()\n        self.assertEqual(\n            user_profile.search_preferences,\n            {\n                \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n                \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n                \"unread\": BookmarkSearch.FILTER_UNREAD_OFF,\n            },\n        )\n\n    def test_url_encode_bookmark_actions_url(self):\n        url = reverse(\"linkding:bookmarks.shared\") + \"?q=%23foo\"\n        response = self.client.get(url)\n        html = response.content.decode()\n        soup = self.make_soup(html)\n        actions_form = soup.select(\"form.bookmark-actions\")[0]\n\n        self.assertEqual(\n            actions_form.attrs[\"action\"],\n            \"/bookmarks/shared/action?q=%23foo\",\n        )\n\n    def test_encode_search_params(self):\n        self.authenticate()\n        user = self.get_or_create_test_user()\n        user.profile.enable_sharing = True\n        user.profile.save()\n        bookmark = self.setup_bookmark(description=\"alert('xss')\", shared=True)\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?q=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n        self.assertContains(response, bookmark.url)\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?sort=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?unread=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?shared=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?user=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n        url = reverse(\"linkding:bookmarks.shared\") + \"?page=alert(%27xss%27)\"\n        response = self.client.get(url)\n        self.assertNotContains(response, \"alert('xss')\")\n\n    def test_turbo_frame_details_modal_renders_details_modal_update(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\"linkding:bookmarks.shared\") + f\"?bookmark_id={bookmark.id}\"\n        response = self.client.get(url, headers={\"Turbo-Frame\": \"details-modal\"})\n\n        self.assertEqual(200, response.status_code)\n\n        soup = self.make_soup(response.content.decode())\n        self.assertIsNotNone(soup.select_one(\"turbo-frame#details-modal\"))\n        self.assertIsNone(soup.select_one(\"#bookmark-list-container\"))\n        self.assertIsNone(soup.select_one(\"#tag-cloud-container\"))\n\n    def test_includes_public_shared_rss_feed(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n        soup = self.make_soup(response.content.decode())\n\n        feed = soup.select_one('head link[type=\"application/rss+xml\"]')\n        self.assertIsNotNone(feed)\n        self.assertEqual(feed.attrs[\"href\"], reverse(\"linkding:feeds.public_shared\"))\n\n    def test_tag_menu_visible_for_authenticated_user(self):\n        self.authenticate()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n        html = response.content.decode()\n\n        soup = self.make_soup(html)\n        tag_menu = soup.find(attrs={\"aria-label\": \"Tags menu\"})\n        self.assertIsNotNone(tag_menu)\n\n    def test_tag_menu_not_visible_for_unauthenticated_user(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n        html = response.content.decode()\n\n        soup = self.make_soup(html)\n        tag_menu = soup.find(attrs={\"aria-label\": \"Tags menu\"})\n        self.assertIsNone(tag_menu)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_shared_view_performance.py",
    "content": "from django.db import connections\nfrom django.db.utils import DEFAULT_DB_ALIAS\nfrom django.test import TransactionTestCase\nfrom django.test.utils import CaptureQueriesContext\nfrom django.urls import reverse\n\nfrom bookmarks.models import GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BookmarkSharedViewPerformanceTestCase(\n    TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin\n):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def get_connection(self):\n        return connections[DEFAULT_DB_ALIAS]\n\n    def test_should_not_increase_number_of_queries_per_bookmark(self):\n        # create global settings\n        GlobalSettings.get()\n\n        # create initial users and bookmarks\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            user = self.setup_user(enable_sharing=True)\n            self.setup_bookmark(user=user, shared=True)\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n            html = response.content.decode(\"utf-8\")\n            soup = self.make_soup(html)\n            list_items = soup.select(\"ul.bookmark-list > li\")\n            self.assertEqual(len(list_items), num_initial_bookmarks)\n\n        number_of_queries = context.final_queries\n\n        # add more users and bookmarks\n        num_additional_bookmarks = 10\n        for _ in range(num_additional_bookmarks):\n            user = self.setup_user(enable_sharing=True)\n            self.setup_bookmark(user=user, shared=True)\n\n        # assert num queries doesn't increase\n        with self.assertNumQueries(number_of_queries):\n            response = self.client.get(reverse(\"linkding:bookmarks.shared\"))\n            html = response.content.decode(\"utf-8\")\n            soup = self.make_soup(html)\n            list_items = soup.select(\"ul.bookmark-list > li\")\n            self.assertEqual(\n                len(list_items), num_initial_bookmarks + num_additional_bookmarks\n            )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmark_validation.py",
    "content": "import datetime\n\nfrom django.core.exceptions import ValidationError\nfrom django.test import TestCase, override_settings\nfrom django.test.client import RequestFactory\n\nfrom bookmarks.forms import BookmarkForm\nfrom bookmarks.models import Bookmark\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\nENABLED_URL_VALIDATION_TEST_CASES = [\n    (\"thisisnotavalidurl\", False),\n    (\"http://domain\", False),\n    (\"unknownscheme://domain.com\", False),\n    (\"http://domain.com\", True),\n    (\"http://www.domain.com\", True),\n    (\"https://domain.com\", True),\n    (\"https://www.domain.com\", True),\n]\n\nDISABLED_URL_VALIDATION_TEST_CASES = [\n    (\"thisisnotavalidurl\", True),\n    (\"http://domain\", True),\n    (\"unknownscheme://domain.com\", True),\n    (\"http://domain.com\", True),\n    (\"http://www.domain.com\", True),\n    (\"https://domain.com\", True),\n    (\"https://www.domain.com\", True),\n]\n\n\nclass BookmarkValidationTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.get_or_create_test_user()\n\n    def test_bookmark_model_should_not_allow_missing_url(self):\n        bookmark = Bookmark(\n            date_added=datetime.datetime.now(),\n            date_modified=datetime.datetime.now(),\n            owner=self.user,\n        )\n\n        with self.assertRaises(ValidationError):\n            bookmark.full_clean()\n\n    def test_bookmark_model_should_not_allow_empty_url(self):\n        bookmark = Bookmark(\n            url=\"\",\n            date_added=datetime.datetime.now(),\n            date_modified=datetime.datetime.now(),\n            owner=self.user,\n        )\n\n        with self.assertRaises(ValidationError):\n            bookmark.full_clean()\n\n    @override_settings(LD_DISABLE_URL_VALIDATION=False)\n    def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):\n        self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)\n\n    @override_settings(LD_DISABLE_URL_VALIDATION=True)\n    def test_bookmark_model_should_not_validate_url_if_disabled_in_settings(self):\n        self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)\n\n    def test_bookmark_form_should_validate_required_fields(self):\n        rf = RequestFactory()\n        request = rf.post(\"/\", data={\"url\": \"\"})\n        form = BookmarkForm(request)\n\n        self.assertEqual(len(form.errors), 1)\n        self.assertIn(\"required\", str(form.errors))\n\n        request = rf.post(\"/\", data={})\n        form = BookmarkForm(request)\n\n        self.assertEqual(len(form.errors), 1)\n        self.assertIn(\"required\", str(form.errors))\n\n    @override_settings(LD_DISABLE_URL_VALIDATION=False)\n    def test_bookmark_form_should_validate_url_if_not_disabled_in_settings(self):\n        self._run_bookmark_form_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)\n\n    @override_settings(LD_DISABLE_URL_VALIDATION=True)\n    def test_bookmark_form_should_not_validate_url_if_disabled_in_settings(self):\n        self._run_bookmark_form_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)\n\n    def _run_bookmark_model_url_validity_checks(self, cases):\n        for case in cases:\n            url, expectation = case\n            bookmark = Bookmark(\n                url=url,\n                date_added=datetime.datetime.now(),\n                date_modified=datetime.datetime.now(),\n                owner=self.user,\n            )\n\n            try:\n                bookmark.full_clean()\n                self.assertTrue(expectation, \"Did not expect validation error\")\n            except ValidationError as e:\n                self.assertFalse(expectation, \"Expected validation error\")\n                self.assertTrue(\n                    \"url\" in e.message_dict, \"Expected URL validation to fail\"\n                )\n\n    def _run_bookmark_form_url_validity_checks(self, cases):\n        for case in cases:\n            url, expectation = case\n            rf = RequestFactory()\n            request = rf.post(\"/\", data={\"url\": url})\n            form = BookmarkForm(request)\n\n            if expectation:\n                self.assertEqual(len(form.errors), 0)\n            else:\n                self.assertEqual(len(form.errors), 1)\n                self.assertIn(\"Enter a valid URL\", str(form.errors))\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_api.py",
    "content": "import datetime\nimport io\nimport urllib.parse\nfrom collections import OrderedDict\nfrom unittest.mock import ANY, patch\n\nfrom django.contrib.auth.models import User\nfrom django.test import override_settings\nfrom django.urls import reverse\nfrom django.utils import timezone\nfrom rest_framework import status\nfrom rest_framework.response import Response\n\nimport bookmarks.services.bookmarks\nfrom bookmarks.models import Bookmark, BookmarkSearch, UserProfile\nfrom bookmarks.services import website_loader\nfrom bookmarks.services.wayback import generate_fallback_webarchive_url\nfrom bookmarks.services.website_loader import WebsiteMetadata\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase\nfrom bookmarks.utils import app_version\n\n\nclass BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.mock_assets_upload_snapshot_patcher = patch(\n            \"bookmarks.services.assets.upload_snapshot\",\n        )\n        self.mock_assets_upload_snapshot = (\n            self.mock_assets_upload_snapshot_patcher.start()\n        )\n\n    def tearDown(self):\n        self.mock_assets_upload_snapshot_patcher.stop()\n\n    def authenticate(self):\n        self.api_token = self.setup_api_token()\n        self.client.credentials(HTTP_AUTHORIZATION=\"Token \" + self.api_token.key)\n\n    def assertBookmarkListEqual(self, data_list, bookmarks):\n        expectations = []\n        for bookmark in bookmarks:\n            tag_names = [tag.name for tag in bookmark.tags.all()]\n            tag_names.sort(key=str.lower)\n            expectation = OrderedDict()\n            expectation[\"id\"] = bookmark.id\n            expectation[\"url\"] = bookmark.url\n            expectation[\"title\"] = bookmark.title\n            expectation[\"description\"] = bookmark.description\n            expectation[\"notes\"] = bookmark.notes\n            expectation[\"web_archive_snapshot_url\"] = (\n                bookmark.web_archive_snapshot_url\n                or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)\n            )\n            expectation[\"favicon_url\"] = (\n                f\"http://testserver/static/{bookmark.favicon_file}\"\n                if bookmark.favicon_file\n                else None\n            )\n            expectation[\"preview_image_url\"] = (\n                f\"http://testserver/static/{bookmark.preview_image_file}\"\n                if bookmark.preview_image_file\n                else None\n            )\n            expectation[\"is_archived\"] = bookmark.is_archived\n            expectation[\"unread\"] = bookmark.unread\n            expectation[\"shared\"] = bookmark.shared\n            expectation[\"tag_names\"] = tag_names\n            expectation[\"date_added\"] = bookmark.date_added.isoformat().replace(\n                \"+00:00\", \"Z\"\n            )\n            expectation[\"date_modified\"] = bookmark.date_modified.isoformat().replace(\n                \"+00:00\", \"Z\"\n            )\n            expectation[\"website_title\"] = None\n            expectation[\"website_description\"] = None\n            expectations.append(expectation)\n\n        for data in data_list:\n            data[\"tag_names\"].sort(key=str.lower)\n\n        self.assertCountEqual(data_list, expectations)\n\n    def test_list_bookmarks(self):\n        self.authenticate()\n        bookmarks = self.setup_numbered_bookmarks(5)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_bookmarks_with_more_details(self):\n        self.authenticate()\n        bookmarks = self.setup_numbered_bookmarks(\n            5,\n            with_tags=True,\n            with_web_archive_snapshot_url=True,\n            with_favicon_file=True,\n            with_preview_image_file=True,\n        )\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_bookmarks_returns_none_for_website_title_and_description(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n        bookmark.website_title = \"Website title\"\n        bookmark.website_description = \"Website description\"\n        bookmark.save()\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n        self.assertIsNone(response.data[\"results\"][0][\"website_title\"])\n        self.assertIsNone(response.data[\"results\"][0][\"website_description\"])\n\n    def test_list_bookmarks_does_not_return_archived_bookmarks(self):\n        self.authenticate()\n        bookmarks = self.setup_numbered_bookmarks(5)\n        self.setup_numbered_bookmarks(5, archived=True)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_bookmarks_should_filter_by_query(self):\n        self.authenticate()\n        search_value = self.get_random_string()\n        bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)\n        self.setup_numbered_bookmarks(5)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + \"?q=\" + search_value,\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_bookmarks_should_filter_by_bundle(self):\n        self.authenticate()\n        search_value = self.get_random_string()\n        bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)\n        self.setup_numbered_bookmarks(5)\n        bundle = self.setup_bundle(search=search_value)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + f\"?bundle={bundle.id}\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_bookmarks_filter_unread(self):\n        self.authenticate()\n        unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)\n        read_bookmarks = self.setup_numbered_bookmarks(5, unread=False)\n\n        # Filter off\n        response = self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n        self.assertBookmarkListEqual(\n            response.data[\"results\"], unread_bookmarks + read_bookmarks\n        )\n\n        # Filter shared\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + \"?unread=yes\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], unread_bookmarks)\n\n        # Filter unshared\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + \"?unread=no\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], read_bookmarks)\n\n    def test_list_bookmarks_filter_shared(self):\n        self.authenticate()\n        unshared_bookmarks = self.setup_numbered_bookmarks(5)\n        shared_bookmarks = self.setup_numbered_bookmarks(5, shared=True)\n\n        # Filter off\n        response = self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n        self.assertBookmarkListEqual(\n            response.data[\"results\"], unshared_bookmarks + shared_bookmarks\n        )\n\n        # Filter shared\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + \"?shared=yes\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], shared_bookmarks)\n\n        # Filter unshared\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + \"?shared=no\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], unshared_bookmarks)\n\n    def test_list_bookmarks_should_respect_sort(self):\n        self.authenticate()\n        bookmarks = self.setup_numbered_bookmarks(5)\n        bookmarks.reverse()\n\n        response = self.get(\n            reverse(\"linkding:bookmark-list\") + \"?sort=title_desc\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):\n        self.authenticate()\n        self.setup_numbered_bookmarks(5)\n        archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-archived\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], archived_bookmarks)\n\n    def test_list_archived_bookmarks_with_more_details(self):\n        self.authenticate()\n        archived_bookmarks = self.setup_numbered_bookmarks(\n            5,\n            archived=True,\n            with_tags=True,\n            with_web_archive_snapshot_url=True,\n            with_favicon_file=True,\n            with_preview_image_file=True,\n        )\n\n        response = self.get(\n            reverse(\"linkding:bookmark-archived\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], archived_bookmarks)\n\n    def test_list_archived_bookmarks_should_filter_by_query(self):\n        self.authenticate()\n        search_value = self.get_random_string()\n        archived_bookmarks = self.setup_numbered_bookmarks(\n            5, archived=True, prefix=search_value\n        )\n        self.setup_numbered_bookmarks(5, archived=True)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-archived\") + \"?q=\" + search_value,\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], archived_bookmarks)\n\n    def test_list_archived_bookmarks_should_filter_by_bundle(self):\n        self.authenticate()\n        search_value = self.get_random_string()\n        archived_bookmarks = self.setup_numbered_bookmarks(\n            5, archived=True, prefix=search_value\n        )\n        self.setup_numbered_bookmarks(5, archived=True)\n        bundle = self.setup_bundle(search=search_value)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-archived\") + f\"?bundle={bundle.id}\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], archived_bookmarks)\n\n    def test_list_archived_bookmarks_should_respect_sort(self):\n        self.authenticate()\n        bookmarks = self.setup_numbered_bookmarks(5, archived=True)\n        bookmarks.reverse()\n\n        response = self.get(\n            reverse(\"linkding:bookmark-archived\") + \"?sort=title_desc\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_list_shared_bookmarks(self):\n        self.authenticate()\n\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        user4 = self.setup_user(enable_sharing=False)\n        shared_bookmarks = [\n            self.setup_bookmark(shared=True, user=user1),\n            self.setup_bookmark(shared=True, user=user2),\n            self.setup_bookmark(shared=True, user=user3),\n        ]\n        # Unshared bookmarks\n        self.setup_bookmark(shared=False, user=user1)\n        self.setup_bookmark(shared=False, user=user2)\n        self.setup_bookmark(shared=False, user=user3)\n        self.setup_bookmark(shared=True, user=user4)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], shared_bookmarks)\n\n    def test_list_shared_bookmarks_with_more_details(self):\n        self.authenticate()\n\n        other_user = self.setup_user(enable_sharing=True)\n        shared_bookmarks = self.setup_numbered_bookmarks(\n            5,\n            shared=True,\n            user=other_user,\n            with_tags=True,\n            with_web_archive_snapshot_url=True,\n            with_favicon_file=True,\n            with_preview_image_file=True,\n        )\n\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], shared_bookmarks)\n\n    def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        shared_bookmarks = [\n            self.setup_bookmark(shared=True, user=user1),\n            self.setup_bookmark(shared=True, user=user1),\n        ]\n        self.setup_bookmark(shared=True, user=user2)\n        self.setup_bookmark(shared=True, user=user2)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], shared_bookmarks)\n\n    def test_list_shared_bookmarks_should_filter_by_query_and_user(self):\n        self.authenticate()\n\n        # Search by query\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        expected_bookmarks = [\n            self.setup_bookmark(title=\"searchvalue\", shared=True, user=user1),\n            self.setup_bookmark(title=\"searchvalue\", shared=True, user=user2),\n            self.setup_bookmark(title=\"searchvalue\", shared=True, user=user3),\n        ]\n        self.setup_bookmark(shared=True, user=user1)\n        self.setup_bookmark(shared=True, user=user2)\n        self.setup_bookmark(shared=True, user=user3)\n\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\") + \"?q=searchvalue\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], expected_bookmarks)\n\n        # Search by user\n        user_search_user = self.setup_user(enable_sharing=True)\n        expected_bookmarks = [\n            self.setup_bookmark(shared=True, user=user_search_user),\n            self.setup_bookmark(shared=True, user=user_search_user),\n            self.setup_bookmark(shared=True, user=user_search_user),\n        ]\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\") + \"?user=\" + user_search_user.username,\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], expected_bookmarks)\n\n        # Search by query and user\n        combined_search_user = self.setup_user(enable_sharing=True)\n        expected_bookmarks = [\n            self.setup_bookmark(\n                title=\"searchvalue\", shared=True, user=combined_search_user\n            ),\n            self.setup_bookmark(\n                title=\"searchvalue\", shared=True, user=combined_search_user\n            ),\n            self.setup_bookmark(\n                title=\"searchvalue\", shared=True, user=combined_search_user\n            ),\n        ]\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\")\n            + \"?q=searchvalue&user=\"\n            + combined_search_user.username,\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], expected_bookmarks)\n\n    def test_list_shared_bookmarks_should_respect_sort(self):\n        self.authenticate()\n        user = self.setup_user(enable_sharing=True)\n        bookmarks = self.setup_numbered_bookmarks(5, shared=True, user=user)\n        bookmarks.reverse()\n\n        response = self.get(\n            reverse(\"linkding:bookmark-shared\") + \"?sort=title_desc\",\n            expected_status_code=status.HTTP_200_OK,\n        )\n        self.assertBookmarkListEqual(response.data[\"results\"], bookmarks)\n\n    def test_create_bookmark(self):\n        self.authenticate()\n\n        data = {\n            \"url\": \"https://example.com/\",\n            \"title\": \"Test title\",\n            \"description\": \"Test description\",\n            \"notes\": \"Test notes\",\n            \"is_archived\": False,\n            \"unread\": False,\n            \"shared\": False,\n            \"tag_names\": [\"tag1\", \"tag2\"],\n        }\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.url, data[\"url\"])\n        self.assertEqual(bookmark.title, data[\"title\"])\n        self.assertEqual(bookmark.description, data[\"description\"])\n        self.assertEqual(bookmark.notes, data[\"notes\"])\n        self.assertFalse(bookmark.is_archived, data[\"is_archived\"])\n        self.assertFalse(bookmark.unread, data[\"unread\"])\n        self.assertFalse(bookmark.shared, data[\"shared\"])\n        self.assertEqual(bookmark.tags.count(), 2)\n        self.assertEqual(bookmark.tags.filter(name=data[\"tag_names\"][0]).count(), 1)\n        self.assertEqual(bookmark.tags.filter(name=data[\"tag_names\"][1]).count(), 1)\n\n    def test_create_bookmark_enhances_with_metadata_by_default(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\"}\n        with patch.object(website_loader, \"load_website_metadata\") as mock_load:\n            mock_load.return_value = WebsiteMetadata(\n                url=\"https://example.com/\",\n                title=\"Website title\",\n                description=\"Website description\",\n                preview_image=None,\n            )\n            self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.title, \"Website title\")\n        self.assertEqual(bookmark.description, \"Website description\")\n\n    def test_create_bookmark_does_not_enhance_with_metadata_if_scraping_is_disabled(\n        self,\n    ):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\"}\n        with patch.object(website_loader, \"load_website_metadata\") as mock_load:\n            mock_load.return_value = WebsiteMetadata(\n                url=\"https://example.com/\",\n                title=\"Website title\",\n                description=\"Website description\",\n                preview_image=None,\n            )\n            self.post(\n                reverse(\"linkding:bookmark-list\") + \"?disable_scraping\",\n                data,\n                status.HTTP_201_CREATED,\n            )\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.title, \"\")\n        self.assertEqual(bookmark.description, \"\")\n\n    def test_create_bookmark_creates_html_snapshot_by_default(self):\n        self.authenticate()\n\n        with patch.object(\n            bookmarks.services.bookmarks,\n            \"create_bookmark\",\n            wraps=bookmarks.services.bookmarks.create_bookmark,\n        ) as mock_create_bookmark:\n            data = {\"url\": \"https://example.com/\"}\n            self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n\n            mock_create_bookmark.assert_called_with(\n                ANY, \"\", self.get_or_create_test_user(), disable_html_snapshot=False\n            )\n\n    def test_create_bookmark_does_not_create_html_snapshot_if_disabled(self):\n        self.authenticate()\n\n        with patch.object(\n            bookmarks.services.bookmarks,\n            \"create_bookmark\",\n            wraps=bookmarks.services.bookmarks.create_bookmark,\n        ) as mock_create_bookmark:\n            data = {\"url\": \"https://example.com/\"}\n            self.post(\n                reverse(\"linkding:bookmark-list\") + \"?disable_html_snapshot\",\n                data,\n                status.HTTP_201_CREATED,\n            )\n\n            mock_create_bookmark.assert_called_with(\n                ANY, \"\", self.get_or_create_test_user(), disable_html_snapshot=True\n            )\n\n    def test_create_bookmark_with_same_url_updates_existing_bookmark(self):\n        self.authenticate()\n\n        original_bookmark = self.setup_bookmark()\n        data = {\n            \"url\": original_bookmark.url,\n            \"title\": \"Updated title\",\n            \"description\": \"Updated description\",\n            \"notes\": \"Updated notes\",\n            \"unread\": True,\n            \"shared\": True,\n            \"is_archived\": True,\n            \"tag_names\": [\"tag1\", \"tag2\"],\n        }\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.id, original_bookmark.id)\n        self.assertEqual(bookmark.url, data[\"url\"])\n        self.assertEqual(bookmark.title, data[\"title\"])\n        self.assertEqual(bookmark.description, data[\"description\"])\n        self.assertEqual(bookmark.notes, data[\"notes\"])\n        # Saving a duplicate bookmark should not modify archive flag - right?\n        self.assertFalse(bookmark.is_archived)\n        self.assertEqual(bookmark.unread, data[\"unread\"])\n        self.assertEqual(bookmark.shared, data[\"shared\"])\n        self.assertEqual(bookmark.tags.count(), 2)\n        self.assertEqual(bookmark.tags.filter(name=data[\"tag_names\"][0]).count(), 1)\n        self.assertEqual(bookmark.tags.filter(name=data[\"tag_names\"][1]).count(), 1)\n\n    def test_create_bookmark_replaces_whitespace_in_tag_names(self):\n        self.authenticate()\n\n        data = {\n            \"url\": \"https://example.com/\",\n            \"title\": \"Test title\",\n            \"description\": \"Test description\",\n            \"tag_names\": [\"tag 1\", \"tag 2\"],\n        }\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        tag_names = [tag.name for tag in bookmark.tags.all()]\n        self.assertListEqual(tag_names, [\"tag-1\", \"tag-2\"])\n\n    def test_create_bookmark_minimal_payload(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\"}\n        self.post(\n            reverse(\"linkding:bookmark-list\") + \"?disable_scraping\",\n            data,\n            status.HTTP_201_CREATED,\n        )\n\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(data[\"url\"], bookmark.url)\n        self.assertEqual(\"\", bookmark.title)\n        self.assertEqual(\"\", bookmark.description)\n        self.assertEqual(\"\", bookmark.notes)\n        self.assertFalse(bookmark.is_archived)\n        self.assertFalse(bookmark.unread)\n        self.assertFalse(bookmark.shared)\n        self.assertBookmarkListEqual([], bookmark.tag_names)\n\n    def test_create_archived_bookmark(self):\n        self.authenticate()\n\n        data = {\n            \"url\": \"https://example.com/\",\n            \"title\": \"Test title\",\n            \"description\": \"Test description\",\n            \"is_archived\": True,\n            \"tag_names\": [\"tag1\", \"tag2\"],\n        }\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.url, data[\"url\"])\n        self.assertEqual(bookmark.title, data[\"title\"])\n        self.assertEqual(bookmark.description, data[\"description\"])\n        self.assertTrue(bookmark.is_archived)\n        self.assertEqual(bookmark.tags.count(), 2)\n        self.assertEqual(bookmark.tags.filter(name=data[\"tag_names\"][0]).count(), 1)\n        self.assertEqual(bookmark.tags.filter(name=data[\"tag_names\"][1]).count(), 1)\n\n    def test_create_bookmark_is_not_archived_by_default(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\"}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertFalse(bookmark.is_archived)\n\n    def test_create_unread_bookmark(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\", \"unread\": True}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertTrue(bookmark.unread)\n\n    def test_create_bookmark_is_not_unread_by_default(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\"}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertFalse(bookmark.unread)\n\n    def test_create_shared_bookmark(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\", \"shared\": True}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertTrue(bookmark.shared)\n\n    def test_create_bookmark_is_not_shared_by_default(self):\n        self.authenticate()\n\n        data = {\"url\": \"https://example.com/\"}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertFalse(bookmark.shared)\n\n    def test_create_bookmark_should_add_tags_from_auto_tagging(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        self.authenticate()\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = f\"example.com {tag2.name}\"\n        profile.save()\n\n        data = {\"url\": \"https://example.com/\", \"tag_names\": [tag1.name]}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])\n\n    def test_create_bookmark_should_set_default_dates(self):\n        self.authenticate()\n\n        with patch(\"bookmarks.services.bookmarks.timezone.now\") as mock_now:\n            fixed_time = timezone.make_aware(datetime.datetime(2024, 1, 15, 12, 0, 0))\n            mock_now.return_value = fixed_time\n\n            data = {\"url\": \"https://example.com/\"}\n            self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n            bookmark = Bookmark.objects.get(url=data[\"url\"])\n            self.assertEqual(bookmark.date_added, fixed_time)\n            self.assertEqual(bookmark.date_modified, fixed_time)\n\n    def test_create_bookmark_with_date_added(self):\n        self.authenticate()\n\n        date1 = timezone.now() - datetime.timedelta(days=30)\n        data = {\"url\": \"https://example.com/\", \"date_added\": date1.isoformat()}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.date_added.isoformat(), date1.isoformat())\n\n    def test_create_bookmark_with_date_modified(self):\n        self.authenticate()\n\n        date1 = timezone.now() - datetime.timedelta(days=15)\n        data = {\"url\": \"https://example.com/\", \"date_modified\": date1.isoformat()}\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n        bookmark = Bookmark.objects.get(url=data[\"url\"])\n        self.assertEqual(bookmark.date_modified.isoformat(), date1.isoformat())\n\n    def test_get_bookmark(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertBookmarkListEqual([response.data], [bookmark])\n\n    def test_get_bookmark_with_more_details(self):\n        self.authenticate()\n        tag1 = self.setup_tag()\n        bookmark = self.setup_bookmark(\n            web_archive_snapshot_url=\"https://web.archive.org/web/1/\",\n            tags=[tag1],\n        )\n\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertBookmarkListEqual([response.data], [bookmark])\n\n    def test_get_bookmark_returns_fallback_webarchive_url(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark(\n            web_archive_snapshot_url=\"\",\n            url=\"https://example.com/\",\n            added=timezone.datetime(2023, 8, 11, 21, 45, 11, tzinfo=datetime.UTC),\n        )\n\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertEqual(\n            response.data[\"web_archive_snapshot_url\"],\n            \"https://web.archive.org/web/20230811214511/https://example.com/\",\n        )\n\n    def test_update_bookmark(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\"url\": \"https://example.com/updated\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertEqual(updated_bookmark.url, data[\"url\"])\n\n    def test_update_bookmark_ignores_readonly_fields(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\n            \"url\": \"https://example.com/updated\",\n            \"web_archive_snapshot_url\": \"test\",\n            \"website_title\": \"test\",\n            \"website_description\": \"test\",\n        }\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertEqual(data[\"url\"], updated_bookmark.url)\n        self.assertNotEqual(\n            data[\"web_archive_snapshot_url\"], updated_bookmark.web_archive_snapshot_url\n        )\n        self.assertNotEqual(data[\"website_title\"], updated_bookmark.website_title)\n        self.assertNotEqual(\n            data[\"website_description\"], updated_bookmark.website_description\n        )\n\n    def test_update_bookmark_fails_without_required_fields(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\"title\": \"https://example.com/\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)\n\n    def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark(\n            is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]\n        )\n\n        data = {\"url\": \"https://example.com/\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertEqual(updated_bookmark.url, data[\"url\"])\n        self.assertEqual(updated_bookmark.title, bookmark.title)\n        self.assertEqual(updated_bookmark.description, bookmark.description)\n        self.assertEqual(updated_bookmark.notes, bookmark.notes)\n        self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)\n        self.assertEqual(updated_bookmark.unread, bookmark.unread)\n        self.assertEqual(updated_bookmark.shared, bookmark.shared)\n        self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)\n\n    def test_update_bookmark_unread_flag(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\"url\": \"https://example.com/\", \"unread\": True}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertEqual(updated_bookmark.unread, True)\n\n    def test_update_bookmark_shared_flag(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\"url\": \"https://example.com/\", \"shared\": True}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertEqual(updated_bookmark.shared, True)\n\n    def test_update_bookmark_adds_tags_from_auto_tagging(self):\n        bookmark = self.setup_bookmark()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        self.authenticate()\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = f\"example.com {tag2.name}\"\n        profile.save()\n\n        data = {\"url\": \"https://example.com/\", \"tag_names\": [tag1.name]}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])\n\n    def test_update_bookmark_should_prevent_duplicate_urls(self):\n        self.authenticate()\n        edited_bookmark = self.setup_bookmark(url=\"https://example.com/edited\")\n        existing_bookmark = self.setup_bookmark(url=\"https://example.com/existing\")\n        other_user_bookmark = self.setup_bookmark(\n            url=\"https://example.com/other\", user=self.setup_user()\n        )\n\n        # if the URL isn't modified it's not a duplicate\n        data = {\"url\": edited_bookmark.url}\n        url = reverse(\"linkding:bookmark-detail\", args=[edited_bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n\n        # if the URL is already bookmarked by another user, it's not a duplicate\n        data = {\"url\": other_user_bookmark.url}\n        url = reverse(\"linkding:bookmark-detail\", args=[edited_bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n\n        # if the URL is already bookmarked by the same user, it's a duplicate\n        data = {\"url\": existing_bookmark.url}\n        url = reverse(\"linkding:bookmark-detail\", args=[edited_bookmark.id])\n        self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)\n\n    def test_patch_bookmark(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\"url\": \"https://example.com\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.url, data[\"url\"])\n\n        data = {\"title\": \"Updated title\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.title, data[\"title\"])\n\n        data = {\"description\": \"Updated description\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.description, data[\"description\"])\n\n        data = {\"notes\": \"Updated notes\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.notes, data[\"notes\"])\n\n        data = {\"unread\": True}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertTrue(bookmark.unread)\n\n        data = {\"unread\": False}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertFalse(bookmark.unread)\n\n        data = {\"shared\": True}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertTrue(bookmark.shared)\n\n        data = {\"shared\": False}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        self.assertFalse(bookmark.shared)\n\n        data = {\"tag_names\": [\"updated-tag-1\", \"updated-tag-2\"]}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        bookmark.refresh_from_db()\n        tag_names = [tag.name for tag in bookmark.tags.all()]\n        self.assertListEqual(tag_names, [\"updated-tag-1\", \"updated-tag-2\"])\n\n    def test_patch_ignores_readonly_fields(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        data = {\n            \"web_archive_snapshot_url\": \"test\",\n            \"website_title\": \"test\",\n            \"website_description\": \"test\",\n        }\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertNotEqual(\n            data[\"web_archive_snapshot_url\"], updated_bookmark.web_archive_snapshot_url\n        )\n        self.assertNotEqual(data[\"website_title\"], updated_bookmark.website_title)\n        self.assertNotEqual(\n            data[\"website_description\"], updated_bookmark.website_description\n        )\n\n    def test_patch_with_empty_payload_does_not_modify_bookmark(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark(\n            is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]\n        )\n\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, {}, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertEqual(updated_bookmark.url, bookmark.url)\n        self.assertEqual(updated_bookmark.title, bookmark.title)\n        self.assertEqual(updated_bookmark.description, bookmark.description)\n        self.assertEqual(updated_bookmark.notes, bookmark.notes)\n        self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)\n        self.assertEqual(updated_bookmark.unread, bookmark.unread)\n        self.assertEqual(updated_bookmark.shared, bookmark.shared)\n        self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)\n\n    def test_patch_bookmark_adds_tags_from_auto_tagging(self):\n        bookmark = self.setup_bookmark()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        self.authenticate()\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = f\"example.com {tag2.name}\"\n        profile.save()\n\n        data = {\"tag_names\": [tag1.name]}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])\n\n    def test_delete_bookmark(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n        self.assertEqual(len(Bookmark.objects.filter(id=bookmark.id)), 0)\n\n    def test_archive(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark()\n\n        url = reverse(\"linkding:bookmark-archive\", args=[bookmark.id])\n        self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n        bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertTrue(bookmark.is_archived)\n\n    def test_unarchive(self):\n        self.authenticate()\n        bookmark = self.setup_bookmark(is_archived=True)\n\n        url = reverse(\"linkding:bookmark-unarchive\", args=[bookmark.id])\n        self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n        bookmark = Bookmark.objects.get(id=bookmark.id)\n        self.assertFalse(bookmark.is_archived)\n\n    def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):\n        self.authenticate()\n\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(\"https://example.com\")\n        response = self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n        )\n        bookmark_data = response.data[\"bookmark\"]\n\n        self.assertIsNone(bookmark_data)\n\n    def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):\n        self.authenticate()\n\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            expected_metadata = WebsiteMetadata(\n                \"https://example.com\",\n                \"Scraped metadata\",\n                \"Scraped description\",\n                \"https://example.com/preview.png\",\n            )\n            mock_load_website_metadata.return_value = expected_metadata\n\n            url = reverse(\"linkding:bookmark-check\")\n            check_url = urllib.parse.quote_plus(\"https://example.com\")\n            response = self.get(\n                f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n            )\n            metadata = response.data[\"metadata\"]\n\n            self.assertIsNotNone(metadata)\n            self.assertEqual(expected_metadata.url, metadata[\"url\"])\n            self.assertEqual(expected_metadata.title, metadata[\"title\"])\n            self.assertEqual(expected_metadata.description, metadata[\"description\"])\n            self.assertEqual(expected_metadata.preview_image, metadata[\"preview_image\"])\n\n    def test_check_returns_bookmark_if_url_is_bookmarked(self):\n        self.authenticate()\n\n        bookmark = self.setup_bookmark(\n            url=\"https://example.com\",\n            title=\"Example title\",\n            description=\"Example description\",\n            favicon_file=\"favicon.png\",\n            preview_image_file=\"preview.png\",\n        )\n\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(\"https://example.com\")\n        response = self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n        )\n        bookmark_data = response.data[\"bookmark\"]\n\n        self.assertIsNotNone(bookmark_data)\n        self.assertEqual(bookmark.id, bookmark_data[\"id\"])\n        self.assertEqual(bookmark.url, bookmark_data[\"url\"])\n        self.assertEqual(bookmark.title, bookmark_data[\"title\"])\n        self.assertEqual(bookmark.description, bookmark_data[\"description\"])\n        self.assertEqual(\n            \"http://testserver/static/favicon.png\", bookmark_data[\"favicon_url\"]\n        )\n        self.assertEqual(\n            \"http://testserver/static/preview.png\", bookmark_data[\"preview_image_url\"]\n        )\n\n    def test_check_returns_scraped_metadata_if_url_is_bookmarked(self):\n        self.authenticate()\n\n        self.setup_bookmark(\n            url=\"https://example.com\",\n        )\n\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            expected_metadata = WebsiteMetadata(\n                \"https://example.com\",\n                \"Scraped metadata\",\n                \"Scraped description\",\n                \"https://example.com/preview.png\",\n            )\n            mock_load_website_metadata.return_value = expected_metadata\n\n            url = reverse(\"linkding:bookmark-check\")\n            check_url = urllib.parse.quote_plus(\"https://example.com\")\n            response = self.get(\n                f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n            )\n            metadata = response.data[\"metadata\"]\n\n            self.assertIsNotNone(metadata)\n            self.assertEqual(expected_metadata.url, metadata[\"url\"])\n            self.assertEqual(expected_metadata.title, metadata[\"title\"])\n            self.assertEqual(expected_metadata.description, metadata[\"description\"])\n            self.assertEqual(expected_metadata.preview_image, metadata[\"preview_image\"])\n\n    def test_check_returns_bookmark_using_normalized_url(self):\n        self.authenticate()\n\n        # Create bookmark with one URL variant\n        bookmark = self.setup_bookmark(\n            url=\"https://EXAMPLE.COM/path/?z=1&a=2\",\n            title=\"Example title\",\n            description=\"Example description\",\n        )\n\n        # Check with different URL variant that should normalize to the same URL\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(\"https://example.com/path?a=2&z=1\")\n        response = self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n        )\n        bookmark_data = response.data[\"bookmark\"]\n\n        # Should find the existing bookmark despite URL differences\n        self.assertIsNotNone(bookmark_data)\n        self.assertEqual(bookmark.id, bookmark_data[\"id\"])\n        self.assertEqual(bookmark.title, bookmark_data[\"title\"])\n\n    def test_check_returns_no_auto_tags_if_none_configured(self):\n        self.authenticate()\n\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(\"https://example.com\")\n        response = self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n        )\n        auto_tags = response.data[\"auto_tags\"]\n\n        self.assertCountEqual(auto_tags, [])\n\n    def test_check_returns_matching_auto_tags(self):\n        self.authenticate()\n\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = \"example.com tag1 tag2\"\n        profile.save()\n\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(\"https://example.com\")\n        response = self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n        )\n        auto_tags = response.data[\"auto_tags\"]\n\n        self.assertCountEqual(auto_tags, [\"tag1\", \"tag2\"])\n\n    def test_check_ignore_cache(self):\n        self.authenticate()\n\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            expected_metadata = WebsiteMetadata(\n                \"https://example.com\",\n                \"Scraped metadata\",\n                \"Scraped description\",\n                \"https://example.com/preview.png\",\n            )\n            mock_load_website_metadata.return_value = expected_metadata\n\n            # Does not ignore cache by default\n            url = reverse(\"linkding:bookmark-check\")\n            check_url = urllib.parse.quote_plus(\"https://example.com\")\n            self.get(\n                f\"{url}?url={check_url}\",\n                expected_status_code=status.HTTP_200_OK,\n            )\n\n            mock_load_website_metadata.assert_called_once_with(\n                \"https://example.com\", ignore_cache=False\n            )\n            mock_load_website_metadata.reset_mock()\n\n            # Ignores cache based on query param\n            self.get(\n                f\"{url}?url={check_url}&ignore_cache=true\",\n                expected_status_code=status.HTTP_200_OK,\n            )\n\n            mock_load_website_metadata.assert_called_once_with(\n                \"https://example.com\", ignore_cache=True\n            )\n\n    def test_can_only_access_own_bookmarks(self):\n        self.authenticate()\n        self.setup_bookmark()\n        self.setup_bookmark(is_archived=True)\n\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        inaccessible_bookmark = self.setup_bookmark(user=other_user)\n        inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)\n        self.setup_bookmark(user=other_user, is_archived=True)\n\n        url = reverse(\"linkding:bookmark-list\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n\n        url = reverse(\"linkding:bookmark-archived\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n        self.assertEqual(len(response.data[\"results\"]), 1)\n\n        url = reverse(\"linkding:bookmark-detail\", args=[inaccessible_bookmark.id])\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\n            \"linkding:bookmark-detail\", args=[inaccessible_shared_bookmark.id]\n        )\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\"linkding:bookmark-detail\", args=[inaccessible_bookmark.id])\n        self.put(\n            url,\n            {url: \"https://example.com/\"},\n            expected_status_code=status.HTTP_404_NOT_FOUND,\n        )\n        self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\n            \"linkding:bookmark-detail\", args=[inaccessible_shared_bookmark.id]\n        )\n        self.put(\n            url,\n            {url: \"https://example.com/\"},\n            expected_status_code=status.HTTP_404_NOT_FOUND,\n        )\n        self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\"linkding:bookmark-detail\", args=[inaccessible_bookmark.id])\n        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\n            \"linkding:bookmark-detail\", args=[inaccessible_shared_bookmark.id]\n        )\n        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\"linkding:bookmark-archive\", args=[inaccessible_bookmark.id])\n        self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\n            \"linkding:bookmark-archive\", args=[inaccessible_shared_bookmark.id]\n        )\n        self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\"linkding:bookmark-unarchive\", args=[inaccessible_bookmark.id])\n        self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\n            \"linkding:bookmark-unarchive\", args=[inaccessible_shared_bookmark.id]\n        )\n        self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)\n        response = self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK\n        )\n        self.assertIsNone(response.data[\"bookmark\"])\n\n    def assertUserProfile(self, response: Response, profile: UserProfile):\n        self.assertEqual(response.data[\"theme\"], profile.theme)\n        self.assertEqual(\n            response.data[\"bookmark_date_display\"], profile.bookmark_date_display\n        )\n        self.assertEqual(\n            response.data[\"bookmark_link_target\"], profile.bookmark_link_target\n        )\n        self.assertEqual(\n            response.data[\"web_archive_integration\"], profile.web_archive_integration\n        )\n        self.assertEqual(response.data[\"tag_search\"], profile.tag_search)\n        self.assertEqual(response.data[\"enable_sharing\"], profile.enable_sharing)\n        self.assertEqual(\n            response.data[\"enable_public_sharing\"], profile.enable_public_sharing\n        )\n        self.assertEqual(response.data[\"enable_favicons\"], profile.enable_favicons)\n        self.assertEqual(response.data[\"display_url\"], profile.display_url)\n        self.assertEqual(response.data[\"permanent_notes\"], profile.permanent_notes)\n        self.assertEqual(\n            response.data[\"search_preferences\"], profile.search_preferences\n        )\n        self.assertEqual(response.data[\"version\"], app_version)\n\n    def test_user_profile(self):\n        self.authenticate()\n\n        # default profile\n        profile = self.user.profile\n        url = reverse(\"linkding:user-profile\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertUserProfile(response, profile)\n\n        # update profile\n        profile.theme = \"dark\"\n        profile.bookmark_date_display = \"absolute\"\n        profile.bookmark_link_target = \"_self\"\n        profile.web_archive_integration = \"enabled\"\n        profile.tag_search = \"lax\"\n        profile.enable_sharing = True\n        profile.enable_public_sharing = True\n        profile.enable_favicons = True\n        profile.display_url = True\n        profile.permanent_notes = True\n        profile.search_preferences = {\n            \"sort\": BookmarkSearch.SORT_TITLE_ASC,\n            \"shared\": BookmarkSearch.FILTER_SHARED_OFF,\n            \"unread\": BookmarkSearch.FILTER_UNREAD_YES,\n        }\n        profile.save()\n\n        url = reverse(\"linkding:user-profile\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertUserProfile(response, profile)\n\n    def create_singlefile_upload_body(self):\n        url = \"https://example.com\"\n        file_content = b\"dummy content\"\n        file = io.BytesIO(file_content)\n        file.name = \"snapshot.html\"\n\n        return {\"url\": url, \"file\": file}\n\n    def test_singlefile_upload(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n\n        self.authenticate()\n        response = self.client.post(\n            reverse(\"linkding:bookmark-singlefile\"),\n            self.create_singlefile_upload_body(),\n            format=\"multipart\",\n            expected_status_code=status.HTTP_201_CREATED,\n        )\n\n        self.assertEqual(response.data[\"message\"], \"Snapshot uploaded successfully.\")\n\n        self.mock_assets_upload_snapshot.assert_called_once()\n        self.mock_assets_upload_snapshot.assert_called_with(bookmark, b\"dummy content\")\n\n    def test_singlefile_creates_bookmark_if_not_exists(self):\n        other_user = self.setup_user()\n        self.setup_bookmark(url=\"https://example.com\", user=other_user)\n\n        self.authenticate()\n        self.client.post(\n            reverse(\"linkding:bookmark-singlefile\"),\n            self.create_singlefile_upload_body(),\n            format=\"multipart\",\n            expected_status_code=status.HTTP_201_CREATED,\n        )\n\n        self.assertEqual(Bookmark.objects.count(), 2)\n\n        bookmark = Bookmark.objects.get(\n            url=\"https://example.com\", owner=self.get_or_create_test_user()\n        )\n        self.mock_assets_upload_snapshot.assert_called_once()\n        self.mock_assets_upload_snapshot.assert_called_with(bookmark, b\"dummy content\")\n\n    def test_singlefile_updates_own_bookmark_if_exists(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        other_user = self.setup_user()\n        self.setup_bookmark(url=\"https://example.com\", user=other_user)\n\n        self.authenticate()\n        self.client.post(\n            reverse(\"linkding:bookmark-singlefile\"),\n            self.create_singlefile_upload_body(),\n            format=\"multipart\",\n            expected_status_code=status.HTTP_201_CREATED,\n        )\n\n        self.assertEqual(Bookmark.objects.count(), 2)\n        self.mock_assets_upload_snapshot.assert_called_once()\n        self.mock_assets_upload_snapshot.assert_called_with(bookmark, b\"dummy content\")\n\n    def test_singlefile_creates_bookmark_without_creating_snapshot(self):\n        with patch(\n            \"bookmarks.services.bookmarks.create_bookmark\"\n        ) as mock_create_bookmark:\n            self.authenticate()\n            self.client.post(\n                reverse(\"linkding:bookmark-singlefile\"),\n                self.create_singlefile_upload_body(),\n                format=\"multipart\",\n                expected_status_code=status.HTTP_201_CREATED,\n            )\n\n            mock_create_bookmark.assert_called_once()\n            mock_create_bookmark.assert_called_with(\n                ANY, \"\", self.get_or_create_test_user(), disable_html_snapshot=True\n            )\n\n    def test_singlefile_upload_missing_parameters(self):\n        self.authenticate()\n\n        # Missing 'url'\n        file_content = b\"dummy content\"\n        file = io.BytesIO(file_content)\n        file.name = \"snapshot.html\"\n        response = self.client.post(\n            reverse(\"linkding:bookmark-singlefile\"),\n            {\"file\": file},\n            format=\"multipart\",\n            expected_status_code=status.HTTP_400_BAD_REQUEST,\n        )\n        self.assertEqual(\n            response.data[\"error\"], \"Both 'url' and 'file' parameters are required.\"\n        )\n\n        # Missing 'file'\n        response = self.client.post(\n            reverse(\"linkding:bookmark-singlefile\"),\n            {\"url\": \"https://example.com\"},\n            format=\"multipart\",\n            expected_status_code=status.HTTP_400_BAD_REQUEST,\n        )\n        self.assertEqual(\n            response.data[\"error\"], \"Both 'url' and 'file' parameters are required.\"\n        )\n\n    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)\n    def test_singlefile_upload_disabled(self):\n        self.authenticate()\n        self.client.post(\n            reverse(\"linkding:bookmark-singlefile\"),\n            self.create_singlefile_upload_body(),\n            format=\"multipart\",\n            expected_status_code=status.HTTP_403_FORBIDDEN,\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_api_performance.py",
    "content": "from django.db import connections\nfrom django.db.utils import DEFAULT_DB_ALIAS\nfrom django.test.utils import CaptureQueriesContext\nfrom django.urls import reverse\nfrom rest_framework import status\n\nfrom bookmarks.models import GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase\n\n\nclass BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.api_token = self.setup_api_token()\n        self.client.credentials(HTTP_AUTHORIZATION=\"Token \" + self.api_token.key)\n\n        # create global settings\n        GlobalSettings.get()\n\n    def get_connection(self):\n        return connections[DEFAULT_DB_ALIAS]\n\n    def test_list_bookmarks_max_queries(self):\n        # set up some bookmarks with associated tags\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            self.setup_bookmark(tags=[self.setup_tag()])\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            self.get(\n                reverse(\"linkding:bookmark-list\"),\n                expected_status_code=status.HTTP_200_OK,\n            )\n\n        number_of_queries = context.final_queries\n\n        self.assertLess(number_of_queries, num_initial_bookmarks)\n\n    def test_list_archived_bookmarks_max_queries(self):\n        # set up some bookmarks with associated tags\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            self.setup_bookmark(is_archived=True, tags=[self.setup_tag()])\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            self.get(\n                reverse(\"linkding:bookmark-archived\"),\n                expected_status_code=status.HTTP_200_OK,\n            )\n\n        number_of_queries = context.final_queries\n\n        self.assertLess(number_of_queries, num_initial_bookmarks)\n\n    def test_list_shared_bookmarks_max_queries(self):\n        # set up some bookmarks with associated tags\n        share_user = self.setup_user(enable_sharing=True)\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            self.setup_bookmark(user=share_user, shared=True, tags=[self.setup_tag()])\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            self.get(\n                reverse(\"linkding:bookmark-shared\"),\n                expected_status_code=status.HTTP_200_OK,\n            )\n\n        number_of_queries = context.final_queries\n\n        self.assertLess(number_of_queries, num_initial_bookmarks)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_api_permissions.py",
    "content": "import urllib.parse\n\nfrom django.urls import reverse\nfrom rest_framework import status\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase\n\n\nclass BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):\n    def authenticate(self) -> None:\n        self.api_token = self.setup_api_token()\n        self.client.credentials(HTTP_AUTHORIZATION=\"Token \" + self.api_token.key)\n\n    def test_list_bookmarks_requires_authentication(self):\n        self.get(\n            reverse(\"linkding:bookmark-list\"),\n            expected_status_code=status.HTTP_401_UNAUTHORIZED,\n        )\n\n        self.authenticate()\n        self.get(\n            reverse(\"linkding:bookmark-list\"), expected_status_code=status.HTTP_200_OK\n        )\n\n    def test_list_archived_bookmarks_requires_authentication(self):\n        self.get(\n            reverse(\"linkding:bookmark-archived\"),\n            expected_status_code=status.HTTP_401_UNAUTHORIZED,\n        )\n\n        self.authenticate()\n        self.get(\n            reverse(\"linkding:bookmark-archived\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n\n    def test_list_shared_bookmarks_does_not_require_authentication(self):\n        self.get(\n            reverse(\"linkding:bookmark-shared\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n\n        self.authenticate()\n        self.get(\n            reverse(\"linkding:bookmark-shared\"),\n            expected_status_code=status.HTTP_200_OK,\n        )\n\n    def test_create_bookmark_requires_authentication(self):\n        data = {\n            \"url\": \"https://example.com/\",\n            \"title\": \"Test title\",\n            \"description\": \"Test description\",\n            \"notes\": \"Test notes\",\n            \"is_archived\": False,\n            \"unread\": False,\n            \"shared\": False,\n            \"tag_names\": [\"tag1\", \"tag2\"],\n        }\n\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.post(reverse(\"linkding:bookmark-list\"), data, status.HTTP_201_CREATED)\n\n    def test_get_bookmark_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.get(url, expected_status_code=status.HTTP_200_OK)\n\n    def test_update_bookmark_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        data = {\"url\": \"https://example.com/\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n\n        self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.put(url, data, expected_status_code=status.HTTP_200_OK)\n\n    def test_update_bookmark_only_updates_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        data = {\"url\": \"https://example.com/\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n\n        self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_patch_bookmark_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        data = {\"url\": \"https://example.com\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n\n        self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.patch(url, data, expected_status_code=status.HTTP_200_OK)\n\n    def test_patch_bookmark_only_updates_own_bookmarks(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        bookmark = self.setup_bookmark(user=other_user)\n        data = {\"url\": \"https://example.com\"}\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n\n        self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_delete_bookmark_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\"linkding:bookmark-detail\", args=[bookmark.id])\n\n        self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n\n    def test_archive_requires_authentication(self):\n        bookmark = self.setup_bookmark()\n        url = reverse(\"linkding:bookmark-archive\", args=[bookmark.id])\n\n        self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n\n    def test_unarchive_requires_authentication(self):\n        bookmark = self.setup_bookmark(is_archived=True)\n        url = reverse(\"linkding:bookmark-unarchive\", args=[bookmark.id])\n\n        self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n\n    def test_check_requires_authentication(self):\n        url = reverse(\"linkding:bookmark-check\")\n        check_url = urllib.parse.quote_plus(\"https://example.com\")\n\n        self.get(\n            f\"{url}?url={check_url}\", expected_status_code=status.HTTP_401_UNAUTHORIZED\n        )\n\n        self.authenticate()\n        self.get(f\"{url}?url={check_url}\", expected_status_code=status.HTTP_200_OK)\n\n    def test_user_profile_requires_authentication(self):\n        url = reverse(\"linkding:user-profile\")\n\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.authenticate()\n        self.get(url, expected_status_code=status.HTTP_200_OK)\n\n    def test_singlefile_upload_requires_authentication(self):\n        url = reverse(\"linkding:bookmark-singlefile\")\n\n        self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_list_template.py",
    "content": "import datetime\n\nfrom django.contrib.auth.models import AnonymousUser\nfrom django.http import HttpResponse\nfrom django.template import RequestContext, Template\nfrom django.test import RequestFactory, TestCase\nfrom django.urls import reverse\nfrom django.utils import formats, timezone\n\nfrom bookmarks.middlewares import LinkdingMiddleware\nfrom bookmarks.models import Bookmark, BookmarkSearch, User, UserProfile\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\nfrom bookmarks.utils import app_version\nfrom bookmarks.views import contexts\n\n\nclass BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def assertBookmarksLink(\n        self, html: str, bookmark: Bookmark, link_target: str = \"_blank\"\n    ):\n        favicon_img = (\n            f'<img class=\"favicon\" src=\"/static/{bookmark.favicon_file}\" alt=\"\">'\n            if bookmark.favicon_file\n            else \"\"\n        )\n        self.assertInHTML(\n            f\"\"\"\n            {favicon_img}\n            <a href=\"{bookmark.url}\" \n                target=\"{link_target}\" \n                rel=\"noopener\">\n                <span>{bookmark.resolved_title}</span>\n            </a>\n            \"\"\",\n            html,\n        )\n\n    def assertWebArchiveLink(\n        self, html: str, label_content: str, url: str, link_target: str = \"_blank\"\n    ):\n        self.assertInHTML(\n            f\"\"\"\n        <a href=\"{url}\"\n           title=\"View snapshot on the Internet Archive Wayback Machine\" target=\"{link_target}\" rel=\"noopener\">\n            {label_content}\n        </a>\n        \"\"\",\n            html,\n        )\n\n    def assertViewLink(self, html: str, bookmark: Bookmark, base_url=None):\n        self.assertViewLinkCount(html, bookmark, base_url)\n\n    def assertNoViewLink(self, html: str, bookmark: Bookmark, base_url=None):\n        self.assertViewLinkCount(html, bookmark, base_url, count=0)\n\n    def assertViewLinkCount(\n        self,\n        html: str,\n        bookmark: Bookmark,\n        base_url: str = None,\n        count=1,\n    ):\n        if base_url is None:\n            base_url = reverse(\"linkding:bookmarks.index\")\n        details_url = base_url + f\"?details={bookmark.id}\"\n        self.assertInHTML(\n            f\"\"\"\n                <a href=\"{details_url}\" class=\"view-action\" data-turbo-action=\"replace\" data-turbo-frame=\"details-modal\">View</a>\n            \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):\n        edit_url = reverse(\"linkding:bookmarks.edit\", args=[bookmark.id])\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{edit_url}?return_url=/bookmarks\">Edit</a>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertArchiveLinkCount(self, html: str, bookmark: Bookmark, count=1):\n        self.assertInHTML(\n            f\"\"\"\n            <button type=\"submit\" name=\"archive\" value=\"{bookmark.id}\"\n               class=\"btn btn-link btn-sm\">Archive</button>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):\n        self.assertInHTML(\n            f\"\"\"\n            <button data-confirm type=\"submit\" name=\"remove\" value=\"{bookmark.id}\"\n               class=\"btn btn-link btn-sm\">Remove</button>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertBookmarkActions(self, html: str, bookmark: Bookmark):\n        self.assertBookmarkActionsCount(html, bookmark, count=1)\n\n    def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):\n        self.assertBookmarkActionsCount(html, bookmark, count=0)\n\n    def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):\n        self.assertEditLinkCount(html, bookmark, count=count)\n        self.assertArchiveLinkCount(html, bookmark, count=count)\n        self.assertDeleteLinkCount(html, bookmark, count=count)\n\n    def assertShareInfo(self, html: str, bookmark: Bookmark):\n        self.assertShareInfoCount(html, bookmark, 1)\n\n    def assertNoShareInfo(self, html: str, bookmark: Bookmark):\n        self.assertShareInfoCount(html, bookmark, 0)\n\n    def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):\n        # Shared by link\n        self.assertInHTML(\n            f\"\"\"\n            <span>Shared by \n                <a href=\"?user={bookmark.owner.username}\">{bookmark.owner.username}</a>\n            </span>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertFaviconVisible(self, html: str, bookmark: Bookmark):\n        self.assertFavicon(html, bookmark, True)\n\n    def assertFaviconHidden(self, html: str, bookmark: Bookmark):\n        self.assertFavicon(html, bookmark, False)\n\n    def assertFavicon(self, html: str, bookmark: Bookmark, visible=True):\n        soup = self.make_soup(html)\n\n        favicon = soup.select_one(\".favicon\")\n\n        if not visible:\n            self.assertIsNone(favicon)\n            return\n\n        url = f\"/static/{bookmark.favicon_file}\"\n        self.assertIsNotNone(favicon)\n        self.assertEqual(favicon[\"src\"], url)\n\n    def assertPreviewImageVisible(self, html: str, bookmark: Bookmark):\n        self.assertPreviewImage(html, bookmark, True)\n\n    def assertPreviewImageHidden(self, html: str, bookmark: Bookmark):\n        self.assertPreviewImage(html, bookmark, False)\n\n    def assertPreviewImage(self, html: str, bookmark: Bookmark, visible=True):\n        soup = self.make_soup(html)\n        preview_image = soup.select_one(\".preview-image\")\n\n        if not visible:\n            self.assertIsNone(preview_image)\n            return\n\n        url = f\"/static/{bookmark.preview_image_file}\"\n        self.assertIsNotNone(preview_image)\n        self.assertEqual(preview_image[\"src\"], url)\n\n    def assertPreviewImagePlaceholder(self, html: str):\n        soup = self.make_soup(html)\n        placeholder = soup.select_one(\".preview-image.placeholder\")\n        self.assertIsNotNone(placeholder)\n\n    def assertBookmarkURLCount(\n        self, html: str, bookmark: Bookmark, link_target: str = \"_blank\", count=0\n    ):\n        self.assertInHTML(\n            f\"\"\"\n        <div class=\"url-path truncate\">\n          <a href=\"{bookmark.url}\" target=\"{link_target}\" rel=\"noopener\" \n          class=\"url-display\">\n            {bookmark.url}\n          </a>\n        </div>\n        \"\"\",\n            html,\n            count,\n        )\n\n    def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):\n        self.assertBookmarkURLCount(html, bookmark, count=1)\n\n    def assertBookmarkURLHidden(\n        self, html: str, bookmark: Bookmark, link_target: str = \"_blank\"\n    ):\n        self.assertBookmarkURLCount(html, bookmark, count=0)\n\n    def assertNotes(self, html: str, notes_html: str, count=1):\n        self.assertInHTML(\n            f\"\"\"\n        <div class=\"notes\">\n          <div class=\"markdown\">\n            {notes_html}\n          </div>\n        </div>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertNotesToggle(self, html: str, count=1):\n        self.assertInHTML(\n            f\"\"\"\n        <button type=\"button\" class=\"btn btn-link btn-sm btn-icon toggle-notes\">\n          <svg width=\"16\" height=\"16\">\n            <use href=\"/static/icons.svg?v={app_version}#note\"></use>\n          </svg>\n          Notes\n        </button>      \n          \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertUnshareButton(self, html: str, bookmark: Bookmark, count=1):\n        self.assertInHTML(\n            f\"\"\"\n        <button type=\"submit\" name=\"unshare\" value=\"{bookmark.id}\"\n                class=\"btn btn-link btn-sm btn-icon\"\n                data-confirm data-confirm-question=\"Unshare?\">\n          <svg width=\"16\" height=\"16\">\n            <use href=\"/static/icons.svg?v={app_version}#share\"></use>\n          </svg>\n          Shared\n        </button>    \n          \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertMarkAsReadButton(self, html: str, bookmark: Bookmark, count=1):\n        self.assertInHTML(\n            f\"\"\"\n        <button type=\"submit\" name=\"mark_as_read\" value=\"{bookmark.id}\"\n                class=\"btn btn-link btn-sm btn-icon\"\n                data-confirm data-confirm-question=\"Mark as read?\">\n          <svg width=\"16\" height=\"16\">\n            <use href=\"/static/icons.svg?v={app_version}#unread\"></use>\n          </svg>\n          Unread\n        </button>   \n          \"\"\",\n            html,\n            count=count,\n        )\n\n    def render_template(\n        self,\n        url=\"/bookmarks\",\n        context_type: type[\n            contexts.BookmarkListContext\n        ] = contexts.ActiveBookmarkListContext,\n        user: User | AnonymousUser = None,\n        is_preview: bool = False,\n    ) -> str:\n        rf = RequestFactory()\n        request = rf.get(url)\n        request.user = user or self.get_or_create_test_user()\n        middleware = LinkdingMiddleware(lambda r: HttpResponse())\n        middleware(request)\n\n        search = BookmarkSearch.from_request(request, request.GET)\n        bookmark_list_context = context_type(request, search)\n        if is_preview:\n            bookmark_list_context.is_preview = True\n        context = RequestContext(request, {\"bookmark_list\": bookmark_list_context})\n\n        template = Template(\"{% include 'bookmarks/bookmark_list.html' %}\")\n        return template.render(context)\n\n    def setup_date_format_test(\n        self, date_display_setting: str, web_archive_url: str = \"\"\n    ):\n        bookmark = self.setup_bookmark()\n        bookmark.date_added = timezone.now() - datetime.timedelta(days=8)\n        bookmark.web_archive_snapshot_url = web_archive_url\n        bookmark.save()\n        user = self.get_or_create_test_user()\n        user.profile.bookmark_date_display = date_display_setting\n        user.profile.save()\n        return bookmark\n\n    def inline_bookmark_description_test(self, bookmark):\n        html = self.render_template()\n        soup = self.make_soup(html)\n\n        has_description = bool(bookmark.description)\n        has_tags = len(bookmark.tags.all()) > 0\n\n        # inline description block exists\n        description = soup.select_one(\".description.inline.truncate\")\n        self.assertIsNotNone(description)\n\n        # separate description block does not exist\n        separate_description = soup.select_one(\".description.separate\")\n        self.assertIsNone(separate_description)\n\n        # one direct child element per description or tags\n        children = description.find_all(recursive=False)\n        expected_child_count = (\n            0 + (1 if has_description else 0) + (1 if has_tags else 0)\n        )\n        self.assertEqual(len(children), expected_child_count)\n\n        # has separator between description and tags\n        if has_description and has_tags:\n            self.assertTrue(\"|\" in description.text)\n\n        # contains description text, without leading/trailing whitespace\n        if has_description:\n            description_text = description.find(\"span\", string=bookmark.description)\n            self.assertIsNotNone(description_text)\n\n        if not has_tags:\n            # no tags element\n            tags = soup.select_one(\".tags\")\n            self.assertIsNone(tags)\n        else:\n            # tags element exists\n            tags = soup.select_one(\".tags\")\n            self.assertIsNotNone(tags)\n\n            # one link for each tag\n            tag_links = tags.find_all(\"a\")\n            self.assertEqual(len(tag_links), len(bookmark.tags.all()))\n\n            for tag in bookmark.tags.all():\n                tag_link = tags.find(\"a\", string=f\"#{tag.name}\")\n                self.assertIsNotNone(tag_link)\n                self.assertEqual(tag_link[\"href\"], f\"?q=%23{tag.name}\")\n\n    def test_inline_bookmark_description(self):\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_description_display = (\n            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE\n        )\n        profile.save()\n\n        # no description, no tags\n        bookmark = self.setup_bookmark(description=\"\")\n        self.inline_bookmark_description_test(bookmark)\n\n        # with description, no tags\n        bookmark = self.setup_bookmark(description=\"Test description\")\n        self.inline_bookmark_description_test(bookmark)\n\n        # no description, with tags\n        Bookmark.objects.all().delete()\n        bookmark = self.setup_bookmark(\n            description=\"\", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]\n        )\n        self.inline_bookmark_description_test(bookmark)\n\n        # with description, with tags\n        Bookmark.objects.all().delete()\n        bookmark = self.setup_bookmark(\n            description=\"Test description\",\n            tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],\n        )\n        self.inline_bookmark_description_test(bookmark)\n\n    def separate_bookmark_description_test(self, bookmark):\n        html = self.render_template()\n        soup = self.make_soup(html)\n\n        has_description = bool(bookmark.description)\n        has_tags = len(bookmark.tags.all()) > 0\n\n        # inline description block does not exist\n        inline_description = soup.select_one(\".description.inline\")\n        self.assertIsNone(inline_description)\n\n        if not has_description:\n            # no description element\n            description = soup.select_one(\".description\")\n            self.assertIsNone(description)\n        else:\n            # contains description text, without leading/trailing whitespace\n            description = soup.select_one(\".description.separate\")\n            self.assertIsNotNone(description)\n            self.assertEqual(description.text, bookmark.description)\n\n        if not has_tags:\n            # no tags element\n            tags = soup.select_one(\".tags\")\n            self.assertIsNone(tags)\n        else:\n            # tags element exists\n            tags = soup.select_one(\".tags\")\n            self.assertIsNotNone(tags)\n\n            # one link for each tag\n            tag_links = tags.find_all(\"a\")\n            self.assertEqual(len(tag_links), len(bookmark.tags.all()))\n\n            for tag in bookmark.tags.all():\n                tag_link = tags.find(\"a\", string=f\"#{tag.name}\")\n                self.assertIsNotNone(tag_link)\n                self.assertEqual(tag_link[\"href\"], f\"?q=%23{tag.name}\")\n\n    def test_separate_bookmark_description(self):\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_description_display = (\n            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE\n        )\n        profile.save()\n\n        # no description, no tags\n        bookmark = self.setup_bookmark(description=\"\")\n        self.separate_bookmark_description_test(bookmark)\n\n        # with description, no tags\n        bookmark = self.setup_bookmark(description=\"Test description\")\n        self.separate_bookmark_description_test(bookmark)\n\n        # no description, with tags\n        Bookmark.objects.all().delete()\n        bookmark = self.setup_bookmark(\n            description=\"\", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]\n        )\n        self.separate_bookmark_description_test(bookmark)\n\n        # with description, with tags\n        Bookmark.objects.all().delete()\n        bookmark = self.setup_bookmark(\n            description=\"Test description\",\n            tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],\n        )\n        self.separate_bookmark_description_test(bookmark)\n\n    def test_bookmark_description_max_lines(self):\n        self.setup_bookmark()\n        html = self.render_template()\n        soup = self.make_soup(html)\n        bookmark_list = soup.select_one(\"ul.bookmark-list\")\n        style = bookmark_list[\"style\"]\n        self.assertIn(\"--ld-bookmark-description-max-lines:1\", style)\n\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_description_max_lines = 3\n        profile.save()\n\n        html = self.render_template()\n        soup = self.make_soup(html)\n        bookmark_list = soup.select_one(\"ul.bookmark-list\")\n        style = bookmark_list[\"style\"]\n        self.assertIn(\"--ld-bookmark-description-max-lines:3\", style)\n\n    def test_bookmark_tag_ordering(self):\n        bookmark = self.setup_bookmark()\n        tag3 = self.setup_tag(name=\"tag3\")\n        tag1 = self.setup_tag(name=\"tag1\")\n        tag2 = self.setup_tag(name=\"tag2\")\n        bookmark.tags.add(tag3, tag1, tag2)\n\n        html = self.render_template()\n        soup = self.make_soup(html)\n        tags = soup.select_one(\".tags\")\n        tag_links = tags.find_all(\"a\")\n        self.assertEqual(len(tag_links), 3)\n        self.assertEqual(tag_links[0].text, \"#tag1\")\n        self.assertEqual(tag_links[1].text, \"#tag2\")\n        self.assertEqual(tag_links[2].text, \"#tag3\")\n\n    def test_bookmark_tag_query_string(self):\n        # appends tag to existing query string\n        bookmark = self.setup_bookmark(title=\"term1 term2\")\n        tag1 = self.setup_tag(name=\"tag1\")\n        bookmark.tags.add(tag1)\n\n        html = self.render_template(url=\"/bookmarks?q=term1 and term2\")\n        soup = self.make_soup(html)\n        tags = soup.select_one(\".tags\")\n        tag_links = tags.find_all(\"a\")\n        self.assertEqual(len(tag_links), 1)\n        self.assertEqual(tag_links[0][\"href\"], \"?q=term1+and+term2+%23tag1\")\n\n        # wraps or expression in parentheses\n        html = self.render_template(url=\"/bookmarks?q=term1 or term2\")\n        soup = self.make_soup(html)\n        tags = soup.select_one(\".tags\")\n        tag_links = tags.find_all(\"a\")\n        self.assertEqual(len(tag_links), 1)\n        self.assertEqual(tag_links[0][\"href\"], \"?q=%28term1+or+term2%29+%23tag1\")\n\n    def test_should_render_web_archive_link_with_absolute_date_setting(self):\n        bookmark = self.setup_date_format_test(\n            UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,\n            \"https://web.archive.org/web/20210811214511/https://wanikani.com/\",\n        )\n        html = self.render_template()\n        formatted_date = formats.date_format(bookmark.date_added, \"SHORT_DATE_FORMAT\")\n\n        self.assertWebArchiveLink(\n            html, formatted_date, bookmark.web_archive_snapshot_url\n        )\n\n    def test_should_render_web_archive_link_with_relative_date_setting(self):\n        bookmark = self.setup_date_format_test(\n            UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,\n            \"https://web.archive.org/web/20210811214511/https://wanikani.com/\",\n        )\n        html = self.render_template()\n\n        self.assertWebArchiveLink(html, \"1 week ago\", bookmark.web_archive_snapshot_url)\n\n    def test_should_render_generated_web_archive_link_without_saved_snapshot_url(self):\n        user = self.get_or_create_test_user()\n        user.profile.bookmark_date_display = UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE\n        user.profile.save()\n\n        date_added = timezone.datetime(2023, 8, 11, 21, 45, 11, tzinfo=datetime.UTC)\n        bookmark = self.setup_bookmark(\n            url=\"https://example.com/article\", added=date_added\n        )\n\n        html = self.render_template()\n        formatted_date = formats.date_format(bookmark.date_added, \"SHORT_DATE_FORMAT\")\n\n        self.assertWebArchiveLink(\n            html,\n            formatted_date,\n            \"https://web.archive.org/web/20230811214511/https://example.com/article\",\n        )\n\n    def test_bookmark_link_target_should_be_blank_by_default(self):\n        bookmark = self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertBookmarksLink(html, bookmark, link_target=\"_blank\")\n\n    def test_bookmark_link_target_should_respect_user_profile(self):\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertBookmarksLink(html, bookmark, link_target=\"_self\")\n\n    def test_web_archive_link_target_should_be_blank_by_default(self):\n        bookmark = self.setup_bookmark()\n        bookmark.date_added = timezone.now() - datetime.timedelta(days=8)\n        bookmark.web_archive_snapshot_url = \"https://example.com\"\n        bookmark.save()\n\n        html = self.render_template()\n\n        self.assertWebArchiveLink(\n            html, \"1 week ago\", bookmark.web_archive_snapshot_url, link_target=\"_blank\"\n        )\n\n    def test_web_archive_link_target_should_respect_user_profile(self):\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        bookmark.date_added = timezone.now() - datetime.timedelta(days=8)\n        bookmark.web_archive_snapshot_url = \"https://example.com\"\n        bookmark.save()\n\n        html = self.render_template()\n\n        self.assertWebArchiveLink(\n            html, \"1 week ago\", bookmark.web_archive_snapshot_url, link_target=\"_self\"\n        )\n\n    def test_should_render_latest_snapshot_link_if_one_exists(self):\n        bookmark = self.setup_date_format_test(\n            UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE\n        )\n        bookmark.latest_snapshot = self.setup_asset(bookmark)\n        bookmark.save()\n\n        html = self.render_template()\n        formatted_date = formats.date_format(bookmark.date_added, \"SHORT_DATE_FORMAT\")\n        snapshot_url = reverse(\n            \"linkding:assets.view\", args=[bookmark.latest_snapshot.id]\n        )\n\n        # Check that the snapshot link is rendered with the correct URL and title\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{snapshot_url}\"\n               title=\"View latest snapshot\" target=\"_blank\" rel=\"noopener\">\n                {formatted_date}\n            </a>\n            <span>|</span>\n            \"\"\",\n            html,\n        )\n\n    def test_should_reflect_unread_state_as_css_class(self):\n        self.setup_bookmark(unread=True)\n        html = self.render_template()\n        soup = self.make_soup(html)\n\n        list_item = soup.select_one(\"ul.bookmark-list > li\")\n        self.assertIsNotNone(list_item)\n        self.assertListEqual([\"unread\"], list_item[\"class\"])\n\n    def test_should_reflect_shared_state_as_css_class(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        self.setup_bookmark(shared=True)\n        html = self.render_template()\n        soup = self.make_soup(html)\n\n        list_item = soup.select_one(\"ul.bookmark-list > li\")\n        self.assertIsNotNone(list_item)\n        self.assertListEqual([\"shared\"], list_item[\"class\"])\n\n    def test_should_reflect_both_unread_and_shared_state_as_css_class(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        self.setup_bookmark(unread=True, shared=True)\n        html = self.render_template()\n        soup = self.make_soup(html)\n\n        list_item = soup.select_one(\"ul.bookmark-list > li\")\n        self.assertIsNotNone(list_item)\n        self.assertListEqual([\"unread\", \"shared\"], list_item[\"class\"])\n\n    def test_show_bookmark_actions_for_owned_bookmarks(self):\n        bookmark = self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertViewLink(html, bookmark)\n        self.assertBookmarkActions(html, bookmark)\n        self.assertNoShareInfo(html, bookmark)\n\n    def test_hide_view_link(self):\n        bookmark = self.setup_bookmark()\n        profile = self.get_or_create_test_user().profile\n        profile.display_view_bookmark_action = False\n        profile.save()\n\n        html = self.render_template()\n        self.assertViewLinkCount(html, bookmark, count=0)\n        self.assertEditLinkCount(html, bookmark, count=1)\n        self.assertArchiveLinkCount(html, bookmark, count=1)\n        self.assertDeleteLinkCount(html, bookmark, count=1)\n\n    def test_hide_edit_link(self):\n        bookmark = self.setup_bookmark()\n        profile = self.get_or_create_test_user().profile\n        profile.display_edit_bookmark_action = False\n        profile.save()\n\n        html = self.render_template()\n        self.assertViewLinkCount(html, bookmark, count=1)\n        self.assertEditLinkCount(html, bookmark, count=0)\n        self.assertArchiveLinkCount(html, bookmark, count=1)\n        self.assertDeleteLinkCount(html, bookmark, count=1)\n\n    def test_hide_archive_link(self):\n        bookmark = self.setup_bookmark()\n        profile = self.get_or_create_test_user().profile\n        profile.display_archive_bookmark_action = False\n        profile.save()\n\n        html = self.render_template()\n        self.assertViewLinkCount(html, bookmark, count=1)\n        self.assertEditLinkCount(html, bookmark, count=1)\n        self.assertArchiveLinkCount(html, bookmark, count=0)\n        self.assertDeleteLinkCount(html, bookmark, count=1)\n\n    def test_hide_remove_link(self):\n        bookmark = self.setup_bookmark()\n        profile = self.get_or_create_test_user().profile\n        profile.display_remove_bookmark_action = False\n        profile.save()\n\n        html = self.render_template()\n        self.assertViewLinkCount(html, bookmark, count=1)\n        self.assertEditLinkCount(html, bookmark, count=1)\n        self.assertArchiveLinkCount(html, bookmark, count=1)\n        self.assertDeleteLinkCount(html, bookmark, count=0)\n\n    def test_show_share_info_for_non_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        other_user.profile.enable_sharing = True\n        other_user.profile.save()\n\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n        html = self.render_template(context_type=contexts.SharedBookmarkListContext)\n\n        self.assertViewLink(\n            html, bookmark, base_url=reverse(\"linkding:bookmarks.shared\")\n        )\n        self.assertNoBookmarkActions(html, bookmark)\n        self.assertShareInfo(html, bookmark)\n\n    def test_share_info_user_link_keeps_query_params(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        other_user.profile.enable_sharing = True\n        other_user.profile.save()\n\n        bookmark = self.setup_bookmark(user=other_user, shared=True, title=\"foo\")\n        html = self.render_template(\n            url=\"/bookmarks?q=foo\", context_type=contexts.SharedBookmarkListContext\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n            <span>Shared by \n                <a href=\"?q=foo&user={bookmark.owner.username}\">{bookmark.owner.username}</a>\n            </span>\n        \"\"\",\n            html,\n        )\n\n    def test_preview_image_should_be_visible_when_preview_images_enabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_preview_images = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(preview_image_file=\"preview.png\")\n        html = self.render_template()\n\n        self.assertPreviewImageVisible(html, bookmark)\n\n    def test_preview_image_should_be_hidden_when_preview_images_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_preview_images = False\n        profile.save()\n\n        bookmark = self.setup_bookmark(preview_image_file=\"preview.png\")\n        html = self.render_template()\n\n        self.assertPreviewImageHidden(html, bookmark)\n\n    def test_preview_image_shows_placeholder_when_there_is_no_preview_image(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_preview_images = True\n        profile.save()\n\n        self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertPreviewImagePlaceholder(html)\n\n    def test_favicon_should_be_visible_when_favicons_enabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(favicon_file=\"https_example_com.png\")\n        html = self.render_template()\n\n        self.assertFaviconVisible(html, bookmark)\n\n    def test_favicon_should_be_hidden_when_there_is_no_icon(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(favicon_file=\"\")\n        html = self.render_template()\n\n        self.assertFaviconHidden(html, bookmark)\n\n    def test_favicon_should_be_hidden_when_favicons_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = False\n        profile.save()\n\n        bookmark = self.setup_bookmark(favicon_file=\"https_example_com.png\")\n        html = self.render_template()\n\n        self.assertFaviconHidden(html, bookmark)\n\n    def test_bookmark_url_should_be_hidden_by_default(self):\n        profile = self.get_or_create_test_user().profile\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertBookmarkURLHidden(html, bookmark)\n\n    def test_show_bookmark_url_when_enabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.display_url = True\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertBookmarkURLVisible(html, bookmark)\n\n    def test_hide_bookmark_url_when_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.display_url = False\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertBookmarkURLHidden(html, bookmark)\n\n    def test_show_mark_as_read_when_unread(self):\n        bookmark = self.setup_bookmark(unread=True)\n        html = self.render_template()\n\n        self.assertMarkAsReadButton(html, bookmark)\n\n    def test_hide_mark_as_read_when_read(self):\n        bookmark = self.setup_bookmark(unread=False)\n        html = self.render_template()\n\n        self.assertMarkAsReadButton(html, bookmark, count=0)\n\n    def test_hide_mark_as_read_for_non_owned_bookmarks(self):\n        other_user = self.setup_user(enable_sharing=True)\n\n        bookmark = self.setup_bookmark(user=other_user, shared=True, unread=True)\n        html = self.render_template(context_type=contexts.SharedBookmarkListContext)\n\n        self.assertBookmarksLink(html, bookmark)\n        self.assertMarkAsReadButton(html, bookmark, count=0)\n\n    def test_show_unshare_button_when_shared(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(shared=True)\n        html = self.render_template()\n\n        self.assertUnshareButton(html, bookmark)\n\n    def test_hide_unshare_button_when_not_shared(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        bookmark = self.setup_bookmark(shared=False)\n        html = self.render_template()\n\n        self.assertUnshareButton(html, bookmark, count=0)\n\n    def test_hide_unshare_button_when_sharing_is_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = False\n        profile.save()\n\n        bookmark = self.setup_bookmark(shared=True)\n        html = self.render_template()\n\n        self.assertUnshareButton(html, bookmark, count=0)\n\n    def test_hide_unshare_for_non_owned_bookmarks(self):\n        other_user = self.setup_user(enable_sharing=True)\n\n        bookmark = self.setup_bookmark(user=other_user, shared=True)\n        html = self.render_template(context_type=contexts.SharedBookmarkListContext)\n\n        self.assertBookmarksLink(html, bookmark)\n        self.assertUnshareButton(html, bookmark, count=0)\n\n    def test_without_notes(self):\n        self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertNotes(html, \"\", 0)\n        self.assertNotesToggle(html, 0)\n\n    def test_with_notes(self):\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n\n        note_html = \"<p>Test note</p>\"\n        self.assertNotes(html, note_html, 1)\n\n    def test_note_renders_markdown(self):\n        self.setup_bookmark(notes='**Example:** `print(\"Hello world!\")`')\n        html = self.render_template()\n\n        note_html = (\n            '<p><strong>Example:</strong> <code>print(\"Hello world!\")</code></p>'\n        )\n        self.assertNotes(html, note_html, 1)\n\n    def test_note_renders_markdown_with_linkify(self):\n        # Should linkify plain URL\n        self.setup_bookmark(notes=\"Example: https://example.com\")\n        html = self.render_template()\n\n        note_html = '<p>Example: <a href=\"https://example.com\" rel=\"nofollow\">https://example.com</a></p>'\n        self.assertNotes(html, note_html, 1)\n\n        # Should not linkify URL in markdown link\n        self.setup_bookmark(notes=\"[https://example.com](https://example.com)\")\n        html = self.render_template()\n\n        note_html = '<p><a href=\"https://example.com\" rel=\"nofollow\">https://example.com</a></p>'\n        self.assertNotes(html, note_html, 1)\n\n    def test_note_linkify_converts_schemeless_urls_to_https(self):\n        # Scheme-less URL should become HTTPS\n        self.setup_bookmark(notes=\"Example: example.com\")\n        html = self.render_template()\n\n        note_html = '<p>Example: <a href=\"https://example.com\" rel=\"nofollow\">example.com</a></p>'\n        self.assertNotes(html, note_html, 1)\n\n        # Explicit http:// should stay as http://\n        self.setup_bookmark(notes=\"Example: http://example.com\")\n        html = self.render_template()\n\n        note_html = '<p>Example: <a href=\"http://example.com\" rel=\"nofollow\">http://example.com</a></p>'\n        self.assertNotes(html, note_html, 1)\n\n        # Explicit https:// should stay as https://\n        self.setup_bookmark(notes=\"Example: https://example.com\")\n        html = self.render_template()\n\n        note_html = '<p>Example: <a href=\"https://example.com\" rel=\"nofollow\">https://example.com</a></p>'\n        self.assertNotes(html, note_html, 1)\n\n        # Email addresses should not be affected\n        self.setup_bookmark(notes=\"Contact: hello@example.com\")\n        html = self.render_template()\n\n        note_html = \"<p>Contact: hello@example.com</p>\"\n        self.assertNotes(html, note_html, 1)\n\n        # ftp:// should not be converted to https\n        self.setup_bookmark(notes=\"FTP: ftp://ftp.example.com\")\n        html = self.render_template()\n\n        note_html = '<p>FTP: <a href=\"ftp://ftp.example.com\" rel=\"nofollow\">ftp://ftp.example.com</a></p>'\n        self.assertNotes(html, note_html, 1)\n\n    def test_note_cleans_html(self):\n        self.setup_bookmark(notes='<script>alert(\"test\")</script>')\n        self.setup_bookmark(\n            notes='<b ld-fetch=\"https://example.com\" ld-on=\"click\">bold text</b>'\n        )\n        html = self.render_template()\n\n        note_html = '&lt;script&gt;alert(\"test\")&lt;/script&gt;'\n        self.assertIn(note_html, html, 1)\n\n        note_html = \"<b>bold text</b>\"\n        self.assertIn(note_html, html, 1)\n\n    def test_notes_are_hidden_initially_by_default(self):\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n        soup = self.make_soup(html)\n        bookmark_list = soup.select_one(\"ul.bookmark-list.show-notes\")\n\n        self.assertIsNone(bookmark_list)\n\n    def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.permanent_notes = False\n        profile.save()\n\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n        soup = self.make_soup(html)\n        bookmark_list = soup.select_one(\"ul.bookmark-list.show-notes\")\n\n        self.assertIsNone(bookmark_list)\n\n    def test_notes_are_visible_initially_with_permanent_notes_enabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.permanent_notes = True\n        profile.save()\n\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n        soup = self.make_soup(html)\n        bookmark_list = soup.select_one(\"ul.bookmark-list.show-notes\")\n\n        self.assertIsNotNone(bookmark_list)\n\n    def test_toggle_notes_is_visible_by_default(self):\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n\n        self.assertNotesToggle(html, 1)\n\n    def test_toggle_notes_is_visible_with_permanent_notes_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.permanent_notes = False\n        profile.save()\n\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n\n        self.assertNotesToggle(html, 1)\n\n    def test_toggle_notes_is_hidden_with_permanent_notes_enabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.permanent_notes = True\n        profile.save()\n\n        self.setup_bookmark(notes=\"Test note\")\n        html = self.render_template()\n\n        self.assertNotesToggle(html, 0)\n\n    def test_with_anonymous_user(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.enable_public_sharing = True\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n        bookmark.date_added = timezone.now() - datetime.timedelta(days=8)\n        bookmark.web_archive_snapshot_url = (\n            \"https://web.archive.org/web/20230531200136/https://example.com\"\n        )\n        bookmark.notes = '**Example:** `print(\"Hello world!\")`'\n        bookmark.favicon_file = \"https_example_com.png\"\n        bookmark.shared = True\n        bookmark.unread = True\n        bookmark.save()\n\n        html = self.render_template(\n            context_type=contexts.SharedBookmarkListContext, user=AnonymousUser()\n        )\n        self.assertBookmarksLink(html, bookmark, link_target=\"_blank\")\n        self.assertWebArchiveLink(\n            html, \"1 week ago\", bookmark.web_archive_snapshot_url, link_target=\"_blank\"\n        )\n        self.assertViewLink(\n            html, bookmark, base_url=reverse(\"linkding:bookmarks.shared\")\n        )\n        self.assertNoBookmarkActions(html, bookmark)\n        self.assertShareInfo(html, bookmark)\n        self.assertMarkAsReadButton(html, bookmark, count=0)\n        self.assertUnshareButton(html, bookmark, count=0)\n        note_html = (\n            '<p><strong>Example:</strong> <code>print(\"Hello world!\")</code></p>'\n        )\n        self.assertNotes(html, note_html, 1)\n        self.assertFaviconVisible(html, bookmark)\n\n    def test_empty_state(self):\n        html = self.render_template()\n\n        self.assertInHTML(\n            '<p class=\"empty-title h5\">You have no bookmarks yet</p>', html\n        )\n\n    def test_empty_state_with_valid_query_no_results(self):\n        self.setup_bookmark(title=\"Test Bookmark\")\n        html = self.render_template(url=\"/bookmarks?q=nonexistent\")\n\n        self.assertInHTML(\n            '<p class=\"empty-title h5\">You have no bookmarks yet</p>', html\n        )\n\n    def test_empty_state_with_invalid_query(self):\n        self.setup_bookmark()\n        html = self.render_template(url=\"/bookmarks?q=(test\")\n\n        self.assertInHTML('<p class=\"empty-title h5\">Invalid search query</p>', html)\n        self.assertIn(\"Expected RPAREN\", html)\n\n    def test_empty_state_with_legacy_search(self):\n        profile = self.get_or_create_test_user().profile\n        profile.legacy_search = True\n        profile.save()\n\n        self.setup_bookmark()\n        html = self.render_template(url=\"/bookmarks?q=(test\")\n\n        # With legacy search, search queries are not validated\n        self.assertInHTML(\n            '<p class=\"empty-title h5\">You have no bookmarks yet</p>', html\n        )\n\n    def test_pagination_is_not_sticky_by_default(self):\n        self.setup_bookmark()\n        html = self.render_template()\n\n        self.assertIn('<div class=\"bookmark-pagination\">', html)\n\n    def test_pagination_is_sticky_when_enabled_in_profile(self):\n        self.setup_bookmark()\n        profile = self.get_or_create_test_user().profile\n        profile.sticky_pagination = True\n        profile.save()\n        html = self.render_template()\n\n        self.assertIn('<div class=\"bookmark-pagination sticky\">', html)\n\n    def test_items_per_page_is_30_by_default(self):\n        self.setup_numbered_bookmarks(50)\n        html = self.render_template()\n\n        soup = self.make_soup(html)\n        bookmarks = soup.select(\"ul.bookmark-list > li\")\n        self.assertEqual(30, len(bookmarks))\n\n    def test_items_per_page_is_configurable(self):\n        self.setup_numbered_bookmarks(50)\n        profile = self.get_or_create_test_user().profile\n        profile.items_per_page = 10\n        profile.save()\n        html = self.render_template()\n\n        soup = self.make_soup(html)\n        bookmarks = soup.select(\"ul.bookmark-list > li\")\n        self.assertEqual(10, len(bookmarks))\n\n    def test_no_actions_rendered_when_is_preview(self):\n        bookmark = self.setup_bookmark()\n        bookmark.date_added = timezone.now() - datetime.timedelta(days=8)\n        bookmark.web_archive_snapshot_url = \"https://example.com\"\n        bookmark.save()\n\n        html = self.render_template(is_preview=True)\n\n        # Verify no actions are rendered\n        self.assertNoViewLink(html, bookmark)\n        self.assertNoBookmarkActions(html, bookmark)\n        self.assertMarkAsReadButton(html, bookmark, count=0)\n        self.assertUnshareButton(html, bookmark, count=0)\n        self.assertNotesToggle(html, count=0)\n\n        # But date should still be rendered\n        self.assertWebArchiveLink(html, \"1 week ago\", bookmark.web_archive_snapshot_url)\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_model.py",
    "content": "from django.test import TestCase\n\nfrom bookmarks.models import Bookmark\n\n\nclass BookmarkTestCase(TestCase):\n    def test_bookmark_resolved_title(self):\n        bookmark = Bookmark(\n            title=\"Custom title\",\n            url=\"https://example.com\",\n        )\n        self.assertEqual(bookmark.resolved_title, \"Custom title\")\n\n        bookmark = Bookmark(title=\"\", url=\"https://example.com\")\n        self.assertEqual(bookmark.resolved_title, \"https://example.com\")\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_service.py",
    "content": "import datetime\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom bookmarks.models import Bookmark, Tag\nfrom bookmarks.services import tasks, website_loader\nfrom bookmarks.services.bookmarks import (\n    archive_bookmark,\n    archive_bookmarks,\n    create_bookmark,\n    create_html_snapshots,\n    delete_bookmarks,\n    enhance_with_website_metadata,\n    mark_bookmarks_as_read,\n    mark_bookmarks_as_unread,\n    refresh_bookmarks_metadata,\n    share_bookmarks,\n    tag_bookmarks,\n    unarchive_bookmark,\n    unarchive_bookmarks,\n    unshare_bookmarks,\n    untag_bookmarks,\n    update_bookmark,\n)\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.get_or_create_test_user()\n\n        self.mock_schedule_refresh_metadata_patcher = patch(\n            \"bookmarks.services.bookmarks.tasks.refresh_metadata\"\n        )\n        self.mock_schedule_refresh_metadata = (\n            self.mock_schedule_refresh_metadata_patcher.start()\n        )\n        self.mock_load_preview_image_patcher = patch(\n            \"bookmarks.services.bookmarks.tasks.load_preview_image\"\n        )\n        self.mock_load_preview_image = self.mock_load_preview_image_patcher.start()\n\n    def tearDown(self):\n        self.mock_schedule_refresh_metadata_patcher.stop()\n        self.mock_load_preview_image_patcher.stop()\n\n    def test_create_should_not_update_website_metadata(self):\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            bookmark_data = Bookmark(\n                url=\"https://example.com\",\n                title=\"Initial Title\",\n                description=\"Initial description\",\n                unread=True,\n                shared=True,\n                is_archived=True,\n            )\n            created_bookmark = create_bookmark(\n                bookmark_data, \"\", self.get_or_create_test_user()\n            )\n\n            created_bookmark.refresh_from_db()\n            self.assertEqual(\"Initial Title\", created_bookmark.title)\n            self.assertEqual(\"Initial description\", created_bookmark.description)\n            mock_load_website_metadata.assert_not_called()\n\n    def test_create_should_update_existing_bookmark_with_same_url(self):\n        original_bookmark = self.setup_bookmark(\n            url=\"https://example.com\", unread=False, shared=False\n        )\n        bookmark_data = Bookmark(\n            url=\"https://example.com\",\n            title=\"Updated Title\",\n            description=\"Updated description\",\n            notes=\"Updated notes\",\n            unread=True,\n            shared=True,\n            is_archived=True,\n        )\n        updated_bookmark = create_bookmark(\n            bookmark_data, \"\", self.get_or_create_test_user()\n        )\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(updated_bookmark.id, original_bookmark.id)\n        self.assertEqual(updated_bookmark.title, bookmark_data.title)\n        self.assertEqual(updated_bookmark.description, bookmark_data.description)\n        self.assertEqual(updated_bookmark.notes, bookmark_data.notes)\n        self.assertEqual(updated_bookmark.unread, bookmark_data.unread)\n        self.assertEqual(updated_bookmark.shared, bookmark_data.shared)\n        # Saving a duplicate bookmark should not modify archive flag - right?\n        self.assertFalse(updated_bookmark.is_archived)\n\n    def test_create_should_update_existing_bookmark_with_normalized_url(\n        self,\n    ):\n        original_bookmark = self.setup_bookmark(\n            url=\"https://EXAMPLE.com/path/?a=1&z=2\", unread=False, shared=False\n        )\n        bookmark_data = Bookmark(\n            url=\"HTTPS://example.com/path?z=2&a=1\",\n            title=\"Updated Title\",\n            description=\"Updated description\",\n        )\n        updated_bookmark = create_bookmark(\n            bookmark_data, \"\", self.get_or_create_test_user()\n        )\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(updated_bookmark.id, original_bookmark.id)\n        self.assertEqual(updated_bookmark.title, bookmark_data.title)\n\n    def test_create_should_update_existing_bookmark_when_normalized_url_is_empty(\n        self,\n    ):\n        # Test behavior when url_normalized is empty for whatever reason\n        # In this case should at least match the URL directly\n        original_bookmark = self.setup_bookmark(url=\"https://example.com\")\n        Bookmark.objects.update(url_normalized=\"\")\n        bookmark_data = Bookmark(\n            url=\"https://example.com\",\n            title=\"Updated Title\",\n            description=\"Updated description\",\n        )\n        updated_bookmark = create_bookmark(\n            bookmark_data, \"\", self.get_or_create_test_user()\n        )\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(updated_bookmark.id, original_bookmark.id)\n        self.assertEqual(updated_bookmark.title, bookmark_data.title)\n\n    def test_create_should_update_first_existing_bookmark_for_multiple_duplicates(\n        self,\n    ):\n        first_dupe = self.setup_bookmark(url=\"https://example.com\")\n        second_dupe = self.setup_bookmark(url=\"https://example.com/\")\n\n        bookmark_data = Bookmark(\n            url=\"https://example.com\",\n            title=\"Updated Title\",\n            description=\"Updated description\",\n        )\n        create_bookmark(bookmark_data, \"\", self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 2)\n\n        first_dupe.refresh_from_db()\n        self.assertEqual(first_dupe.title, bookmark_data.title)\n\n        second_dupe.refresh_from_db()\n        self.assertNotEqual(second_dupe.title, bookmark_data.title)\n\n    def test_create_should_populate_url_normalized_field(self):\n        bookmark_data = Bookmark(\n            url=\"https://EXAMPLE.COM/path/?z=1&a=2\",\n            title=\"Test Title\",\n            description=\"Test description\",\n        )\n        created_bookmark = create_bookmark(\n            bookmark_data, \"\", self.get_or_create_test_user()\n        )\n\n        created_bookmark.refresh_from_db()\n        self.assertEqual(created_bookmark.url, \"https://EXAMPLE.COM/path/?z=1&a=2\")\n        self.assertEqual(\n            created_bookmark.url_normalized, \"https://example.com/path?a=2&z=1\"\n        )\n\n    def test_create_should_create_web_archive_snapshot(self):\n        with patch.object(\n            tasks, \"create_web_archive_snapshot\"\n        ) as mock_create_web_archive_snapshot:\n            bookmark_data = Bookmark(url=\"https://example.com\")\n            bookmark = create_bookmark(bookmark_data, \"tag1,tag2\", self.user)\n\n            mock_create_web_archive_snapshot.assert_called_once_with(\n                self.user, bookmark, False\n            )\n\n    def test_create_should_load_favicon(self):\n        with patch.object(tasks, \"load_favicon\") as mock_load_favicon:\n            bookmark_data = Bookmark(url=\"https://example.com\")\n            bookmark = create_bookmark(bookmark_data, \"tag1,tag2\", self.user)\n\n            mock_load_favicon.assert_called_once_with(self.user, bookmark)\n\n    def test_create_should_load_html_snapshot(self):\n        with patch.object(tasks, \"create_html_snapshot\") as mock_create_html_snapshot:\n            bookmark_data = Bookmark(url=\"https://example.com\")\n            bookmark = create_bookmark(bookmark_data, \"tag1,tag2\", self.user)\n\n            mock_create_html_snapshot.assert_called_once_with(bookmark)\n\n    def test_create_should_not_load_html_snapshot_when_disabled(self):\n        with patch.object(tasks, \"create_html_snapshot\") as mock_create_html_snapshot:\n            bookmark_data = Bookmark(url=\"https://example.com\")\n            create_bookmark(\n                bookmark_data, \"tag1,tag2\", self.user, disable_html_snapshot=True\n            )\n\n            mock_create_html_snapshot.assert_not_called()\n\n    def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_automatic_html_snapshots = False\n        profile.save()\n\n        with patch.object(tasks, \"create_html_snapshot\") as mock_create_html_snapshot:\n            bookmark_data = Bookmark(url=\"https://example.com\")\n            create_bookmark(bookmark_data, \"tag1,tag2\", self.user)\n\n            mock_create_html_snapshot.assert_not_called()\n\n    def test_create_should_add_tags_from_auto_tagging(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = f\"example.com {tag2.name}\"\n        profile.save()\n\n        bookmark_data = Bookmark(url=\"https://example.com\")\n        bookmark = create_bookmark(bookmark_data, tag1.name, self.user)\n\n        self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])\n\n    def test_create_should_set_default_dates(self):\n        with patch(\"bookmarks.services.bookmarks.timezone.now\") as mock_now:\n            fixed_time = timezone.make_aware(datetime.datetime(2024, 1, 15, 12, 0, 0))\n            mock_now.return_value = fixed_time\n\n            bookmark_data = Bookmark(url=\"https://example.com\")\n            bookmark = create_bookmark(bookmark_data, \"\", self.user)\n\n            bookmark.refresh_from_db()\n            self.assertEqual(bookmark.date_added, fixed_time)\n            self.assertEqual(bookmark.date_modified, fixed_time)\n\n    def test_create_should_use_provided_date_added(self):\n        custom_date = timezone.now() - datetime.timedelta(days=30)\n        bookmark_data = Bookmark(url=\"https://example.com\", date_added=custom_date)\n        bookmark = create_bookmark(bookmark_data, \"\", self.user)\n\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.date_added, custom_date)\n\n    def test_create_should_use_provided_date_modified(self):\n        custom_date = timezone.now() - datetime.timedelta(days=15)\n        bookmark_data = Bookmark(url=\"https://example.com\", date_modified=custom_date)\n        bookmark = create_bookmark(bookmark_data, \"\", self.user)\n\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.date_modified, custom_date)\n\n    def test_create_should_use_provided_dates(self):\n        custom_date_added = timezone.now() - datetime.timedelta(days=30)\n        custom_date_modified = timezone.now() - datetime.timedelta(days=15)\n        bookmark_data = Bookmark(\n            url=\"https://example.com\",\n            date_added=custom_date_added,\n            date_modified=custom_date_modified,\n        )\n        bookmark = create_bookmark(bookmark_data, \"\", self.user)\n\n        bookmark.refresh_from_db()\n        self.assertEqual(bookmark.date_added, custom_date_added)\n        self.assertEqual(bookmark.date_modified, custom_date_modified)\n\n    def test_update_should_create_web_archive_snapshot_if_url_did_change(self):\n        with patch.object(\n            tasks, \"create_web_archive_snapshot\"\n        ) as mock_create_web_archive_snapshot:\n            bookmark = self.setup_bookmark()\n            bookmark.url = \"https://example.com/updated\"\n            update_bookmark(bookmark, \"tag1,tag2\", self.user)\n\n            mock_create_web_archive_snapshot.assert_called_once_with(\n                self.user, bookmark, True\n            )\n\n    def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):\n        with patch.object(\n            tasks, \"create_web_archive_snapshot\"\n        ) as mock_create_web_archive_snapshot:\n            bookmark = self.setup_bookmark()\n            bookmark.title = \"updated title\"\n            update_bookmark(bookmark, \"tag1,tag2\", self.user)\n\n            mock_create_web_archive_snapshot.assert_not_called()\n\n    def test_update_should_not_update_website_metadata(self):\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            bookmark = self.setup_bookmark()\n            bookmark.title = \"updated title\"\n            update_bookmark(bookmark, \"tag1,tag2\", self.user)\n            bookmark.refresh_from_db()\n\n            self.assertEqual(\"updated title\", bookmark.title)\n            mock_load_website_metadata.assert_not_called()\n\n    def test_update_should_not_update_website_metadata_if_url_did_change(self):\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            bookmark = self.setup_bookmark(title=\"initial title\")\n            bookmark.url = \"https://example.com/updated\"\n            update_bookmark(bookmark, \"tag1,tag2\", self.user)\n\n            bookmark.refresh_from_db()\n            self.assertEqual(\"initial title\", bookmark.title)\n            mock_load_website_metadata.assert_not_called()\n\n    def test_update_should_update_favicon(self):\n        with patch.object(tasks, \"load_favicon\") as mock_load_favicon:\n            bookmark = self.setup_bookmark()\n            bookmark.title = \"updated title\"\n            update_bookmark(bookmark, \"tag1,tag2\", self.user)\n\n            mock_load_favicon.assert_called_once_with(self.user, bookmark)\n\n    def test_update_should_not_create_html_snapshot(self):\n        with patch.object(tasks, \"create_html_snapshot\") as mock_create_html_snapshot:\n            bookmark = self.setup_bookmark()\n            bookmark.title = \"updated title\"\n            update_bookmark(bookmark, \"tag1,tag2\", self.user)\n\n            mock_create_html_snapshot.assert_not_called()\n\n    def test_update_should_add_tags_from_auto_tagging(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = f\"example.com {tag2.name}\"\n        profile.save()\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n\n        update_bookmark(bookmark, tag1.name, self.user)\n\n        self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])\n\n    def test_archive_bookmark(self):\n        bookmark = Bookmark(\n            url=\"https://example.com\",\n            date_added=timezone.now(),\n            date_modified=timezone.now(),\n            owner=self.user,\n        )\n        bookmark.save()\n\n        self.assertFalse(bookmark.is_archived)\n\n        archive_bookmark(bookmark)\n\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n\n        self.assertTrue(updated_bookmark.is_archived)\n\n    def test_unarchive_bookmark(self):\n        bookmark = Bookmark(\n            url=\"https://example.com\",\n            date_added=timezone.now(),\n            date_modified=timezone.now(),\n            owner=self.user,\n            is_archived=True,\n        )\n        bookmark.save()\n\n        unarchive_bookmark(bookmark)\n\n        updated_bookmark = Bookmark.objects.get(id=bookmark.id)\n\n        self.assertFalse(updated_bookmark.is_archived)\n\n    def test_archive_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        archive_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_archive_bookmarks_should_only_archive_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        archive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_archive_bookmarks_should_only_archive_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        inaccessible_bookmark = self.setup_bookmark(user=other_user)\n\n        archive_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)\n\n    def test_archive_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        archive_bookmarks(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_unarchive_bookmarks(self):\n        bookmark1 = self.setup_bookmark(is_archived=True)\n        bookmark2 = self.setup_bookmark(is_archived=True)\n        bookmark3 = self.setup_bookmark(is_archived=True)\n\n        unarchive_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_unarchive_bookmarks_should_only_unarchive_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark(is_archived=True)\n        bookmark2 = self.setup_bookmark(is_archived=True)\n        bookmark3 = self.setup_bookmark(is_archived=True)\n\n        unarchive_bookmarks(\n            [bookmark1.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_unarchive_bookmarks_should_only_unarchive_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark(is_archived=True)\n        bookmark2 = self.setup_bookmark(is_archived=True)\n        inaccessible_bookmark = self.setup_bookmark(is_archived=True, user=other_user)\n\n        unarchive_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)\n\n    def test_unarchive_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark(is_archived=True)\n        bookmark2 = self.setup_bookmark(is_archived=True)\n        bookmark3 = self.setup_bookmark(is_archived=True)\n\n        unarchive_bookmarks(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)\n\n    def test_delete_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        delete_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())\n\n    def test_delete_bookmarks_should_only_delete_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        delete_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())\n\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())\n        self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())\n\n    def test_delete_bookmarks_should_only_delete_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        inaccessible_bookmark = self.setup_bookmark(user=other_user)\n\n        delete_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())\n        self.assertIsNotNone(\n            Bookmark.objects.filter(id=inaccessible_bookmark.id).first()\n        )\n\n    def test_delete_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        delete_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())\n        self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())\n\n    def test_tag_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        tag_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n\n    def test_tag_bookmarks_should_create_tags(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        tag_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id],\n            \"tag1,tag2\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertEqual(2, Tag.objects.count())\n\n        tag1 = Tag.objects.filter(name=\"tag1\").first()\n        tag2 = Tag.objects.filter(name=\"tag2\").first()\n\n        self.assertIsNotNone(tag1)\n        self.assertIsNotNone(tag2)\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n\n    def test_tag_bookmarks_should_handle_existing_relationships(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1])\n        bookmark2 = self.setup_bookmark(tags=[tag1])\n        bookmark3 = self.setup_bookmark(tags=[tag1])\n\n        BookmarkToTagRelationShip = Bookmark.tags.through\n        self.assertEqual(3, BookmarkToTagRelationShip.objects.count())\n\n        tag_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n        self.assertEqual(6, BookmarkToTagRelationShip.objects.count())\n\n    def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        tag_bookmarks(\n            [bookmark1.id, bookmark3.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n\n    def test_tag_bookmarks_should_only_tag_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        inaccessible_bookmark = self.setup_bookmark(user=other_user)\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        tag_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        inaccessible_bookmark.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(inaccessible_bookmark.tags.all(), [])\n\n    def test_tag_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n\n        tag_bookmarks(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])\n\n    def test_untag_bookmarks(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark2 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark3 = self.setup_bookmark(tags=[tag1, tag2])\n\n        untag_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [])\n        self.assertCountEqual(bookmark2.tags.all(), [])\n        self.assertCountEqual(bookmark3.tags.all(), [])\n\n    def test_untag_bookmarks_should_only_tag_specified_bookmarks(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark2 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark3 = self.setup_bookmark(tags=[tag1, tag2])\n\n        untag_bookmarks(\n            [bookmark1.id, bookmark3.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        bookmark3.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [])\n        self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])\n        self.assertCountEqual(bookmark3.tags.all(), [])\n\n    def test_untag_bookmarks_should_only_tag_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark2 = self.setup_bookmark(tags=[tag1, tag2])\n        inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])\n\n        untag_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        inaccessible_bookmark.refresh_from_db()\n\n        self.assertCountEqual(bookmark1.tags.all(), [])\n        self.assertCountEqual(bookmark2.tags.all(), [])\n        self.assertCountEqual(inaccessible_bookmark.tags.all(), [tag1, tag2])\n\n    def test_untag_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark2 = self.setup_bookmark(tags=[tag1, tag2])\n        bookmark3 = self.setup_bookmark(tags=[tag1, tag2])\n\n        untag_bookmarks(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            f\"{tag1.name},{tag2.name}\",\n            self.get_or_create_test_user(),\n        )\n\n        self.assertCountEqual(bookmark1.tags.all(), [])\n        self.assertCountEqual(bookmark2.tags.all(), [])\n        self.assertCountEqual(bookmark3.tags.all(), [])\n\n    def test_mark_bookmarks_as_read(self):\n        bookmark1 = self.setup_bookmark(unread=True)\n        bookmark2 = self.setup_bookmark(unread=True)\n        bookmark3 = self.setup_bookmark(unread=True)\n\n        mark_bookmarks_as_read(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_mark_bookmarks_as_read_should_only_update_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark(unread=True)\n        bookmark2 = self.setup_bookmark(unread=True)\n        bookmark3 = self.setup_bookmark(unread=True)\n\n        mark_bookmarks_as_read(\n            [bookmark1.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_mark_bookmarks_as_read_should_only_update_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark(unread=True)\n        bookmark2 = self.setup_bookmark(unread=True)\n        inaccessible_bookmark = self.setup_bookmark(unread=True, user=other_user)\n\n        mark_bookmarks_as_read(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)\n\n    def test_mark_bookmarks_as_read_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark(unread=True)\n        bookmark2 = self.setup_bookmark(unread=True)\n        bookmark3 = self.setup_bookmark(unread=True)\n\n        mark_bookmarks_as_read(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_mark_bookmarks_as_unread(self):\n        bookmark1 = self.setup_bookmark(unread=False)\n        bookmark2 = self.setup_bookmark(unread=False)\n        bookmark3 = self.setup_bookmark(unread=False)\n\n        mark_bookmarks_as_unread(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_mark_bookmarks_as_unread_should_only_update_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark(unread=False)\n        bookmark2 = self.setup_bookmark(unread=False)\n        bookmark3 = self.setup_bookmark(unread=False)\n\n        mark_bookmarks_as_unread(\n            [bookmark1.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_mark_bookmarks_as_unread_should_only_update_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark(unread=False)\n        bookmark2 = self.setup_bookmark(unread=False)\n        inaccessible_bookmark = self.setup_bookmark(unread=False, user=other_user)\n\n        mark_bookmarks_as_unread(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)\n\n    def test_mark_bookmarks_as_unread_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark(unread=False)\n        bookmark2 = self.setup_bookmark(unread=False)\n        bookmark3 = self.setup_bookmark(unread=False)\n\n        mark_bookmarks_as_unread(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)\n\n    def test_share_bookmarks(self):\n        bookmark1 = self.setup_bookmark(shared=False)\n        bookmark2 = self.setup_bookmark(shared=False)\n        bookmark3 = self.setup_bookmark(shared=False)\n\n        share_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_share_bookmarks_should_only_update_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark(shared=False)\n        bookmark2 = self.setup_bookmark(shared=False)\n        bookmark3 = self.setup_bookmark(shared=False)\n\n        share_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_share_bookmarks_should_only_update_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark(shared=False)\n        bookmark2 = self.setup_bookmark(shared=False)\n        inaccessible_bookmark = self.setup_bookmark(shared=False, user=other_user)\n\n        share_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)\n\n    def test_share_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark(shared=False)\n        bookmark2 = self.setup_bookmark(shared=False)\n        bookmark3 = self.setup_bookmark(shared=False)\n\n        share_bookmarks(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_unshare_bookmarks(self):\n        bookmark1 = self.setup_bookmark(shared=True)\n        bookmark2 = self.setup_bookmark(shared=True)\n        bookmark3 = self.setup_bookmark(shared=True)\n\n        unshare_bookmarks(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_unshare_bookmarks_should_only_update_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark(shared=True)\n        bookmark2 = self.setup_bookmark(shared=True)\n        bookmark3 = self.setup_bookmark(shared=True)\n\n        unshare_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_unshare_bookmarks_should_only_update_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark(shared=True)\n        bookmark2 = self.setup_bookmark(shared=True)\n        inaccessible_bookmark = self.setup_bookmark(shared=True, user=other_user)\n\n        unshare_bookmarks(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)\n\n    def test_unshare_bookmarks_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark(shared=True)\n        bookmark2 = self.setup_bookmark(shared=True)\n        bookmark3 = self.setup_bookmark(shared=True)\n\n        unshare_bookmarks(\n            [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)\n        self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)\n\n    def test_enhance_with_website_metadata(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n        with patch.object(\n            website_loader, \"load_website_metadata\"\n        ) as mock_load_website_metadata:\n            mock_load_website_metadata.return_value = website_loader.WebsiteMetadata(\n                url=\"https://example.com\",\n                title=\"Website title\",\n                description=\"Website description\",\n                preview_image=None,\n            )\n\n            # missing title and description\n            bookmark.title = \"\"\n            bookmark.description = \"\"\n            bookmark.save()\n            enhance_with_website_metadata(bookmark)\n            bookmark.refresh_from_db()\n\n            self.assertEqual(\"Website title\", bookmark.title)\n            self.assertEqual(\"Website description\", bookmark.description)\n\n            # missing title only\n            bookmark.title = \"\"\n            bookmark.description = \"Initial description\"\n            bookmark.save()\n            enhance_with_website_metadata(bookmark)\n            bookmark.refresh_from_db()\n\n            self.assertEqual(\"Website title\", bookmark.title)\n            self.assertEqual(\"Initial description\", bookmark.description)\n\n            # missing description only\n            bookmark.title = \"Initial title\"\n            bookmark.description = \"\"\n            bookmark.save()\n            enhance_with_website_metadata(bookmark)\n            bookmark.refresh_from_db()\n\n            self.assertEqual(\"Initial title\", bookmark.title)\n            self.assertEqual(\"Website description\", bookmark.description)\n\n            # metadata returns None\n            mock_load_website_metadata.return_value = website_loader.WebsiteMetadata(\n                url=\"https://example.com\",\n                title=None,\n                description=None,\n                preview_image=None,\n            )\n            bookmark.title = \"\"\n            bookmark.description = \"\"\n            bookmark.save()\n            enhance_with_website_metadata(bookmark)\n            bookmark.refresh_from_db()\n\n            self.assertEqual(\"\", bookmark.title)\n            self.assertEqual(\"\", bookmark.description)\n\n    def test_refresh_bookmarks_metadata(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        refresh_bookmarks_metadata(\n            [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 3)\n        self.assertEqual(self.mock_load_preview_image.call_count, 3)\n\n    def test_refresh_bookmarks_metadata_should_only_refresh_specified_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        refresh_bookmarks_metadata(\n            [bookmark1.id, bookmark3.id], self.get_or_create_test_user()\n        )\n\n        self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 2)\n        self.assertEqual(self.mock_load_preview_image.call_count, 2)\n\n        for call_args in self.mock_schedule_refresh_metadata.call_args_list:\n            args, kwargs = call_args\n            self.assertNotIn(bookmark2.id, args)\n\n        for call_args in self.mock_load_preview_image.call_args_list:\n            args, kwargs = call_args\n            self.assertNotIn(bookmark2.id, args)\n\n    def test_refresh_bookmarks_metadata_should_only_refresh_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        inaccessible_bookmark = self.setup_bookmark(user=other_user)\n\n        refresh_bookmarks_metadata(\n            [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 2)\n        self.assertEqual(self.mock_load_preview_image.call_count, 2)\n\n        for call_args in self.mock_schedule_refresh_metadata.call_args_list:\n            args, kwargs = call_args\n            self.assertNotIn(inaccessible_bookmark.id, args)\n\n        for call_args in self.mock_load_preview_image.call_args_list:\n            args, kwargs = call_args\n            self.assertNotIn(inaccessible_bookmark.id, args)\n\n    def test_refresh_bookmarks_metadata_should_accept_mix_of_int_and_string_ids(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        refresh_bookmarks_metadata(\n            [str(bookmark1.id), str(bookmark2.id), bookmark3.id],\n            self.get_or_create_test_user(),\n        )\n\n        self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 3)\n        self.assertEqual(self.mock_load_preview_image.call_count, 3)\n\n    def test_create_html_snapshots(self):\n        with patch.object(tasks, \"create_html_snapshots\") as mock_create_html_snapshots:\n            bookmark1 = self.setup_bookmark()\n            bookmark2 = self.setup_bookmark()\n            bookmark3 = self.setup_bookmark()\n\n            create_html_snapshots(\n                [bookmark1.id, bookmark2.id, bookmark3.id],\n                self.get_or_create_test_user(),\n            )\n\n            mock_create_html_snapshots.assert_called_once()\n            call_args = mock_create_html_snapshots.call_args[0][0]\n            bookmark_ids = list(call_args.values_list(\"id\", flat=True))\n            self.assertCountEqual(\n                bookmark_ids, [bookmark1.id, bookmark2.id, bookmark3.id]\n            )\n\n    def test_create_html_snapshots_should_only_create_for_specified_bookmarks(self):\n        with patch.object(tasks, \"create_html_snapshots\") as mock_create_html_snapshots:\n            bookmark1 = self.setup_bookmark()\n            bookmark2 = self.setup_bookmark()\n            bookmark3 = self.setup_bookmark()\n\n            create_html_snapshots(\n                [bookmark1.id, bookmark3.id], self.get_or_create_test_user()\n            )\n\n            mock_create_html_snapshots.assert_called_once()\n            call_args = mock_create_html_snapshots.call_args[0][0]\n            bookmark_ids = list(call_args.values_list(\"id\", flat=True))\n            self.assertCountEqual(bookmark_ids, [bookmark1.id, bookmark3.id])\n            self.assertNotIn(bookmark2.id, bookmark_ids)\n\n    def test_create_html_snapshots_should_only_create_for_user_owned_bookmarks(self):\n        with patch.object(tasks, \"create_html_snapshots\") as mock_create_html_snapshots:\n            other_user = self.setup_user()\n            bookmark1 = self.setup_bookmark()\n            bookmark2 = self.setup_bookmark()\n            inaccessible_bookmark = self.setup_bookmark(user=other_user)\n\n            create_html_snapshots(\n                [bookmark1.id, bookmark2.id, inaccessible_bookmark.id],\n                self.get_or_create_test_user(),\n            )\n\n            mock_create_html_snapshots.assert_called_once()\n            call_args = mock_create_html_snapshots.call_args[0][0]\n            bookmark_ids = list(call_args.values_list(\"id\", flat=True))\n            self.assertCountEqual(bookmark_ids, [bookmark1.id, bookmark2.id])\n            self.assertNotIn(inaccessible_bookmark.id, bookmark_ids)\n\n    def test_create_html_snapshots_should_accept_mix_of_int_and_string_ids(self):\n        with patch.object(tasks, \"create_html_snapshots\") as mock_create_html_snapshots:\n            bookmark1 = self.setup_bookmark()\n            bookmark2 = self.setup_bookmark()\n            bookmark3 = self.setup_bookmark()\n\n            create_html_snapshots(\n                [str(bookmark1.id), bookmark2.id, str(bookmark3.id)],\n                self.get_or_create_test_user(),\n            )\n\n            mock_create_html_snapshots.assert_called_once()\n            call_args = mock_create_html_snapshots.call_args[0][0]\n            bookmark_ids = list(call_args.values_list(\"id\", flat=True))\n            self.assertCountEqual(\n                bookmark_ids, [bookmark1.id, bookmark2.id, bookmark3.id]\n            )\n"
  },
  {
    "path": "bookmarks/tests/test_bookmarks_tasks.py",
    "content": "from unittest import mock\n\nimport waybackpy\nfrom django.contrib.auth.models import User\nfrom django.test import TestCase, override_settings\nfrom huey.contrib.djhuey import HUEY as huey\nfrom waybackpy.exceptions import WaybackError\n\nfrom bookmarks.models import BookmarkAsset, UserProfile\nfrom bookmarks.services import tasks\nfrom bookmarks.services.website_loader import WebsiteMetadata\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\ndef create_wayback_machine_save_api_mock(\n    archive_url: str = \"https://example.com/created_snapshot\",\n    fail_on_save: bool = False,\n):\n    mock_api = mock.Mock(archive_url=archive_url)\n\n    if fail_on_save:\n        mock_api.save.side_effect = WaybackError\n\n    return mock_api\n\n\nclass BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        huey.immediate = True\n        huey.results = True\n        huey.store_none = True\n\n        self.mock_save_api = mock.Mock(\n            archive_url=\"https://example.com/created_snapshot\"\n        )\n        self.mock_save_api_patcher = mock.patch.object(\n            waybackpy, \"WaybackMachineSaveAPI\", return_value=self.mock_save_api\n        )\n        self.mock_save_api_patcher.start()\n\n        self.mock_load_favicon_patcher = mock.patch(\n            \"bookmarks.services.favicon_loader.load_favicon\"\n        )\n        self.mock_load_favicon = self.mock_load_favicon_patcher.start()\n        self.mock_load_favicon.return_value = \"https_example_com.png\"\n\n        self.mock_assets_create_snapshot_patcher = mock.patch(\n            \"bookmarks.services.assets.create_snapshot\",\n        )\n        self.mock_assets_create_snapshot = (\n            self.mock_assets_create_snapshot_patcher.start()\n        )\n\n        self.mock_load_preview_image_patcher = mock.patch(\n            \"bookmarks.services.preview_image_loader.load_preview_image\"\n        )\n        self.mock_load_preview_image = self.mock_load_preview_image_patcher.start()\n        self.mock_load_preview_image.return_value = \"preview_image.png\"\n\n        user = self.get_or_create_test_user()\n        user.profile.web_archive_integration = (\n            UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED\n        )\n        user.profile.enable_favicons = True\n        user.profile.enable_preview_images = True\n        user.profile.save()\n\n    def tearDown(self):\n        self.mock_save_api_patcher.stop()\n        self.mock_load_favicon_patcher.stop()\n        self.mock_assets_create_snapshot_patcher.stop()\n        self.mock_load_preview_image_patcher.stop()\n        huey.storage.flush_results()\n        huey.immediate = False\n\n    def executed_count(self):\n        return len(huey.all_results())\n\n    def test_create_web_archive_snapshot_should_update_snapshot_url(self):\n        bookmark = self.setup_bookmark()\n\n        tasks.create_web_archive_snapshot(\n            self.get_or_create_test_user(), bookmark, False\n        )\n        bookmark.refresh_from_db()\n\n        self.mock_save_api.save.assert_called_once()\n        self.assertEqual(self.executed_count(), 1)\n        self.assertEqual(\n            bookmark.web_archive_snapshot_url,\n            \"https://example.com/created_snapshot\",\n        )\n\n    def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):\n        tasks._create_web_archive_snapshot_task(123, False)\n\n        self.assertEqual(self.executed_count(), 1)\n        self.mock_save_api.save.assert_not_called()\n\n    def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):\n        bookmark = self.setup_bookmark(web_archive_snapshot_url=\"https://example.com\")\n\n        self.mock_save_api.create_web_archive_snapshot(\n            self.get_or_create_test_user(), bookmark, False\n        )\n\n        self.assertEqual(self.executed_count(), 0)\n        self.mock_save_api.assert_not_called()\n\n    def test_create_web_archive_snapshot_should_force_update_snapshot(self):\n        bookmark = self.setup_bookmark(web_archive_snapshot_url=\"https://example.com\")\n        self.mock_save_api.archive_url = \"https://other.com\"\n\n        tasks.create_web_archive_snapshot(\n            self.get_or_create_test_user(), bookmark, True\n        )\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.web_archive_snapshot_url, \"https://other.com\")\n\n    def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):\n        bookmark = self.setup_bookmark()\n\n        # update bookmark during API call to check that saving\n        # the snapshot does not overwrite updated bookmark data\n        def mock_save_impl():\n            bookmark.title = \"Updated title\"\n            bookmark.save()\n\n        self.mock_save_api.save.side_effect = mock_save_impl\n\n        tasks.create_web_archive_snapshot(\n            self.get_or_create_test_user(), bookmark, False\n        )\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.title, \"Updated title\")\n        self.assertEqual(\n            \"https://example.com/created_snapshot\",\n            bookmark.web_archive_snapshot_url,\n        )\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(\n        self,\n    ):\n        bookmark = self.setup_bookmark()\n\n        tasks.create_web_archive_snapshot(\n            self.get_or_create_test_user(), bookmark, False\n        )\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(\n        self,\n    ):\n        self.user.profile.web_archive_integration = (\n            UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED\n        )\n        self.user.profile.save()\n\n        bookmark = self.setup_bookmark()\n        tasks.create_web_archive_snapshot(\n            self.get_or_create_test_user(), bookmark, False\n        )\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_load_favicon_should_create_favicon_file(self):\n        bookmark = self.setup_bookmark()\n\n        tasks.load_favicon(self.get_or_create_test_user(), bookmark)\n        bookmark.refresh_from_db()\n\n        self.assertEqual(self.executed_count(), 1)\n        self.assertEqual(bookmark.favicon_file, \"https_example_com.png\")\n\n    def test_load_favicon_should_update_favicon_file(self):\n        bookmark = self.setup_bookmark(favicon_file=\"https_example_com.png\")\n\n        self.mock_load_favicon.return_value = \"https_example_updated_com.png\"\n\n        tasks.load_favicon(self.get_or_create_test_user(), bookmark)\n\n        bookmark.refresh_from_db()\n        self.mock_load_favicon.assert_called_once()\n        self.assertEqual(bookmark.favicon_file, \"https_example_updated_com.png\")\n\n    def test_load_favicon_should_handle_missing_bookmark(self):\n        tasks._load_favicon_task(123)\n\n        self.mock_load_favicon.assert_not_called()\n\n    def test_load_favicon_should_not_save_stale_bookmark_data(self):\n        bookmark = self.setup_bookmark()\n\n        # update bookmark during API call to check that saving\n        # the favicon does not overwrite updated bookmark data\n        def mock_load_favicon_impl(url):\n            bookmark.title = \"Updated title\"\n            bookmark.save()\n            return \"https_example_com.png\"\n\n        self.mock_load_favicon.side_effect = mock_load_favicon_impl\n\n        tasks.load_favicon(self.get_or_create_test_user(), bookmark)\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.title, \"Updated title\")\n        self.assertEqual(bookmark.favicon_file, \"https_example_com.png\")\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self):\n        bookmark = self.setup_bookmark()\n        tasks.load_favicon(self.get_or_create_test_user(), bookmark)\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self):\n        self.user.profile.enable_favicons = False\n        self.user.profile.save()\n\n        bookmark = self.setup_bookmark()\n        tasks.load_favicon(self.get_or_create_test_user(), bookmark)\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(\n        self,\n    ):\n        user = self.get_or_create_test_user()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark(favicon_file=\"https_example_com.png\")\n        self.setup_bookmark(favicon_file=\"https_example_com.png\")\n        self.setup_bookmark(favicon_file=\"https_example_com.png\")\n\n        tasks.schedule_bookmarks_without_favicons(user)\n\n        self.assertEqual(self.executed_count(), 4)\n        self.assertEqual(self.mock_load_favicon.call_count, 3)\n\n    def test_schedule_bookmarks_without_favicons_should_only_update_user_owned_bookmarks(\n        self,\n    ):\n        user = self.get_or_create_test_user()\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n\n        tasks.schedule_bookmarks_without_favicons(user)\n\n        self.assertEqual(self.mock_load_favicon.call_count, 3)\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(\n        self,\n    ):\n        self.setup_bookmark()\n        tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(\n        self,\n    ):\n        self.user.profile.enable_favicons = False\n        self.user.profile.save()\n\n        self.setup_bookmark()\n        tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self):\n        user = self.get_or_create_test_user()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark(favicon_file=\"https_example_com.png\")\n        self.setup_bookmark(favicon_file=\"https_example_com.png\")\n        self.setup_bookmark(favicon_file=\"https_example_com.png\")\n\n        tasks.schedule_refresh_favicons(user)\n\n        self.assertEqual(self.executed_count(), 7)\n        self.assertEqual(self.mock_load_favicon.call_count, 6)\n\n    def test_schedule_refresh_favicons_should_only_update_user_owned_bookmarks(self):\n        user = self.get_or_create_test_user()\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n\n        tasks.schedule_refresh_favicons(user)\n\n        self.assertEqual(self.mock_load_favicon.call_count, 3)\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(\n        self,\n    ):\n        self.setup_bookmark()\n        tasks.schedule_refresh_favicons(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    @override_settings(LD_ENABLE_REFRESH_FAVICONS=False)\n    def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self):\n        self.setup_bookmark()\n        tasks.schedule_refresh_favicons(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(\n        self,\n    ):\n        self.user.profile.enable_favicons = False\n        self.user.profile.save()\n\n        self.setup_bookmark()\n        tasks.schedule_refresh_favicons(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_load_preview_image_should_create_preview_image_file(self):\n        bookmark = self.setup_bookmark()\n\n        tasks.load_preview_image(self.get_or_create_test_user(), bookmark)\n        bookmark.refresh_from_db()\n\n        self.assertEqual(self.executed_count(), 1)\n        self.assertEqual(bookmark.preview_image_file, \"preview_image.png\")\n\n    def test_load_preview_image_should_update_preview_image_file(self):\n        bookmark = self.setup_bookmark(\n            preview_image_file=\"preview_image.png\",\n        )\n\n        self.mock_load_preview_image.return_value = \"preview_image_upd.png\"\n\n        tasks.load_preview_image(self.get_or_create_test_user(), bookmark)\n\n        bookmark.refresh_from_db()\n        self.mock_load_preview_image.assert_called_once()\n        self.assertEqual(bookmark.preview_image_file, \"preview_image_upd.png\")\n\n    def test_load_preview_image_should_set_blank_when_none_is_returned(self):\n        bookmark = self.setup_bookmark(\n            preview_image_file=\"preview_image.png\",\n        )\n\n        self.mock_load_preview_image.return_value = None\n\n        tasks.load_preview_image(self.get_or_create_test_user(), bookmark)\n\n        bookmark.refresh_from_db()\n        self.mock_load_preview_image.assert_called_once()\n        self.assertEqual(bookmark.preview_image_file, \"\")\n\n    def test_load_preview_image_should_handle_missing_bookmark(self):\n        tasks._load_preview_image_task(123)\n\n        self.mock_load_preview_image.assert_not_called()\n\n    def test_load_preview_image_should_not_save_stale_bookmark_data(self):\n        bookmark = self.setup_bookmark()\n\n        # update bookmark during API call to check that saving\n        # the image does not overwrite updated bookmark data\n        def mock_load_preview_image_impl(url):\n            bookmark.title = \"Updated title\"\n            bookmark.save()\n            return \"test.png\"\n\n        self.mock_load_preview_image.side_effect = mock_load_preview_image_impl\n\n        tasks.load_preview_image(self.get_or_create_test_user(), bookmark)\n        bookmark.refresh_from_db()\n\n        self.assertEqual(bookmark.title, \"Updated title\")\n        self.assertEqual(bookmark.preview_image_file, \"test.png\")\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_load_preview_image_should_not_run_when_background_tasks_are_disabled(self):\n        bookmark = self.setup_bookmark()\n        tasks.load_preview_image(self.get_or_create_test_user(), bookmark)\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_load_preview_image_should_not_run_when_preview_image_feature_is_disabled(\n        self,\n    ):\n        self.user.profile.enable_preview_images = False\n        self.user.profile.save()\n\n        bookmark = self.setup_bookmark()\n        tasks.load_preview_image(self.get_or_create_test_user(), bookmark)\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_schedule_bookmarks_without_previews_should_load_preview_for_all_bookmarks_without_preview(\n        self,\n    ):\n        user = self.get_or_create_test_user()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark(preview_image_file=\"test.png\")\n        self.setup_bookmark(preview_image_file=\"test.png\")\n        self.setup_bookmark(preview_image_file=\"test.png\")\n\n        tasks.schedule_bookmarks_without_previews(user)\n\n        self.assertEqual(self.executed_count(), 4)\n        self.assertEqual(self.mock_load_preview_image.call_count, 3)\n\n    def test_schedule_bookmarks_without_previews_should_only_update_user_owned_bookmarks(\n        self,\n    ):\n        user = self.get_or_create_test_user()\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n\n        tasks.schedule_bookmarks_without_previews(user)\n\n        self.assertEqual(self.mock_load_preview_image.call_count, 3)\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_schedule_bookmarks_without_previews_should_not_run_when_background_tasks_are_disabled(\n        self,\n    ):\n        self.setup_bookmark()\n        tasks.schedule_bookmarks_without_previews(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    def test_schedule_bookmarks_without_previews_should_not_run_when_preview_feature_is_disabled(\n        self,\n    ):\n        self.user.profile.enable_preview_images = False\n        self.user.profile.save()\n\n        self.setup_bookmark()\n        tasks.schedule_bookmarks_without_previews(self.get_or_create_test_user())\n\n        self.assertEqual(self.executed_count(), 0)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_html_snapshot_should_create_pending_asset(self):\n        bookmark = self.setup_bookmark()\n\n        # Mock the task function to avoid running it immediately\n        with mock.patch(\"bookmarks.services.tasks._create_html_snapshot_task\"):\n            tasks.create_html_snapshot(bookmark)\n            self.assertEqual(BookmarkAsset.objects.count(), 1)\n\n            tasks.create_html_snapshot(bookmark)\n            self.assertEqual(BookmarkAsset.objects.count(), 2)\n\n            assets = BookmarkAsset.objects.filter(bookmark=bookmark)\n            for asset in assets:\n                self.assertEqual(asset.bookmark, bookmark)\n                self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)\n                self.assertEqual(asset.content_type, \"\")\n                self.assertIn(\"New snapshot\", asset.display_name)\n                self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)\n\n            self.mock_assets_create_snapshot.assert_not_called()\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_schedule_html_snapshots_should_create_snapshots(self):\n        bookmark = self.setup_bookmark(url=\"https://example.com\")\n\n        tasks.create_html_snapshot(bookmark)\n        tasks.create_html_snapshot(bookmark)\n        tasks.create_html_snapshot(bookmark)\n\n        assets = BookmarkAsset.objects.filter(bookmark=bookmark)\n\n        tasks._schedule_html_snapshots_task()\n\n        # should call create_snapshot for each pending asset\n        self.assertEqual(self.mock_assets_create_snapshot.call_count, 3)\n\n        for asset in assets:\n            self.mock_assets_create_snapshot.assert_any_call(asset)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_html_snapshot_should_handle_missing_asset(self):\n        tasks._create_html_snapshot_task(123)\n\n        self.mock_assets_create_snapshot.assert_not_called()\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=False)\n    def test_create_html_snapshot_should_not_create_asset_when_single_file_is_disabled(\n        self,\n    ):\n        bookmark = self.setup_bookmark()\n        tasks.create_html_snapshot(bookmark)\n\n        self.assertEqual(BookmarkAsset.objects.count(), 0)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True, LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_create_html_snapshot_should_not_create_asset_when_background_tasks_are_disabled(\n        self,\n    ):\n        bookmark = self.setup_bookmark()\n        tasks.create_html_snapshot(bookmark)\n\n        self.assertEqual(BookmarkAsset.objects.count(), 0)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_missing_html_snapshots(self):\n        bookmarks_with_snapshots = []\n        bookmarks_without_snapshots = []\n\n        # setup bookmarks with snapshots\n        bookmark = self.setup_bookmark()\n        self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_COMPLETE,\n        )\n        bookmarks_with_snapshots.append(bookmark)\n\n        bookmark = self.setup_bookmark()\n        self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_PENDING,\n        )\n        bookmarks_with_snapshots.append(bookmark)\n\n        # setup bookmarks without snapshots\n        bookmark = self.setup_bookmark()\n        bookmarks_without_snapshots.append(bookmark)\n\n        bookmark = self.setup_bookmark()\n        self.setup_asset(\n            bookmark=bookmark,\n            asset_type=BookmarkAsset.TYPE_SNAPSHOT,\n            status=BookmarkAsset.STATUS_FAILURE,\n        )\n        bookmarks_without_snapshots.append(bookmark)\n\n        bookmark = self.setup_bookmark()\n        self.setup_asset(\n            bookmark=bookmark,\n            asset_type=\"some_other_type\",\n            status=BookmarkAsset.STATUS_PENDING,\n        )\n        bookmarks_without_snapshots.append(bookmark)\n\n        bookmark = self.setup_bookmark()\n        self.setup_asset(\n            bookmark=bookmark,\n            asset_type=\"some_other_type\",\n            status=BookmarkAsset.STATUS_COMPLETE,\n        )\n        bookmarks_without_snapshots.append(bookmark)\n\n        initial_assets = list(BookmarkAsset.objects.all())\n        initial_assets_count = len(initial_assets)\n        initial_asset_ids = [asset.id for asset in initial_assets]\n        count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())\n\n        self.assertEqual(count, 4)\n        self.assertEqual(BookmarkAsset.objects.count(), initial_assets_count + count)\n\n        for bookmark in bookmarks_without_snapshots:\n            new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(\n                id__in=initial_asset_ids\n            )\n            self.assertEqual(new_assets.count(), 1)\n\n        for bookmark in bookmarks_with_snapshots:\n            new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(\n                id__in=initial_asset_ids\n            )\n            self.assertEqual(new_assets.count(), 0)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_missing_html_snapshots_respects_current_user(self):\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n\n        other_user = self.setup_user()\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n\n        count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())\n\n        self.assertEqual(count, 3)\n        self.assertEqual(BookmarkAsset.objects.count(), count)\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)\n    def test_refresh_metadata_task_not_called_when_background_tasks_disabled(self):\n        bookmark = self.setup_bookmark()\n        with mock.patch(\n            \"bookmarks.services.tasks._refresh_metadata_task\"\n        ) as mock_refresh_metadata_task:\n            tasks.refresh_metadata(bookmark)\n            mock_refresh_metadata_task.assert_not_called()\n\n    @override_settings(LD_DISABLE_BACKGROUND_TASKS=False)\n    def test_refresh_metadata_task_called_when_background_tasks_enabled(self):\n        bookmark = self.setup_bookmark()\n        with mock.patch(\n            \"bookmarks.services.tasks._refresh_metadata_task\"\n        ) as mock_refresh_metadata_task:\n            tasks.refresh_metadata(bookmark)\n            mock_refresh_metadata_task.assert_called_once()\n\n    def test_refresh_metadata_task_should_handle_missing_bookmark(self):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_website_metadata\"\n        ) as mock_load_website_metadata:\n            tasks._refresh_metadata_task(123)\n\n            mock_load_website_metadata.assert_not_called()\n\n    def test_refresh_metadata_updates_title_description(self):\n        bookmark = self.setup_bookmark(\n            title=\"Initial title\",\n            description=\"Initial description\",\n        )\n        mock_website_metadata = WebsiteMetadata(\n            url=bookmark.url,\n            title=\"New title\",\n            description=\"New description\",\n            preview_image=None,\n        )\n\n        with mock.patch(\n            \"bookmarks.services.tasks.load_website_metadata\"\n        ) as mock_load_website_metadata:\n            mock_load_website_metadata.return_value = mock_website_metadata\n\n            tasks.refresh_metadata(bookmark)\n\n            bookmark.refresh_from_db()\n            self.assertEqual(bookmark.title, \"New title\")\n            self.assertEqual(bookmark.description, \"New description\")\n"
  },
  {
    "path": "bookmarks/tests/test_bundles_api.py",
    "content": "from django.urls import reverse\nfrom rest_framework import status\n\nfrom bookmarks.models import BookmarkBundle\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase\n\n\nclass BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):\n    def assertBundle(self, bundle: BookmarkBundle, data: dict):\n        self.assertEqual(bundle.id, data[\"id\"])\n        self.assertEqual(bundle.name, data[\"name\"])\n        self.assertEqual(bundle.search, data[\"search\"])\n        self.assertEqual(bundle.any_tags, data[\"any_tags\"])\n        self.assertEqual(bundle.all_tags, data[\"all_tags\"])\n        self.assertEqual(bundle.excluded_tags, data[\"excluded_tags\"])\n        self.assertEqual(bundle.filter_unread, data[\"filter_unread\"])\n        self.assertEqual(bundle.filter_shared, data[\"filter_shared\"])\n        self.assertEqual(bundle.order, data[\"order\"])\n        self.assertEqual(\n            bundle.date_created.isoformat().replace(\"+00:00\", \"Z\"), data[\"date_created\"]\n        )\n        self.assertEqual(\n            bundle.date_modified.isoformat().replace(\"+00:00\", \"Z\"),\n            data[\"date_modified\"],\n        )\n\n    def test_bundle_list(self):\n        self.authenticate()\n\n        bundles = [\n            self.setup_bundle(name=\"Bundle 1\", order=0),\n            self.setup_bundle(name=\"Bundle 2\", order=1),\n            self.setup_bundle(name=\"Bundle 3\", order=2),\n        ]\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertEqual(len(response.data[\"results\"]), 3)\n        self.assertBundle(bundles[0], response.data[\"results\"][0])\n        self.assertBundle(bundles[1], response.data[\"results\"][1])\n        self.assertBundle(bundles[2], response.data[\"results\"][2])\n\n    def test_bundle_list_only_returns_own_bundles(self):\n        self.authenticate()\n\n        user_bundles = [\n            self.setup_bundle(name=\"User Bundle 1\"),\n            self.setup_bundle(name=\"User Bundle 2\"),\n        ]\n\n        other_user = self.setup_user()\n        self.setup_bundle(name=\"Other User Bundle 1\", user=other_user)\n        self.setup_bundle(name=\"Other User Bundle 2\", user=other_user)\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertEqual(len(response.data[\"results\"]), 2)\n        self.assertBundle(user_bundles[0], response.data[\"results\"][0])\n        self.assertBundle(user_bundles[1], response.data[\"results\"][1])\n\n    def test_bundle_list_requires_authentication(self):\n        url = reverse(\"linkding:bundle-list\")\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def test_bundle_detail(self):\n        self.authenticate()\n\n        bundle = self.setup_bundle(\n            name=\"Test Bundle\",\n            search=\"test search\",\n            any_tags=\"tag1 tag2\",\n            all_tags=\"required-tag\",\n            excluded_tags=\"excluded-tag\",\n            filter_unread=BookmarkBundle.FILTER_STATE_YES,\n            filter_shared=BookmarkBundle.FILTER_STATE_NO,\n            order=5,\n        )\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertBundle(bundle, response.data)\n\n    def test_bundle_detail_only_returns_own_bundles(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        other_bundle = self.setup_bundle(name=\"Other User Bundle\", user=other_user)\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": other_bundle.id})\n        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_bundle_detail_requires_authentication(self):\n        bundle = self.setup_bundle()\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def test_create_bundle(self):\n        self.authenticate()\n\n        bundle_data = {\n            \"name\": \"New Bundle\",\n            \"search\": \"test search\",\n            \"any_tags\": \"tag1 tag2\",\n            \"all_tags\": \"required-tag\",\n            \"excluded_tags\": \"excluded-tag\",\n            \"filter_unread\": BookmarkBundle.FILTER_STATE_YES,\n            \"filter_shared\": BookmarkBundle.FILTER_STATE_NO,\n        }\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.post(\n            url, bundle_data, expected_status_code=status.HTTP_201_CREATED\n        )\n\n        bundle = BookmarkBundle.objects.get(id=response.data[\"id\"])\n        self.assertEqual(bundle.name, bundle_data[\"name\"])\n        self.assertEqual(bundle.search, bundle_data[\"search\"])\n        self.assertEqual(bundle.any_tags, bundle_data[\"any_tags\"])\n        self.assertEqual(bundle.all_tags, bundle_data[\"all_tags\"])\n        self.assertEqual(bundle.excluded_tags, bundle_data[\"excluded_tags\"])\n        self.assertEqual(bundle.filter_unread, bundle_data[\"filter_unread\"])\n        self.assertEqual(bundle.filter_shared, bundle_data[\"filter_shared\"])\n        self.assertEqual(bundle.owner, self.user)\n        self.assertEqual(bundle.order, 0)\n\n        self.assertBundle(bundle, response.data)\n\n    def test_create_bundle_auto_increments_order(self):\n        self.authenticate()\n\n        self.setup_bundle(name=\"Existing Bundle\", order=2)\n\n        bundle_data = {\"name\": \"New Bundle\", \"search\": \"test search\"}\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.post(\n            url, bundle_data, expected_status_code=status.HTTP_201_CREATED\n        )\n\n        bundle = BookmarkBundle.objects.get(id=response.data[\"id\"])\n        self.assertEqual(bundle.order, 3)\n\n    def test_create_bundle_with_custom_order(self):\n        self.authenticate()\n\n        bundle_data = {\"name\": \"New Bundle\", \"order\": 10}\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.post(\n            url, bundle_data, expected_status_code=status.HTTP_201_CREATED\n        )\n\n        bundle = BookmarkBundle.objects.get(id=response.data[\"id\"])\n        self.assertEqual(bundle.order, 10)\n\n    def test_create_bundle_requires_name(self):\n        self.authenticate()\n\n        bundle_data = {\"search\": \"test search\"}\n\n        url = reverse(\"linkding:bundle-list\")\n        self.post(url, bundle_data, expected_status_code=status.HTTP_400_BAD_REQUEST)\n\n    def test_create_bundle_fields_can_be_empty(self):\n        self.authenticate()\n\n        bundle_data = {\n            \"name\": \"Minimal Bundle\",\n            \"search\": \"\",\n            \"any_tags\": \"\",\n            \"all_tags\": \"\",\n            \"excluded_tags\": \"\",\n        }\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.post(\n            url, bundle_data, expected_status_code=status.HTTP_201_CREATED\n        )\n\n        bundle = BookmarkBundle.objects.get(id=response.data[\"id\"])\n        self.assertEqual(bundle.name, \"Minimal Bundle\")\n        self.assertEqual(bundle.search, \"\")\n        self.assertEqual(bundle.any_tags, \"\")\n        self.assertEqual(bundle.all_tags, \"\")\n        self.assertEqual(bundle.excluded_tags, \"\")\n\n    def test_create_bundle_requires_authentication(self):\n        bundle_data = {\"name\": \"New Bundle\"}\n\n        url = reverse(\"linkding:bundle-list\")\n        self.post(url, bundle_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def test_update_bundle_put(self):\n        self.authenticate()\n\n        bundle = self.setup_bundle(\n            name=\"Original Bundle\",\n            search=\"original search\",\n            any_tags=\"original-tag\",\n            order=1,\n        )\n\n        updated_data = {\n            \"name\": \"Updated Bundle\",\n            \"search\": \"updated search\",\n            \"any_tags\": \"updated-tag1 updated-tag2\",\n            \"all_tags\": \"required-updated-tag\",\n            \"excluded_tags\": \"excluded-updated-tag\",\n            \"filter_unread\": BookmarkBundle.FILTER_STATE_YES,\n            \"filter_shared\": BookmarkBundle.FILTER_STATE_NO,\n            \"order\": 5,\n        }\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        response = self.put(url, updated_data, expected_status_code=status.HTTP_200_OK)\n\n        bundle.refresh_from_db()\n        self.assertEqual(bundle.name, updated_data[\"name\"])\n        self.assertEqual(bundle.search, updated_data[\"search\"])\n        self.assertEqual(bundle.any_tags, updated_data[\"any_tags\"])\n        self.assertEqual(bundle.all_tags, updated_data[\"all_tags\"])\n        self.assertEqual(bundle.excluded_tags, updated_data[\"excluded_tags\"])\n        self.assertEqual(bundle.filter_unread, updated_data[\"filter_unread\"])\n        self.assertEqual(bundle.filter_shared, updated_data[\"filter_shared\"])\n        self.assertEqual(bundle.order, updated_data[\"order\"])\n\n        self.assertBundle(bundle, response.data)\n\n    def test_update_bundle_patch(self):\n        self.authenticate()\n\n        bundle = self.setup_bundle(\n            name=\"Original Bundle\", search=\"original search\", any_tags=\"original-tag\"\n        )\n\n        updated_data = {\n            \"name\": \"Partially Updated Bundle\",\n            \"search\": \"partially updated search\",\n        }\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        response = self.patch(\n            url, updated_data, expected_status_code=status.HTTP_200_OK\n        )\n\n        bundle.refresh_from_db()\n        self.assertEqual(bundle.name, updated_data[\"name\"])\n        self.assertEqual(bundle.search, updated_data[\"search\"])\n        self.assertEqual(bundle.any_tags, \"original-tag\")  # Should remain unchanged\n\n        self.assertBundle(bundle, response.data)\n\n    def test_update_bundle_only_allows_own_bundles(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        other_bundle = self.setup_bundle(name=\"Other User Bundle\", user=other_user)\n\n        updated_data = {\"name\": \"Updated Bundle\"}\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": other_bundle.id})\n        self.put(url, updated_data, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n    def test_update_bundle_requires_authentication(self):\n        bundle = self.setup_bundle()\n        updated_data = {\"name\": \"Updated Bundle\"}\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        self.put(url, updated_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n    def test_delete_bundle(self):\n        self.authenticate()\n\n        bundle = self.setup_bundle(name=\"Bundle to Delete\")\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n\n        self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())\n\n    def test_delete_bundle_updates_order(self):\n        self.authenticate()\n\n        bundle1 = self.setup_bundle(name=\"Bundle 1\", order=0)\n        bundle2 = self.setup_bundle(name=\"Bundle 2\", order=1)\n        bundle3 = self.setup_bundle(name=\"Bundle 3\", order=2)\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle2.id})\n        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)\n\n        self.assertFalse(BookmarkBundle.objects.filter(id=bundle2.id).exists())\n\n        # Check that the remaining bundles have updated orders\n        bundle1.refresh_from_db()\n        bundle3.refresh_from_db()\n        self.assertEqual(bundle1.order, 0)\n        self.assertEqual(bundle3.order, 1)\n\n    def test_delete_bundle_only_allows_own_bundles(self):\n        self.authenticate()\n\n        other_user = self.setup_user()\n        other_bundle = self.setup_bundle(name=\"Other User Bundle\", user=other_user)\n\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": other_bundle.id})\n        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)\n\n        self.assertTrue(BookmarkBundle.objects.filter(id=other_bundle.id).exists())\n\n    def test_delete_bundle_requires_authentication(self):\n        bundle = self.setup_bundle()\n        url = reverse(\"linkding:bundle-detail\", kwargs={\"pk\": bundle.id})\n        self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)\n\n        self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())\n\n    def test_bundles_ordered_by_order_field(self):\n        self.authenticate()\n\n        self.setup_bundle(name=\"Third Bundle\", order=2)\n        self.setup_bundle(name=\"First Bundle\", order=0)\n        self.setup_bundle(name=\"Second Bundle\", order=1)\n\n        url = reverse(\"linkding:bundle-list\")\n        response = self.get(url, expected_status_code=status.HTTP_200_OK)\n\n        self.assertEqual(len(response.data[\"results\"]), 3)\n        self.assertEqual(response.data[\"results\"][0][\"name\"], \"First Bundle\")\n        self.assertEqual(response.data[\"results\"][1][\"name\"], \"Second Bundle\")\n        self.assertEqual(response.data[\"results\"][2][\"name\"], \"Third Bundle\")\n"
  },
  {
    "path": "bookmarks/tests/test_bundles_edit_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkBundle\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def create_form_data(self, overrides=None):\n        if overrides is None:\n            overrides = {}\n        form_data = {\n            \"name\": \"Test Bundle\",\n            \"search\": \"test search\",\n            \"any_tags\": \"tag1 tag2\",\n            \"all_tags\": \"required-tag\",\n            \"excluded_tags\": \"excluded-tag\",\n            \"filter_unread\": BookmarkBundle.FILTER_STATE_YES,\n            \"filter_shared\": BookmarkBundle.FILTER_STATE_NO,\n        }\n        return {**form_data, **overrides}\n\n    def test_should_edit_bundle(self):\n        bundle = self.setup_bundle()\n\n        updated_data = self.create_form_data()\n\n        response = self.client.post(\n            reverse(\"linkding:bundles.edit\", args=[bundle.id]), updated_data\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:bundles.index\"))\n\n        bundle.refresh_from_db()\n        self.assertEqual(bundle.name, updated_data[\"name\"])\n        self.assertEqual(bundle.search, updated_data[\"search\"])\n        self.assertEqual(bundle.any_tags, updated_data[\"any_tags\"])\n        self.assertEqual(bundle.all_tags, updated_data[\"all_tags\"])\n        self.assertEqual(bundle.excluded_tags, updated_data[\"excluded_tags\"])\n        self.assertEqual(bundle.filter_unread, updated_data[\"filter_unread\"])\n        self.assertEqual(bundle.filter_shared, updated_data[\"filter_shared\"])\n\n    def test_should_render_edit_form_with_prefilled_fields(self):\n        bundle = self.setup_bundle(\n            name=\"Test Bundle\",\n            search=\"test search terms\",\n            any_tags=\"tag1 tag2 tag3\",\n            all_tags=\"required-tag all-tag\",\n            excluded_tags=\"excluded-tag banned-tag\",\n            filter_unread=BookmarkBundle.FILTER_STATE_YES,\n            filter_shared=BookmarkBundle.FILTER_STATE_NO,\n        )\n\n        response = self.client.get(reverse(\"linkding:bundles.edit\", args=[bundle.id]))\n\n        self.assertEqual(response.status_code, 200)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            f\"\"\"\n                <input type=\"text\" name=\"name\" value=\"{bundle.name}\"\n                autocomplete=\"off\" class=\"form-input\"\n                maxlength=\"256\" aria-invalid=\"false\" required id=\"id_name\">\n            \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n                <input type=\"text\" name=\"search\" value=\"{bundle.search}\"\n                autocomplete=\"off\" class=\"form-input\"\n                maxlength=\"256\" aria-describedby=\"id_search_help\" id=\"id_search\">\n            \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n                <ld-tag-autocomplete input-name=\"any_tags\" input-value=\"{bundle.any_tags}\"\n                input-aria-describedby=\"id_any_tags_help\" input-id=\"id_any_tags\">\n            \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n                <ld-tag-autocomplete input-name=\"all_tags\" input-value=\"{bundle.all_tags}\"\n                input-aria-describedby=\"id_all_tags_help\" input-id=\"id_all_tags\">\n            \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            f\"\"\"\n                <ld-tag-autocomplete input-name=\"excluded_tags\" input-value=\"{bundle.excluded_tags}\"\n                input-aria-describedby=\"id_excluded_tags_help\" input-id=\"id_excluded_tags\">\n            \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n                <select name=\"filter_unread\" class=\"form-select\"\n                aria-describedby=\"id_filter_unread_help\" id=\"id_filter_unread\">\n                    <option value=\"off\">All</option>\n                    <option value=\"yes\" selected>Unread</option>\n                    <option value=\"no\">Read</option>\n                </select>\n            \"\"\",\n            html,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n                <select name=\"filter_shared\" class=\"form-select\"\n                aria-describedby=\"id_filter_shared_help\" id=\"id_filter_shared\">\n                    <option value=\"off\">All</option>\n                    <option value=\"yes\">Shared</option>\n                    <option value=\"no\" selected>Unshared</option>\n                </select>\n            \"\"\",\n            html,\n        )\n\n    def test_should_return_422_with_invalid_form(self):\n        bundle = self.setup_bundle(\n            name=\"Test Bundle\",\n            search=\"test search\",\n            any_tags=\"tag1 tag2\",\n            all_tags=\"required-tag\",\n            excluded_tags=\"excluded-tag\",\n        )\n\n        invalid_data = self.create_form_data({\"name\": \"\"})\n\n        response = self.client.post(\n            reverse(\"linkding:bundles.edit\", args=[bundle.id]), invalid_data\n        )\n\n        self.assertEqual(response.status_code, 422)\n\n    def test_should_not_allow_editing_other_users_bundles(self):\n        other_user = self.setup_user(name=\"otheruser\")\n        other_users_bundle = self.setup_bundle(user=other_user)\n\n        response = self.client.get(\n            reverse(\"linkding:bundles.edit\", args=[other_users_bundle.id])\n        )\n        self.assertEqual(response.status_code, 404)\n\n        updated_data = self.create_form_data()\n        response = self.client.post(\n            reverse(\"linkding:bundles.edit\", args=[other_users_bundle.id]), updated_data\n        )\n        self.assertEqual(response.status_code, 404)\n\n    def test_should_show_correct_preview(self):\n        bundle_tag = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[bundle_tag])\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n        bundle = self.setup_bundle(name=\"Test Bundle\", all_tags=bundle_tag.name)\n\n        response = self.client.get(reverse(\"linkding:bundles.edit\", args=[bundle.id]))\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, bookmark1.title)\n        self.assertNotContains(response, bookmark2.title)\n        self.assertNotContains(response, bookmark3.title)\n\n    def test_should_show_correct_preview_after_posting_invalid_data(self):\n        initial_tag = self.setup_tag(name=\"initial-tag\")\n        updated_tag = self.setup_tag(name=\"updated-tag\")\n        bookmark1 = self.setup_bookmark(tags=[initial_tag])\n        bookmark2 = self.setup_bookmark(tags=[updated_tag])\n        bookmark3 = self.setup_bookmark()\n        bundle = self.setup_bundle(name=\"Test Bundle\", all_tags=initial_tag.name)\n\n        form_data = {\n            \"name\": \"\",\n            \"search\": \"\",\n            \"any_tags\": \"\",\n            \"all_tags\": updated_tag.name,\n            \"excluded_tags\": \"\",\n        }\n        response = self.client.post(\n            reverse(\"linkding:bundles.edit\", args=[bundle.id]), form_data\n        )\n        self.assertIn(\n            \"Found 1 bookmarks matching this bundle\", response.content.decode()\n        )\n        self.assertNotIn(bookmark1.title, response.content.decode())\n        self.assertIn(bookmark2.title, response.content.decode())\n        self.assertNotIn(bookmark3.title, response.content.decode())\n"
  },
  {
    "path": "bookmarks/tests/test_bundles_index_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkBundle\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\nfrom bookmarks.utils import app_version\n\n\nclass BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def test_render_bundle_list(self):\n        bundles = [\n            self.setup_bundle(name=\"Bundle 1\"),\n            self.setup_bundle(name=\"Bundle 2\"),\n            self.setup_bundle(name=\"Bundle 3\"),\n        ]\n\n        response = self.client.get(reverse(\"linkding:bundles.index\"))\n\n        self.assertEqual(response.status_code, 200)\n        html = response.content.decode()\n\n        for bundle in bundles:\n            expected_list_item = f\"\"\"\n            <tr data-bundle-id=\"{bundle.id}\" draggable=\"true\">\n              <td>\n                <div class=\"d-flex align-center\">\n                  <svg class=\"text-secondary mr-1\" width=\"16\" height=\"16\">\n                    <use href=\"/static/icons.svg?v={app_version}#drag\"></use>\n                  </svg>\n                  <span>{bundle.name}</span>\n                </div>\n              </td>\n              <td class=\"actions\">\n                <a class=\"btn btn-link\" href=\"{reverse(\"linkding:bundles.edit\", args=[bundle.id])}\">Edit</a>\n                <button data-confirm type=\"submit\" name=\"remove_bundle\" value=\"{bundle.id}\" class=\"btn btn-link\">Remove</button>\n              </td>\n            </tr>\n            \"\"\"\n\n            self.assertInHTML(expected_list_item, html)\n\n    def test_renders_user_owned_bundles_only(self):\n        user_bundle = self.setup_bundle(name=\"User Bundle\")\n\n        other_user = self.setup_user(name=\"otheruser\")\n        other_user_bundle = self.setup_bundle(name=\"Other User Bundle\", user=other_user)\n\n        response = self.client.get(reverse(\"linkding:bundles.index\"))\n\n        self.assertEqual(response.status_code, 200)\n        html = response.content.decode()\n\n        self.assertInHTML(f\"<span>{user_bundle.name}</span>\", html)\n        self.assertNotIn(other_user_bundle.name, html)\n\n    def test_empty_state(self):\n        response = self.client.get(reverse(\"linkding:bundles.index\"))\n\n        self.assertEqual(response.status_code, 200)\n        html = response.content.decode()\n\n        self.assertInHTML('<p class=\"empty-title h5\">You have no bundles yet</p>', html)\n        self.assertInHTML(\n            '<p class=\"empty-subtitle\">Create your first bundle to get started</p>',\n            html,\n        )\n\n    def test_add_new_button(self):\n        response = self.client.get(reverse(\"linkding:bundles.index\"))\n\n        self.assertEqual(response.status_code, 200)\n        html = response.content.decode()\n\n        self.assertInHTML(\n            f'<a href=\"{reverse(\"linkding:bundles.new\")}\" class=\"btn\">Add bundle</a>',\n            html,\n        )\n\n    def test_remove_bundle(self):\n        bundle = self.setup_bundle(name=\"Test Bundle\")\n\n        response = self.client.post(\n            reverse(\"linkding:bundles.action\"),\n            {\"remove_bundle\": str(bundle.id)},\n        )\n\n        self.assertEqual(response.status_code, 302)\n        self.assertRedirects(response, reverse(\"linkding:bundles.index\"))\n\n        self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())\n\n    def test_remove_bundle_updates_order(self):\n        bundle1 = self.setup_bundle(name=\"Bundle 1\", order=0)\n        bundle2 = self.setup_bundle(name=\"Bundle 2\", order=1)\n        bundle3 = self.setup_bundle(name=\"Bundle 3\", order=2)\n\n        self.client.post(\n            reverse(\"linkding:bundles.action\"),\n            {\"remove_bundle\": str(bundle2.id)},\n        )\n\n        self.assertBundleOrder([bundle1, bundle3])\n\n    def test_remove_other_user_bundle(self):\n        other_user = self.setup_user(name=\"otheruser\")\n        other_user_bundle = self.setup_bundle(name=\"Other User Bundle\", user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:bundles.action\"),\n            {\"remove_bundle\": str(other_user_bundle.id)},\n        )\n\n        self.assertEqual(response.status_code, 404)\n        self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())\n\n    def assertBundleOrder(self, expected_bundles, user=None):\n        if user is None:\n            user = self.user\n        actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by(\"order\")\n        self.assertEqual(len(actual_bundles), len(expected_bundles))\n        for i, bundle in enumerate(expected_bundles):\n            self.assertEqual(actual_bundles[i].id, bundle.id)\n            self.assertEqual(actual_bundles[i].order, i)\n\n    def move_bundle(self, bundle: BookmarkBundle, position: int):\n        return self.client.post(\n            reverse(\"linkding:bundles.action\"),\n            {\"move_bundle\": str(bundle.id), \"move_position\": position},\n        )\n\n    def test_move_bundle(self):\n        bundle1 = self.setup_bundle(name=\"Bundle 1\", order=0)\n        bundle2 = self.setup_bundle(name=\"Bundle 2\", order=1)\n        bundle3 = self.setup_bundle(name=\"Bundle 3\", order=2)\n\n        self.move_bundle(bundle1, 1)\n        self.assertBundleOrder([bundle2, bundle1, bundle3])\n\n        self.move_bundle(bundle1, 0)\n        self.assertBundleOrder([bundle1, bundle2, bundle3])\n\n        self.move_bundle(bundle1, 2)\n        self.assertBundleOrder([bundle2, bundle3, bundle1])\n\n        self.move_bundle(bundle1, 2)\n        self.assertBundleOrder([bundle2, bundle3, bundle1])\n\n    def test_move_bundle_response(self):\n        bundle1 = self.setup_bundle(name=\"Bundle 1\", order=0)\n        self.setup_bundle(name=\"Bundle 2\", order=1)\n\n        response = self.move_bundle(bundle1, 1)\n\n        self.assertEqual(response.status_code, 302)\n        self.assertRedirects(response, reverse(\"linkding:bundles.index\"))\n\n    def test_can_only_move_user_owned_bundles(self):\n        other_user = self.setup_user()\n        other_user_bundle1 = self.setup_bundle(user=other_user)\n        self.setup_bundle(user=other_user)\n\n        response = self.move_bundle(other_user_bundle1, 1)\n        self.assertEqual(response.status_code, 404)\n\n    def test_move_bundle_only_affects_own_bundles(self):\n        user_bundle1 = self.setup_bundle(name=\"User Bundle 1\", order=0)\n        user_bundle2 = self.setup_bundle(name=\"User Bundle 2\", order=1)\n\n        other_user = self.setup_user(name=\"otheruser\")\n        other_user_bundle = self.setup_bundle(\n            name=\"Other User Bundle\", user=other_user, order=0\n        )\n\n        # Move user bundle\n        self.move_bundle(user_bundle1, 1)\n        self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)\n\n        # Check that other user's bundle is unaffected\n        self.assertBundleOrder([other_user_bundle], user=other_user)\n\n    def test_remove_non_existing_bundle(self):\n        non_existent_id = 99999\n\n        response = self.client.post(\n            reverse(\"linkding:bundles.action\"),\n            {\"remove_bundle\": str(non_existent_id)},\n        )\n\n        self.assertEqual(response.status_code, 404)\n\n    def test_post_without_action(self):\n        bundle = self.setup_bundle(name=\"Test Bundle\")\n\n        response = self.client.post(reverse(\"linkding:bundles.action\"), {})\n\n        self.assertEqual(response.status_code, 302)\n        self.assertRedirects(response, reverse(\"linkding:bundles.index\"))\n\n        self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())\n"
  },
  {
    "path": "bookmarks/tests/test_bundles_new_view.py",
    "content": "from urllib.parse import urlencode\n\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkBundle\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def create_form_data(self, overrides=None):\n        if overrides is None:\n            overrides = {}\n        form_data = {\n            \"name\": \"Test Bundle\",\n            \"search\": \"test search\",\n            \"any_tags\": \"tag1 tag2\",\n            \"all_tags\": \"required-tag\",\n            \"excluded_tags\": \"excluded-tag\",\n            \"filter_unread\": BookmarkBundle.FILTER_STATE_YES,\n            \"filter_shared\": BookmarkBundle.FILTER_STATE_NO,\n        }\n        return {**form_data, **overrides}\n\n    def test_should_create_new_bundle(self):\n        form_data = self.create_form_data()\n\n        response = self.client.post(reverse(\"linkding:bundles.new\"), form_data)\n\n        self.assertEqual(BookmarkBundle.objects.count(), 1)\n\n        bundle = BookmarkBundle.objects.first()\n        self.assertEqual(bundle.owner, self.user)\n        self.assertEqual(bundle.name, form_data[\"name\"])\n        self.assertEqual(bundle.search, form_data[\"search\"])\n        self.assertEqual(bundle.any_tags, form_data[\"any_tags\"])\n        self.assertEqual(bundle.all_tags, form_data[\"all_tags\"])\n        self.assertEqual(bundle.excluded_tags, form_data[\"excluded_tags\"])\n        self.assertEqual(bundle.filter_unread, form_data[\"filter_unread\"])\n        self.assertEqual(bundle.filter_shared, form_data[\"filter_shared\"])\n\n        self.assertRedirects(response, reverse(\"linkding:bundles.index\"))\n\n    def test_should_increment_order_for_subsequent_bundles(self):\n        # Create first bundle\n        form_data_1 = self.create_form_data({\"name\": \"Bundle 1\"})\n        self.client.post(reverse(\"linkding:bundles.new\"), form_data_1)\n        bundle1 = BookmarkBundle.objects.get(name=\"Bundle 1\")\n        self.assertEqual(bundle1.order, 0)\n\n        # Create second bundle\n        form_data_2 = self.create_form_data({\"name\": \"Bundle 2\"})\n        self.client.post(reverse(\"linkding:bundles.new\"), form_data_2)\n        bundle2 = BookmarkBundle.objects.get(name=\"Bundle 2\")\n        self.assertEqual(bundle2.order, 1)\n\n        # Create another bundle with a higher order\n        self.setup_bundle(order=5)\n\n        # Create third bundle\n        form_data_3 = self.create_form_data({\"name\": \"Bundle 3\"})\n        self.client.post(reverse(\"linkding:bundles.new\"), form_data_3)\n        bundle3 = BookmarkBundle.objects.get(name=\"Bundle 3\")\n        self.assertEqual(bundle3.order, 6)\n\n    def test_incrementing_order_ignores_other_user_bookmark(self):\n        other_user = self.setup_user()\n        self.setup_bundle(user=other_user, order=10)\n\n        form_data = self.create_form_data({\"name\": \"Bundle 1\"})\n        self.client.post(reverse(\"linkding:bundles.new\"), form_data)\n        bundle1 = BookmarkBundle.objects.get(name=\"Bundle 1\")\n        self.assertEqual(bundle1.order, 0)\n\n    def test_should_return_422_with_invalid_form(self):\n        form_data = self.create_form_data({\"name\": \"\"})\n        response = self.client.post(reverse(\"linkding:bundles.new\"), form_data)\n        self.assertEqual(response.status_code, 422)\n\n    def test_should_prefill_form_from_search_query_parameters(self):\n        query = \"machine learning #python #ai\"\n        url = reverse(\"linkding:bundles.new\") + \"?\" + urlencode({\"q\": query})\n        response = self.client.get(url)\n\n        soup = self.make_soup(response.content.decode())\n        search_field = soup.select_one('input[name=\"search\"]')\n        all_tags_field = soup.select_one('ld-tag-autocomplete[input-name=\"all_tags\"]')\n\n        self.assertEqual(search_field.get(\"value\"), \"machine learning\")\n        self.assertEqual(all_tags_field.get(\"input-value\"), \"python ai\")\n\n    def test_should_ignore_special_search_commands(self):\n        query = \"python tutorial !untagged !unread\"\n        url = reverse(\"linkding:bundles.new\") + \"?\" + urlencode({\"q\": query})\n        response = self.client.get(url)\n\n        soup = self.make_soup(response.content.decode())\n        search_field = soup.select_one('input[name=\"search\"]')\n        all_tags_field = soup.select_one('ld-tag-autocomplete[input-name=\"all_tags\"]')\n\n        self.assertEqual(search_field.get(\"value\"), \"python tutorial\")\n        self.assertEqual(all_tags_field.get(\"input-value\"), \"\")\n\n    def test_should_not_prefill_when_no_query_parameter(self):\n        response = self.client.get(reverse(\"linkding:bundles.new\"))\n\n        soup = self.make_soup(response.content.decode())\n        search_field = soup.select_one('input[name=\"search\"]')\n        all_tags_field = soup.select_one('ld-tag-autocomplete[input-name=\"all_tags\"]')\n\n        self.assertIsNone(search_field.get(\"value\"))\n        self.assertEqual(all_tags_field.get(\"input-value\"), \"\")\n\n    def test_should_not_prefill_when_editing_existing_bundle(self):\n        bundle = self.setup_bundle(\n            name=\"Existing Bundle\", search=\"Tutorial\", all_tags=\"java spring\"\n        )\n\n        query = \"machine learning #python #ai\"\n        url = (\n            reverse(\"linkding:bundles.edit\", args=[bundle.id])\n            + \"?\"\n            + urlencode({\"q\": query})\n        )\n        response = self.client.get(url)\n\n        soup = self.make_soup(response.content.decode())\n        search_field = soup.select_one('input[name=\"search\"]')\n        all_tags_field = soup.select_one('ld-tag-autocomplete[input-name=\"all_tags\"]')\n\n        self.assertEqual(search_field.get(\"value\"), \"Tutorial\")\n        self.assertEqual(all_tags_field.get(\"input-value\"), \"java spring\")\n\n    def test_should_show_correct_preview_with_prefilled_values(self):\n        bundle_tag = self.setup_tag()\n        bookmark1 = self.setup_bookmark(tags=[bundle_tag])\n        bookmark2 = self.setup_bookmark()\n        bookmark3 = self.setup_bookmark()\n\n        query = \"#\" + bundle_tag.name\n        url = reverse(\"linkding:bundles.new\") + \"?\" + urlencode({\"q\": query})\n        response = self.client.get(url)\n\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, bookmark1.title)\n        self.assertNotContains(response, bookmark2.title)\n        self.assertNotContains(response, bookmark3.title)\n"
  },
  {
    "path": "bookmarks/tests/test_bundles_preview_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import BookmarkBundle\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def test_preview_empty_bundle(self):\n        bookmark1 = self.setup_bookmark(title=\"Test Bookmark 1\")\n        bookmark2 = self.setup_bookmark(title=\"Test Bookmark 2\")\n\n        response = self.client.get(reverse(\"linkding:bundles.preview\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"Found 2 bookmarks matching this bundle\")\n        self.assertContains(response, bookmark1.title)\n        self.assertContains(response, bookmark2.title)\n        self.assertNotContains(response, \"No bookmarks match the current bundle\")\n\n    def test_preview_with_search_terms(self):\n        bookmark1 = self.setup_bookmark(title=\"Python Programming\")\n        bookmark2 = self.setup_bookmark(title=\"JavaScript Tutorial\")\n        bookmark3 = self.setup_bookmark(title=\"Django Framework\")\n\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"), {\"search\": \"python\"}\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, bookmark1.title)\n        self.assertNotContains(response, bookmark2.title)\n        self.assertNotContains(response, bookmark3.title)\n\n    def test_preview_no_matching_bookmarks(self):\n        bookmark = self.setup_bookmark(title=\"Python Guide\")\n\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"), {\"search\": \"nonexistent\"}\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"No bookmarks match the current bundle\")\n        self.assertNotContains(response, bookmark.title)\n\n    def test_preview_renders_bookmark(self):\n        tag = self.setup_tag(name=\"test-tag\")\n        bookmark = self.setup_bookmark(\n            title=\"Test Bookmark\",\n            description=\"Test description\",\n            url=\"https://example.com/test\",\n            tags=[tag],\n        )\n\n        response = self.client.get(reverse(\"linkding:bundles.preview\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, bookmark.title)\n        self.assertContains(response, bookmark.description)\n        self.assertContains(response, bookmark.url)\n        self.assertContains(response, \"#test-tag\")\n\n    def test_preview_renders_bookmark_in_preview_mode(self):\n        tag = self.setup_tag(name=\"test-tag\")\n        self.setup_bookmark(\n            title=\"Test Bookmark\",\n            description=\"Test description\",\n            url=\"https://example.com/test\",\n            tags=[tag],\n        )\n\n        response = self.client.get(reverse(\"linkding:bundles.preview\"))\n        soup = self.make_soup(response.content.decode())\n\n        list_item = soup.select_one(\"ul.bookmark-list > li\")\n        actions = list_item.select(\".actions > *\")\n        self.assertEqual(len(actions), 1)\n\n    def test_preview_ignores_archived_bookmarks(self):\n        active_bookmark = self.setup_bookmark(title=\"Active Bookmark\")\n        archived_bookmark = self.setup_bookmark(\n            title=\"Archived Bookmark\", is_archived=True\n        )\n\n        response = self.client.get(reverse(\"linkding:bundles.preview\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, active_bookmark.title)\n        self.assertNotContains(response, archived_bookmark.title)\n\n    def test_preview_with_filter_unread(self):\n        unread_bookmark = self.setup_bookmark(title=\"Unread Bookmark\", unread=True)\n        read_bookmark = self.setup_bookmark(title=\"Read Bookmark\", unread=False)\n\n        # Filter unread\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"),\n            {\"filter_unread\": BookmarkBundle.FILTER_STATE_YES},\n        )\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, unread_bookmark.title)\n        self.assertNotContains(response, read_bookmark.title)\n\n        # Filter read\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"),\n            {\"filter_unread\": BookmarkBundle.FILTER_STATE_NO},\n        )\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertNotContains(response, unread_bookmark.title)\n        self.assertContains(response, read_bookmark.title)\n\n        # Filter off\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"),\n            {\"filter_unread\": BookmarkBundle.FILTER_STATE_OFF},\n        )\n        self.assertContains(response, \"Found 2 bookmarks matching this bundle\")\n        self.assertContains(response, unread_bookmark.title)\n        self.assertContains(response, read_bookmark.title)\n\n    def test_preview_with_filter_shared(self):\n        shared_bookmark = self.setup_bookmark(title=\"Shared Bookmark\", shared=True)\n        unshared_bookmark = self.setup_bookmark(title=\"Unshared Bookmark\", shared=False)\n\n        # Filter shared\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"),\n            {\"filter_shared\": BookmarkBundle.FILTER_STATE_YES},\n        )\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, shared_bookmark.title)\n        self.assertNotContains(response, unshared_bookmark.title)\n\n        # Filter unshared\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"),\n            {\"filter_shared\": BookmarkBundle.FILTER_STATE_NO},\n        )\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertNotContains(response, shared_bookmark.title)\n        self.assertContains(response, unshared_bookmark.title)\n\n        # Filter off\n        response = self.client.get(\n            reverse(\"linkding:bundles.preview\"),\n            {\"filter_shared\": BookmarkBundle.FILTER_STATE_OFF},\n        )\n        self.assertContains(response, \"Found 2 bookmarks matching this bundle\")\n        self.assertContains(response, shared_bookmark.title)\n        self.assertContains(response, unshared_bookmark.title)\n\n    def test_preview_requires_authentication(self):\n        self.client.logout()\n\n        response = self.client.get(reverse(\"linkding:bundles.preview\"), follow=True)\n\n        self.assertRedirects(\n            response, f\"/login/?next={reverse('linkding:bundles.preview')}\"\n        )\n\n    def test_preview_only_shows_user_bookmarks(self):\n        other_user = self.setup_user()\n        own_bookmark = self.setup_bookmark(title=\"Own Bookmark\")\n        other_bookmark = self.setup_bookmark(title=\"Other Bookmark\", user=other_user)\n\n        response = self.client.get(reverse(\"linkding:bundles.preview\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"Found 1 bookmarks matching this bundle\")\n        self.assertContains(response, own_bookmark.title)\n        self.assertNotContains(response, other_bookmark.title)\n"
  },
  {
    "path": "bookmarks/tests/test_context_path.py",
    "content": "import importlib\n\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\n\n\nclass MockUrlConf:\n    def __init__(self, module):\n        self.urlpatterns = module.urlpatterns\n\n\nclass ContextPathTestCase(TestCase):\n    def setUp(self):\n        self.urls_module = importlib.import_module(\"bookmarks.urls\")\n\n    @override_settings(LD_CONTEXT_PATH=None)\n    def tearDown(self):\n        importlib.reload(self.urls_module)\n\n    @override_settings(LD_CONTEXT_PATH=\"linkding/\")\n    def test_route_with_context_path(self):\n        module = importlib.reload(self.urls_module)\n        # pass mock config instead of actual module to prevent caching the\n        # url config in django.urls.reverse\n        urlconf = MockUrlConf(module)\n        test_cases = [\n            (\"linkding:bookmarks.index\", \"/linkding/bookmarks\"),\n            (\"linkding:bookmark-list\", \"/linkding/api/bookmarks/\"),\n            (\"login\", \"/linkding/login/\"),\n            (\n                \"admin:bookmarks_bookmark_changelist\",\n                \"/linkding/admin/bookmarks/bookmark/\",\n            ),\n        ]\n\n        for url_name, expected_url in test_cases:\n            url = reverse(url_name, urlconf=urlconf)\n            self.assertEqual(expected_url, url)\n\n    @override_settings(LD_CONTEXT_PATH=\"\")\n    def test_route_without_context_path(self):\n        module = importlib.reload(self.urls_module)\n        # pass mock config instead of actual module to prevent caching the\n        # url config in django.urls.reverse\n        urlconf = MockUrlConf(module)\n        test_cases = [\n            (\"linkding:bookmarks.index\", \"/bookmarks\"),\n            (\"linkding:bookmark-list\", \"/api/bookmarks/\"),\n            (\"login\", \"/login/\"),\n            (\"admin:bookmarks_bookmark_changelist\", \"/admin/bookmarks/bookmark/\"),\n        ]\n\n        for url_name, expected_url in test_cases:\n            url = reverse(url_name, urlconf=urlconf)\n            self.assertEqual(expected_url, url)\n"
  },
  {
    "path": "bookmarks/tests/test_create_initial_superuser_command.py",
    "content": "import os\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nfrom bookmarks.management.commands.create_initial_superuser import Command\nfrom bookmarks.models import User\n\n\nclass TestCreateInitialSuperuserCommand(TestCase):\n    @mock.patch.dict(\n        os.environ,\n        {\"LD_SUPERUSER_NAME\": \"john\", \"LD_SUPERUSER_PASSWORD\": \"password123\"},\n    )\n    def test_create_with_password(self):\n        Command().handle()\n\n        self.assertEqual(1, User.objects.count())\n\n        user = User.objects.first()\n        self.assertEqual(\"john\", user.username)\n        self.assertTrue(user.has_usable_password())\n        self.assertTrue(user.check_password(\"password123\"))\n\n    @mock.patch.dict(os.environ, {\"LD_SUPERUSER_NAME\": \"john\"})\n    def test_create_without_password(self):\n        Command().handle()\n\n        self.assertEqual(1, User.objects.count())\n\n        user = User.objects.first()\n        self.assertEqual(\"john\", user.username)\n        self.assertFalse(user.has_usable_password())\n\n    def test_create_without_options(self):\n        Command().handle()\n\n        self.assertEqual(0, User.objects.count())\n\n    @mock.patch.dict(\n        os.environ,\n        {\"LD_SUPERUSER_NAME\": \"john\", \"LD_SUPERUSER_PASSWORD\": \"password123\"},\n    )\n    def test_create_multiple_times(self):\n        Command().handle()\n        Command().handle()\n        Command().handle()\n\n        self.assertEqual(1, User.objects.count())\n"
  },
  {
    "path": "bookmarks/tests/test_custom_css_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass CustomCssViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def test_with_empty_css(self):\n        response = self.client.get(reverse(\"linkding:custom_css\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"Content-Type\"], \"text/css\")\n        self.assertEqual(response.headers[\"Cache-Control\"], \"public, max-age=2592000\")\n        self.assertEqual(response.content.decode(), \"\")\n\n    def test_with_custom_css(self):\n        css = \"body { background-color: red; }\"\n        self.user.profile.custom_css = css\n        self.user.profile.save()\n\n        response = self.client.get(reverse(\"linkding:custom_css\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"Content-Type\"], \"text/css\")\n        self.assertEqual(response.headers[\"Cache-Control\"], \"public, max-age=2592000\")\n        self.assertEqual(response.content.decode(), css)\n"
  },
  {
    "path": "bookmarks/tests/test_exporter.py",
    "content": "from datetime import UTC, datetime\n\nfrom django.test import TestCase\n\nfrom bookmarks.services import exporter\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass ExporterTestCase(TestCase, BookmarkFactoryMixin):\n    def test_export_bookmarks(self):\n        bookmarks = [\n            self.setup_bookmark(\n                url=\"https://example.com/1\",\n                title=\"Title 1\",\n                added=datetime.fromtimestamp(1, UTC),\n                modified=datetime.fromtimestamp(11, UTC),\n                description=\"Example description\",\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/2\",\n                title=\"Title 2\",\n                added=datetime.fromtimestamp(2, UTC),\n                modified=datetime.fromtimestamp(22, UTC),\n                tags=[\n                    self.setup_tag(name=\"tag1\"),\n                    self.setup_tag(name=\"tag2\"),\n                    self.setup_tag(name=\"tag3\"),\n                ],\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/3\",\n                title=\"Title 3\",\n                added=datetime.fromtimestamp(3, UTC),\n                modified=datetime.fromtimestamp(33, UTC),\n                unread=True,\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/4\",\n                title=\"Title 4\",\n                added=datetime.fromtimestamp(4, UTC),\n                modified=datetime.fromtimestamp(44, UTC),\n                shared=True,\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/5\",\n                title=\"Title 5\",\n                added=datetime.fromtimestamp(5, UTC),\n                modified=datetime.fromtimestamp(55, UTC),\n                shared=True,\n                description=\"Example description\",\n                notes=\"Example notes\",\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/6\",\n                title=\"Title 6\",\n                added=datetime.fromtimestamp(6, UTC),\n                modified=datetime.fromtimestamp(66, UTC),\n                shared=True,\n                notes=\"Example notes\",\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/7\",\n                title=\"Title 7\",\n                added=datetime.fromtimestamp(7, UTC),\n                modified=datetime.fromtimestamp(77, UTC),\n                is_archived=True,\n            ),\n            self.setup_bookmark(\n                url=\"https://example.com/8\",\n                title=\"Title 8\",\n                added=datetime.fromtimestamp(8, UTC),\n                modified=datetime.fromtimestamp(88, UTC),\n                tags=[self.setup_tag(name=\"tag4\"), self.setup_tag(name=\"tag5\")],\n                is_archived=True,\n            ),\n        ]\n        html = exporter.export_netscape_html(bookmarks)\n\n        lines = [\n            '<DT><A HREF=\"https://example.com/1\" ADD_DATE=\"1\" LAST_MODIFIED=\"11\" PRIVATE=\"1\" TOREAD=\"0\" TAGS=\"\">Title 1</A>',\n            \"<DD>Example description\",\n            '<DT><A HREF=\"https://example.com/2\" ADD_DATE=\"2\" LAST_MODIFIED=\"22\" PRIVATE=\"1\" TOREAD=\"0\" TAGS=\"tag1,tag2,tag3\">Title 2</A>',\n            '<DT><A HREF=\"https://example.com/3\" ADD_DATE=\"3\" LAST_MODIFIED=\"33\" PRIVATE=\"1\" TOREAD=\"1\" TAGS=\"\">Title 3</A>',\n            '<DT><A HREF=\"https://example.com/4\" ADD_DATE=\"4\" LAST_MODIFIED=\"44\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"\">Title 4</A>',\n            '<DT><A HREF=\"https://example.com/5\" ADD_DATE=\"5\" LAST_MODIFIED=\"55\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"\">Title 5</A>',\n            \"<DD>Example description[linkding-notes]Example notes[/linkding-notes]\",\n            '<DT><A HREF=\"https://example.com/6\" ADD_DATE=\"6\" LAST_MODIFIED=\"66\" PRIVATE=\"0\" TOREAD=\"0\" TAGS=\"\">Title 6</A>',\n            \"<DD>[linkding-notes]Example notes[/linkding-notes]\",\n            '<DT><A HREF=\"https://example.com/7\" ADD_DATE=\"7\" LAST_MODIFIED=\"77\" PRIVATE=\"1\" TOREAD=\"0\" TAGS=\"linkding:bookmarks.archived\">Title 7</A>',\n            '<DT><A HREF=\"https://example.com/8\" ADD_DATE=\"8\" LAST_MODIFIED=\"88\" PRIVATE=\"1\" TOREAD=\"0\" TAGS=\"tag4,tag5,linkding:bookmarks.archived\">Title 8</A>',\n        ]\n        self.assertIn(\"\\n\\r\".join(lines), html)\n\n    def test_escape_html(self):\n        bookmark = self.setup_bookmark(\n            title=\"<style>: The Style Information element\",\n            description=\"The <style> HTML element contains style information for a document, or part of a document.\",\n            notes=\"Interesting notes about the <style> HTML element.\",\n        )\n        html = exporter.export_netscape_html([bookmark])\n\n        self.assertIn(\"&lt;style&gt;: The Style Information element\", html)\n        self.assertIn(\n            \"The &lt;style&gt; HTML element contains style information for a document, or part of a document.\",\n            html,\n        )\n        self.assertIn(\"Interesting notes about the &lt;style&gt; HTML element.\", html)\n\n    def test_handle_empty_values(self):\n        bookmark = self.setup_bookmark()\n        bookmark.title = \"\"\n        bookmark.description = \"\"\n        bookmark.save()\n        exporter.export_netscape_html([bookmark])\n"
  },
  {
    "path": "bookmarks/tests/test_exporter_performance.py",
    "content": "from django.db import connections\nfrom django.db.utils import DEFAULT_DB_ALIAS\nfrom django.test import TestCase\nfrom django.test.utils import CaptureQueriesContext\nfrom django.urls import reverse\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass ExporterPerformanceTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def get_connection(self):\n        return connections[DEFAULT_DB_ALIAS]\n\n    def test_export_max_queries(self):\n        # set up some bookmarks with associated tags\n        num_initial_bookmarks = 10\n        for _i in range(num_initial_bookmarks):\n            self.setup_bookmark(tags=[self.setup_tag()])\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            self.client.get(reverse(\"linkding:settings.export\"), follow=True)\n\n        number_of_queries = context.final_queries\n\n        self.assertLess(number_of_queries, num_initial_bookmarks)\n"
  },
  {
    "path": "bookmarks/tests/test_favicon_loader.py",
    "content": "import io\nimport os.path\nimport tempfile\nimport time\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom django.conf import settings\nfrom django.test import TestCase, override_settings\n\nfrom bookmarks.services import favicon_loader\n\nmock_icon_data = b\"mock_icon\"\n\n\nclass MockStreamingResponse:\n    def __init__(self, data=mock_icon_data, content_type=\"image/png\"):\n        self.chunks = [data]\n        self.headers = {\"Content-Type\": content_type}\n\n    def iter_content(self, **kwargs):\n        return self.chunks\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n\nclass FaviconLoaderTestCase(TestCase):\n    def setUp(self) -> None:\n        self.temp_favicon_folder = tempfile.TemporaryDirectory()\n        self.favicon_folder_override = self.settings(\n            LD_FAVICON_FOLDER=self.temp_favicon_folder.name\n        )\n        self.favicon_folder_override.enable()\n\n    def tearDown(self) -> None:\n        self.temp_favicon_folder.cleanup()\n        self.favicon_folder_override.disable()\n\n    def create_mock_response(self, icon_data=mock_icon_data, content_type=\"image/png\"):\n        mock_response = mock.Mock()\n        mock_response.raw = io.BytesIO(icon_data)\n        return MockStreamingResponse(icon_data, content_type)\n\n    def clear_favicon_folder(self):\n        folder = Path(settings.LD_FAVICON_FOLDER)\n        for file in folder.iterdir():\n            file.unlink()\n\n    def get_icon_path(self, filename):\n        return Path(os.path.join(settings.LD_FAVICON_FOLDER, filename))\n\n    def icon_exists(self, filename):\n        return self.get_icon_path(filename).exists()\n\n    def get_icon_data(self, filename):\n        return self.get_icon_path(filename).read_bytes()\n\n    def count_icons(self):\n        files = os.listdir(settings.LD_FAVICON_FOLDER)\n        return len(files)\n\n    def test_load_favicon(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n            favicon_loader.load_favicon(\"https://example.com\")\n\n            # should create icon file\n            self.assertTrue(self.icon_exists(\"https_example_com.png\"))\n\n            # should store image data\n            self.assertEqual(\n                mock_icon_data, self.get_icon_data(\"https_example_com.png\")\n            )\n\n    def test_load_favicon_creates_folder_if_not_exists(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n\n            folder = Path(settings.LD_FAVICON_FOLDER)\n            folder.rmdir()\n\n            self.assertFalse(folder.exists())\n\n            favicon_loader.load_favicon(\"https://example.com\")\n\n            self.assertTrue(folder.exists())\n\n    def test_load_favicon_creates_single_icon_for_same_base_url(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n            favicon_loader.load_favicon(\"https://example.com\")\n            favicon_loader.load_favicon(\"https://example.com?foo=bar\")\n            favicon_loader.load_favicon(\"https://example.com/foo\")\n\n            self.assertEqual(1, self.count_icons())\n            self.assertTrue(self.icon_exists(\"https_example_com.png\"))\n\n    def test_load_favicon_creates_multiple_icons_for_different_base_url(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n            favicon_loader.load_favicon(\"https://example.com\")\n            favicon_loader.load_favicon(\"https://sub.example.com\")\n            favicon_loader.load_favicon(\"https://other-domain.com\")\n\n            self.assertEqual(3, self.count_icons())\n            self.assertTrue(self.icon_exists(\"https_example_com.png\"))\n            self.assertTrue(self.icon_exists(\"https_sub_example_com.png\"))\n            self.assertTrue(self.icon_exists(\"https_other_domain_com.png\"))\n\n    def test_load_favicon_caches_icons(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n\n            favicon_file = favicon_loader.load_favicon(\"https://example.com\")\n            mock_get.assert_called()\n            self.assertEqual(favicon_file, \"https_example_com.png\")\n\n            mock_get.reset_mock()\n            updated_favicon_file = favicon_loader.load_favicon(\"https://example.com\")\n            mock_get.assert_not_called()\n            self.assertEqual(favicon_file, updated_favicon_file)\n\n    def test_load_favicon_updates_stale_icon(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n            favicon_loader.load_favicon(\"https://example.com\")\n\n            icon_path = self.get_icon_path(\"https_example_com.png\")\n\n            updated_mock_icon_data = b\"updated_mock_icon\"\n            mock_get.return_value = self.create_mock_response(\n                icon_data=updated_mock_icon_data\n            )\n            mock_get.reset_mock()\n\n            # change icon modification date so it is not stale yet\n            nearly_one_day_ago = time.time() - 60 * 60 * 23\n            os.utime(icon_path.absolute(), (nearly_one_day_ago, nearly_one_day_ago))\n\n            favicon_loader.load_favicon(\"https://example.com\")\n            mock_get.assert_not_called()\n\n            # change icon modification date so it is considered stale\n            one_day_ago = time.time() - 60 * 60 * 24\n            os.utime(icon_path.absolute(), (one_day_ago, one_day_ago))\n\n            favicon_loader.load_favicon(\"https://example.com\")\n            mock_get.assert_called()\n            self.assertEqual(\n                updated_mock_icon_data, self.get_icon_data(\"https_example_com.png\")\n            )\n\n    @override_settings(LD_FAVICON_PROVIDER=\"https://custom.icons.com/?url={url}\")\n    def test_custom_provider_with_url_param(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n\n            favicon_loader.load_favicon(\"https://example.com/foo?bar=baz\")\n            mock_get.assert_called_with(\n                \"https://custom.icons.com/?url=https://example.com\", stream=True\n            )\n\n    @override_settings(LD_FAVICON_PROVIDER=\"https://custom.icons.com/?url={domain}\")\n    def test_custom_provider_with_domain_param(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n\n            favicon_loader.load_favicon(\"https://example.com/foo?bar=baz\")\n            mock_get.assert_called_with(\n                \"https://custom.icons.com/?url=example.com\", stream=True\n            )\n\n    def test_guess_file_extension(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(content_type=\"image/png\")\n            favicon_loader.load_favicon(\"https://example.com\")\n\n            self.assertTrue(self.icon_exists(\"https_example_com.png\"))\n\n        self.clear_favicon_folder()\n\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(\n                content_type=\"image/x-icon\"\n            )\n            favicon_loader.load_favicon(\"https://example.com\")\n\n            self.assertTrue(self.icon_exists(\"https_example_com.ico\"))\n"
  },
  {
    "path": "bookmarks/tests/test_feeds.py",
    "content": "import datetime\nimport email\nimport unittest\nimport urllib.parse\n\nfrom django.conf import settings\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.feeds import sanitize\nfrom bookmarks.models import FeedToken, User\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\ndef rfc2822_date(date):\n    if not isinstance(date, datetime.datetime):\n        date = datetime.datetime.combine(date, datetime.time())\n    return email.utils.format_datetime(date)\n\n\nclass FeedsTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n        self.token = FeedToken.objects.get_or_create(user=user)[0]\n\n    def assertFeedItems(self, response, bookmarks):\n        self.assertContains(response, \"<item>\", count=len(bookmarks))\n\n        for bookmark in bookmarks:\n            categories = []\n            for tag in bookmark.tag_names:\n                categories.append(f\"<category>{tag}</category>\")\n\n            if bookmark.resolved_description:\n                expected_description = (\n                    f\"<description>{bookmark.resolved_description}</description>\"\n                )\n            else:\n                expected_description = \"<description/>\"\n\n            expected_item = (\n                \"<item>\"\n                f\"<title>{bookmark.resolved_title}</title>\"\n                f\"<link>{bookmark.url}</link>\"\n                f\"{expected_description}\"\n                f\"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>\"\n                f\"<guid>{bookmark.url}</guid>\"\n                f\"{''.join(categories)}\"\n                \"</item>\"\n            )\n            self.assertContains(response, expected_item, count=1)\n\n    def test_all_returns_404_for_unknown_feed_token(self):\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[\"foo\"]))\n\n        self.assertEqual(response.status_code, 404)\n\n    def test_all_metadata(self):\n        feed_url = reverse(\"linkding:feeds.all\", args=[self.token.key])\n        response = self.client.get(feed_url)\n        self.assertEqual(response.status_code, 200)\n\n        self.assertContains(response, \"<title>All bookmarks</title>\")\n        self.assertContains(response, \"<description>All bookmarks</description>\")\n        self.assertContains(response, f\"<link>http://testserver{feed_url}</link>\")\n        self.assertContains(\n            response, f'<atom:link href=\"http://testserver{feed_url}\" rel=\"self\"/>'\n        )\n\n    def test_all_returns_all_unarchived_bookmarks(self):\n        bookmarks = [\n            self.setup_bookmark(description=\"test description\"),\n            self.setup_bookmark(description=\"\"),\n            self.setup_bookmark(unread=True, description=\"test description\"),\n        ]\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True)\n\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n        self.assertFeedItems(response, bookmarks)\n\n    def test_all_returns_only_user_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        self.setup_bookmark(unread=True, user=other_user)\n        self.setup_bookmark(unread=True, user=other_user)\n        self.setup_bookmark(unread=True, user=other_user)\n\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n\n        self.assertContains(response, \"<item>\", count=0)\n\n    def test_unread_returns_404_for_unknown_feed_token(self):\n        response = self.client.get(reverse(\"linkding:feeds.unread\", args=[\"foo\"]))\n\n        self.assertEqual(response.status_code, 404)\n\n    def test_unread_metadata(self):\n        feed_url = reverse(\"linkding:feeds.unread\", args=[self.token.key])\n        response = self.client.get(feed_url)\n        self.assertEqual(response.status_code, 200)\n\n        self.assertContains(response, \"<title>Unread bookmarks</title>\")\n        self.assertContains(response, \"<description>All unread bookmarks</description>\")\n        self.assertContains(response, f\"<link>http://testserver{feed_url}</link>\")\n        self.assertContains(\n            response, f'<atom:link href=\"http://testserver{feed_url}\" rel=\"self\"/>'\n        )\n\n    def test_unread_returns_unread_and_unarchived_bookmarks(self):\n        self.setup_bookmark(unread=False)\n        self.setup_bookmark(unread=False)\n        self.setup_bookmark(unread=False)\n        self.setup_bookmark(unread=True, is_archived=True)\n        self.setup_bookmark(unread=True, is_archived=True)\n        self.setup_bookmark(unread=False, is_archived=True)\n\n        unread_bookmarks = [\n            self.setup_bookmark(unread=True, description=\"test description\"),\n            self.setup_bookmark(unread=True, description=\"\"),\n            self.setup_bookmark(unread=True, description=\"test description\"),\n        ]\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.unread\", args=[self.token.key])\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFeedItems(response, unread_bookmarks)\n\n    def test_unread_returns_only_user_owned_bookmarks(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        self.setup_bookmark(unread=True, user=other_user)\n        self.setup_bookmark(unread=True, user=other_user)\n        self.setup_bookmark(unread=True, user=other_user)\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.unread\", args=[self.token.key])\n        )\n        self.assertEqual(response.status_code, 200)\n\n        self.assertContains(response, \"<item>\", count=0)\n\n    def test_shared_returns_404_for_unknown_feed_token(self):\n        response = self.client.get(reverse(\"linkding:feeds.shared\", args=[\"foo\"]))\n\n        self.assertEqual(response.status_code, 404)\n\n    def test_shared_metadata(self):\n        feed_url = reverse(\"linkding:feeds.shared\", args=[self.token.key])\n        response = self.client.get(feed_url)\n        self.assertEqual(response.status_code, 200)\n\n        self.assertContains(response, \"<title>Shared bookmarks</title>\")\n        self.assertContains(response, \"<description>All shared bookmarks</description>\")\n        self.assertContains(response, f\"<link>http://testserver{feed_url}</link>\")\n        self.assertContains(\n            response, f'<atom:link href=\"http://testserver{feed_url}\" rel=\"self\"/>'\n        )\n\n    def test_shared_returns_shared_bookmarks_only(self):\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=False)\n\n        self.setup_bookmark()\n        self.setup_bookmark(shared=False, user=user1)\n        self.setup_bookmark(shared=True, user=user2)\n\n        shared_bookmarks = [\n            self.setup_bookmark(shared=True, user=user1, description=\"test\"),\n            self.setup_bookmark(shared=True, user=user1, description=\"test\"),\n            self.setup_bookmark(shared=True, user=user1, description=\"test\"),\n        ]\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.shared\", args=[self.token.key])\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFeedItems(response, shared_bookmarks)\n\n    def test_public_shared_does_not_require_auth(self):\n        response = self.client.get(reverse(\"linkding:feeds.public_shared\"))\n\n        self.assertEqual(response.status_code, 200)\n\n    def test_public_shared_metadata(self):\n        feed_url = reverse(\"linkding:feeds.public_shared\")\n        response = self.client.get(feed_url)\n        self.assertEqual(response.status_code, 200)\n\n        self.assertContains(response, \"<title>Public shared bookmarks</title>\")\n        self.assertContains(\n            response, \"<description>All public shared bookmarks</description>\"\n        )\n        self.assertContains(response, f\"<link>http://testserver{feed_url}</link>\")\n        self.assertContains(\n            response, f'<atom:link href=\"http://testserver{feed_url}\" rel=\"self\"/>'\n        )\n\n    def test_public_shared_returns_publicly_shared_bookmarks_only(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=False)\n\n        self.setup_bookmark()\n        self.setup_bookmark(shared=False, user=user1)\n        self.setup_bookmark(shared=False, user=user2)\n        self.setup_bookmark(shared=True, user=user2)\n        self.setup_bookmark(shared=True, user=user3)\n\n        public_shared_bookmarks = [\n            self.setup_bookmark(shared=True, user=user1, description=\"test\"),\n            self.setup_bookmark(shared=True, user=user1, description=\"test\"),\n            self.setup_bookmark(shared=True, user=user1, description=\"test\"),\n        ]\n\n        response = self.client.get(reverse(\"linkding:feeds.public_shared\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertFeedItems(response, public_shared_bookmarks)\n\n    def test_with_query(self):\n        tag1 = self.setup_tag()\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark(tags=[tag1])\n        bookmark3 = self.setup_bookmark(tags=[tag1])\n\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n\n        feed_url = reverse(\"linkding:feeds.all\", args=[self.token.key])\n\n        url = feed_url + f\"?q={bookmark1.title}\"\n        response = self.client.get(url)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=1)\n        self.assertContains(response, f\"<guid>{bookmark1.url}</guid>\", count=1)\n\n        url = feed_url + \"?q=\" + urllib.parse.quote(\"#\" + tag1.name)\n        response = self.client.get(url)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=2)\n        self.assertContains(response, f\"<guid>{bookmark2.url}</guid>\", count=1)\n        self.assertContains(response, f\"<guid>{bookmark3.url}</guid>\", count=1)\n\n        url = feed_url + \"?q=\" + urllib.parse.quote(f\"#{tag1.name} {bookmark2.title}\")\n        response = self.client.get(url)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=1)\n        self.assertContains(response, f\"<guid>{bookmark2.url}</guid>\", count=1)\n\n    def test_unread_parameter(self):\n        self.setup_bookmark(unread=True)\n        self.setup_bookmark(unread=True)\n        self.setup_bookmark(unread=False)\n        self.setup_bookmark(unread=False)\n        self.setup_bookmark(unread=False)\n        self.setup_bookmark(unread=False)\n\n        # without unread parameter\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=6)\n\n        # with unread=yes\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?unread=yes\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=2)\n\n        # with unread=no\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?unread=no\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=4)\n\n    def test_shared_parameter(self):\n        self.setup_bookmark(shared=True)\n        self.setup_bookmark(shared=True)\n        self.setup_bookmark(shared=False)\n        self.setup_bookmark(shared=False)\n        self.setup_bookmark(shared=False)\n        self.setup_bookmark(shared=False)\n\n        # without shared parameter\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=6)\n\n        # with shared=yes\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?shared=yes\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=2)\n\n        # with shared=no\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?shared=no\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=4)\n\n    def test_with_tags(self):\n        bookmarks = [\n            self.setup_bookmark(description=\"test description\"),\n            self.setup_bookmark(\n                description=\"test description\",\n                tags=[self.setup_tag(), self.setup_tag()],\n            ),\n        ]\n\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n        self.assertFeedItems(response, bookmarks)\n\n    def test_with_limit(self):\n        self.setup_numbered_bookmarks(200)\n\n        # without limit - defaults to 100\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=100)\n\n        # with increased limit\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?limit=200\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=200)\n\n        # with decreased limit\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?limit=5\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=5)\n\n    @unittest.skipIf(\n        settings.LD_DB_ENGINE == \"postgres\",\n        \"Postgres does not allow NUL in text columns\",\n    )\n    def test_strip_control_characters(self):\n        self.setup_bookmark(\n            title=\"test\\n\\r\\t\\0\\x08title\", description=\"test\\n\\r\\t\\0\\x08description\"\n        )\n        response = self.client.get(reverse(\"linkding:feeds.all\", args=[self.token.key]))\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"<item>\", count=1)\n        self.assertContains(response, \"<title>test\\n\\r\\ttitle</title>\", count=1)\n        self.assertContains(\n            response, \"<description>test\\n\\r\\tdescription</description>\", count=1\n        )\n\n    def test_sanitize_with_none_text(self):\n        self.assertEqual(\"\", sanitize(None))\n\n    def test_with_bundle(self):\n        tag1 = self.setup_tag()\n        visible_bookmarks = [\n            self.setup_bookmark(tags=[tag1]),\n            self.setup_bookmark(tags=[tag1]),\n        ]\n\n        self.setup_bookmark()\n        self.setup_bookmark()\n\n        bundle = self.setup_bundle(all_tags=tag1.name)\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key])\n            + f\"?bundle={bundle.id}\"\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertFeedItems(response, visible_bookmarks)\n\n    def test_with_bundle_not_owned_by_user(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        other_bundle = self.setup_bundle(user=other_user, search=\"test\")\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key])\n            + f\"?bundle={other_bundle.id}\"\n        )\n        self.assertEqual(response.status_code, 404)\n\n    def test_with_invalid_bundle_id(self):\n        self.setup_bookmark(title=\"test bookmark\")\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?bundle=999999\"\n        )\n        self.assertEqual(response.status_code, 404)\n\n    def test_with_non_numeric_bundle_id(self):\n        self.setup_bookmark(title=\"test bookmark\")\n\n        response = self.client.get(\n            reverse(\"linkding:feeds.all\", args=[self.token.key]) + \"?bundle=invalid\"\n        )\n        self.assertEqual(response.status_code, 404)\n"
  },
  {
    "path": "bookmarks/tests/test_feeds_performance.py",
    "content": "from django.db import connections\nfrom django.db.utils import DEFAULT_DB_ALIAS\nfrom django.test import TestCase\nfrom django.test.utils import CaptureQueriesContext\nfrom django.urls import reverse\n\nfrom bookmarks.models import FeedToken, GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass FeedsPerformanceTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n        self.token = FeedToken.objects.get_or_create(user=user)[0]\n\n        # create global settings\n        GlobalSettings.get()\n\n    def get_connection(self):\n        return connections[DEFAULT_DB_ALIAS]\n\n    def test_all_max_queries(self):\n        # set up some bookmarks with associated tags\n        num_initial_bookmarks = 10\n        for _ in range(num_initial_bookmarks):\n            self.setup_bookmark(tags=[self.setup_tag()])\n\n        # capture number of queries\n        context = CaptureQueriesContext(self.get_connection())\n        with context:\n            feed_url = reverse(\"linkding:feeds.all\", args=[self.token.key])\n            self.client.get(feed_url)\n\n        number_of_queries = context.final_queries\n\n        self.assertLess(number_of_queries, num_initial_bookmarks)\n"
  },
  {
    "path": "bookmarks/tests/test_health_view.py",
    "content": "from unittest.mock import patch\n\nfrom django.db import connections\nfrom django.test import TestCase\n\nfrom bookmarks.views.settings import app_version\n\n\nclass HealthViewTestCase(TestCase):\n    def test_health_healthy(self):\n        response = self.client.get(\"/health\")\n\n        self.assertEqual(response.status_code, 200)\n\n        response_body = response.json()\n        expected_body = {\"version\": app_version, \"status\": \"healthy\"}\n        self.assertDictEqual(response_body, expected_body)\n\n    def test_health_unhealhty(self):\n        with patch.object(\n            connections[\"default\"], \"ensure_connection\"\n        ) as mock_ensure_connection:\n            mock_ensure_connection.side_effect = Exception(\"Connection error\")\n\n            response = self.client.get(\"/health\")\n\n            self.assertEqual(response.status_code, 500)\n\n            response_body = response.json()\n            expected_body = {\"version\": app_version, \"status\": \"unhealthy\"}\n            self.assertDictEqual(response_body, expected_body)\n"
  },
  {
    "path": "bookmarks/tests/test_importer.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase, override_settings\nfrom django.utils import timezone\n\nfrom bookmarks.models import Bookmark, Tag, parse_tag_string\nfrom bookmarks.services import tasks\nfrom bookmarks.services.importer import ImportOptions, import_netscape_html\nfrom bookmarks.tests.helpers import (\n    BookmarkFactoryMixin,\n    BookmarkHtmlTag,\n    ImportTestMixin,\n    disable_logging,\n)\nfrom bookmarks.utils import parse_timestamp\n\n\nclass ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):\n    def assertBookmarksImported(self, html_tags: list[BookmarkHtmlTag]):\n        for html_tag in html_tags:\n            bookmark = Bookmark.objects.get(url=html_tag.href)\n            self.assertIsNotNone(bookmark)\n\n            self.assertEqual(bookmark.title, html_tag.title)\n            self.assertEqual(bookmark.description, html_tag.description)\n            self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))\n            self.assertEqual(\n                bookmark.date_modified, parse_timestamp(html_tag.last_modified)\n            )\n            self.assertEqual(bookmark.unread, html_tag.to_read)\n            self.assertEqual(bookmark.shared, not html_tag.private)\n\n            tag_names = parse_tag_string(html_tag.tags)\n\n            # Check assigned tags\n            for tag_name in tag_names:\n                tag = next(\n                    (tag for tag in bookmark.tags.all() if tag.name == tag_name), None\n                )\n                self.assertIsNotNone(tag)\n\n    def test_import(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                last_modified=\"11\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/foo\",\n                title=\"Foo title\",\n                description=\"\",\n                add_date=\"2\",\n                last_modified=\"22\",\n                tags=\"\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/bar\",\n                title=\"Bar title\",\n                description=\"Bar description\",\n                add_date=\"3\",\n                last_modified=\"33\",\n                tags=\"bar-tag, other-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/baz\",\n                title=\"Baz title\",\n                description=\"Baz description\",\n                add_date=\"4\",\n                last_modified=\"44\",\n                to_read=True,\n            ),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        result = import_netscape_html(import_html, self.get_or_create_test_user())\n\n        # Check result\n        self.assertEqual(result.total, 4)\n        self.assertEqual(result.success, 4)\n        self.assertEqual(result.failed, 0)\n\n        # Check bookmarks\n        bookmarks = Bookmark.objects.all()\n        self.assertEqual(len(bookmarks), 4)\n        self.assertBookmarksImported(html_tags)\n\n    def test_synchronize(self):\n        # Initial import\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                last_modified=\"11\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/foo\",\n                title=\"Foo title\",\n                description=\"\",\n                add_date=\"2\",\n                last_modified=\"22\",\n                tags=\"\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/bar\",\n                title=\"Bar title\",\n                description=\"Bar description\",\n                add_date=\"3\",\n                last_modified=\"33\",\n                tags=\"bar-tag, other-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/unread\",\n                title=\"Unread title\",\n                description=\"Unread description\",\n                add_date=\"4\",\n                last_modified=\"44\",\n                to_read=True,\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/private\",\n                title=\"Private title\",\n                description=\"Private description\",\n                add_date=\"5\",\n                last_modified=\"55\",\n                private=True,\n            ),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        # Check bookmarks\n        bookmarks = Bookmark.objects.all()\n        self.assertEqual(len(bookmarks), 5)\n        self.assertBookmarksImported(html_tags)\n\n        # Change data, add some new data\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Updated Example title\",\n                description=\"Updated Example description\",\n                add_date=\"111\",\n                last_modified=\"1111\",\n                tags=\"updated-example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/foo\",\n                title=\"Updated Foo title\",\n                description=\"Updated Foo description\",\n                add_date=\"222\",\n                last_modified=\"2222\",\n                tags=\"new-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/bar\",\n                title=\"Updated Bar title\",\n                description=\"Updated Bar description\",\n                add_date=\"333\",\n                last_modified=\"3333\",\n                tags=\"updated-bar-tag, updated-other-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/unread\",\n                title=\"Unread title\",\n                description=\"Unread description\",\n                add_date=\"3\",\n                last_modified=\"3\",\n                to_read=False,\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/private\",\n                title=\"Private title\",\n                description=\"Private description\",\n                add_date=\"4\",\n                last_modified=\"4\",\n                private=False,\n            ),\n            BookmarkHtmlTag(\n                href=\"https://baz.com\",\n                add_date=\"444\",\n                last_modified=\"4444\",\n                tags=\"baz-tag\",\n            ),\n        ]\n\n        # Import updated data\n        import_html = self.render_html(tags=html_tags)\n        result = import_netscape_html(\n            import_html,\n            self.get_or_create_test_user(),\n            ImportOptions(map_private_flag=True),\n        )\n\n        # Check result\n        self.assertEqual(result.total, 6)\n        self.assertEqual(result.success, 6)\n        self.assertEqual(result.failed, 0)\n\n        # Check bookmarks\n        bookmarks = Bookmark.objects.all()\n        self.assertEqual(len(bookmarks), 6)\n        self.assertBookmarksImported(html_tags)\n\n    def test_import_with_some_invalid_bookmarks(self):\n        html_tags = [\n            BookmarkHtmlTag(href=\"https://example.com\"),\n            # Invalid URL\n            BookmarkHtmlTag(href=\"foo.com\"),\n            # No URL\n            BookmarkHtmlTag(),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        result = import_netscape_html(import_html, self.get_or_create_test_user())\n\n        # Check result\n        self.assertEqual(result.total, 3)\n        self.assertEqual(result.success, 1)\n        self.assertEqual(result.failed, 2)\n\n        # Check bookmarks\n        bookmarks = Bookmark.objects.all()\n        self.assertEqual(len(bookmarks), 1)\n        self.assertBookmarksImported(html_tags[1:1])\n\n    def test_import_invalid_bookmark_does_not_associate_tags(self):\n        html_tags = [\n            # No URL\n            BookmarkHtmlTag(tags=\"tag1, tag2, tag3\"),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        # Sqlite silently ignores relationships that have a non-persisted bookmark,\n        # thus testing if the bulk create receives no relationships\n        BookmarkToTagRelationShip = Bookmark.tags.through\n        with patch.object(\n            BookmarkToTagRelationShip.objects, \"bulk_create\"\n        ) as mock_bulk_create:\n            import_netscape_html(import_html, self.get_or_create_test_user())\n            mock_bulk_create.assert_called_once_with([], ignore_conflicts=True)\n\n    def test_import_tags(self):\n        html_tags = [\n            BookmarkHtmlTag(href=\"https://example.com\", tags=\"tag1\"),\n            BookmarkHtmlTag(href=\"https://foo.com\", tags=\"tag2\"),\n            BookmarkHtmlTag(href=\"https://bar.com\", tags=\"tag3\"),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        self.assertEqual(Tag.objects.count(), 3)\n\n    def test_create_missing_tags(self):\n        html_tags = [\n            BookmarkHtmlTag(href=\"https://example.com\", tags=\"tag1\"),\n            BookmarkHtmlTag(href=\"https://foo.com\", tags=\"tag2\"),\n            BookmarkHtmlTag(href=\"https://bar.com\", tags=\"tag3\"),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        html_tags.append(BookmarkHtmlTag(href=\"https://baz.com\", tags=\"tag4\"))\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        self.assertEqual(Tag.objects.count(), 4)\n\n    def test_create_missing_tags_does_not_duplicate_tags(self):\n        html_tags = [\n            BookmarkHtmlTag(href=\"https://example.com\", tags=\"tag1\"),\n            BookmarkHtmlTag(href=\"https://foo.com\", tags=\"tag1\"),\n            BookmarkHtmlTag(href=\"https://bar.com\", tags=\"tag1\"),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        self.assertEqual(Tag.objects.count(), 1)\n\n    def test_should_append_tags_to_bookmark_when_reimporting_with_different_tags(self):\n        html_tags = [\n            BookmarkHtmlTag(href=\"https://example.com\", tags=\"tag1\"),\n        ]\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        html_tags.append(BookmarkHtmlTag(href=\"https://example.com\", tags=\"tag2, tag3\"))\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].tags.all().count(), 3)\n\n    @override_settings(USE_TZ=False)\n    def test_use_current_date_when_no_add_date(self):\n        test_html = self.render_html(\n            tags_html=\"\"\"\n            <DT><A HREF=\"https://example.com\">Example.com</A>\n            <DD>Example.com\n        \"\"\"\n        )\n\n        with patch.object(timezone, \"now\", return_value=timezone.datetime(2021, 1, 1)):\n            import_netscape_html(test_html, self.get_or_create_test_user())\n\n            self.assertEqual(Bookmark.objects.count(), 1)\n            self.assertEqual(\n                Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1)\n            )\n\n    def test_use_add_date_when_no_last_modified(self):\n        test_html = self.render_html(\n            tags_html=\"\"\"\n            <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example.com</A>\n            <DD>Example.com\n        \"\"\"\n        )\n\n        import_netscape_html(test_html, self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].date_modified, parse_timestamp(\"1\"))\n\n    def test_keep_title_if_imported_bookmark_has_empty_title(self):\n        test_html = self.render_html(\n            tags=[BookmarkHtmlTag(href=\"https://example.com\", title=\"Example.com\")]\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user())\n\n        test_html = self.render_html(tags=[BookmarkHtmlTag(href=\"https://example.com\")])\n        import_netscape_html(test_html, self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].title, \"Example.com\")\n\n    def test_keep_description_if_imported_bookmark_has_empty_description(self):\n        test_html = self.render_html(\n            tags=[\n                BookmarkHtmlTag(href=\"https://example.com\", description=\"Example.com\")\n            ]\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user())\n\n        test_html = self.render_html(tags=[BookmarkHtmlTag(href=\"https://example.com\")])\n        import_netscape_html(test_html, self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].description, \"Example.com\")\n\n    def test_replace_whitespace_in_tag_names(self):\n        test_html = self.render_html(\n            tags_html=\"\"\"\n            <DT><A HREF=\"https://example.com\" TAGS=\"tag 1, tag 2, tag 3\">Example.com</A>\n            <DD>Example.com\n        \"\"\"\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user())\n\n        tags = Tag.objects.all()\n        tag_names = [tag.name for tag in tags]\n\n        self.assertListEqual(tag_names, [\"tag-1\", \"tag-2\", \"tag-3\"])\n\n    def test_ignore_long_tag_names(self):\n        long_tag = \"a\" * 65\n        valid_tag = \"valid-tag\"\n\n        test_html = self.render_html(\n            tags_html=f\"\"\"\n            <DT><A HREF=\"https://example.com\" TAGS=\"{long_tag}, {valid_tag}\">Example.com</A>\n            <DD>Example.com\n        \"\"\"\n        )\n        result = import_netscape_html(test_html, self.get_or_create_test_user())\n\n        # Import should succeed\n        self.assertEqual(result.success, 1)\n        self.assertEqual(result.failed, 0)\n\n        # Only the valid tag should be created\n        tags = Tag.objects.all()\n        self.assertEqual(len(tags), 1)\n        self.assertEqual(tags[0].name, valid_tag)\n\n        # Bookmark should only have the valid tag assigned\n        bookmark = Bookmark.objects.get(url=\"https://example.com\")\n        bookmark_tag_names = [tag.name for tag in bookmark.tags.all()]\n        self.assertEqual(bookmark_tag_names, [valid_tag])\n\n    @disable_logging\n    def test_validate_empty_or_missing_bookmark_url(self):\n        test_html = self.render_html(\n            tags_html=\"\"\"\n            <DT><A HREF=\"\">Empty URL</A>\n            <DD>Empty URL\n            <DT><A>Missing URL</A>\n            <DD>Missing URL\n        \"\"\"\n        )\n\n        import_result = import_netscape_html(test_html, self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 0)\n        self.assertEqual(import_result.success, 0)\n        self.assertEqual(import_result.failed, 2)\n\n    def test_generate_normalized_url(self):\n        html_tags = [\n            BookmarkHtmlTag(href=\"https://example.com/?z=1&a=2#\"),\n            BookmarkHtmlTag(\n                href=\"foo.bar\"\n            ),  # invalid URL, should be skipped without error\n        ]\n        import_html = self.render_html(tags=html_tags)\n        import_netscape_html(import_html, self.get_or_create_test_user())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(\n            Bookmark.objects.all()[0].url_normalized, \"https://example.com?a=2&z=1\"\n        )\n\n    def test_private_flag(self):\n        # does not map private flag if not enabled in options\n        test_html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com/1\" ADD_DATE=\"1\">Example title 1</A>\n        <DD>Example description 1</DD>\n        <DT><A HREF=\"https://example.com/2\" ADD_DATE=\"1\" PRIVATE=\"1\">Example title 2</A>\n        <DD>Example description 2</DD>\n        <DT><A HREF=\"https://example.com/3\" ADD_DATE=\"1\" PRIVATE=\"0\">Example title 3</A>\n        <DD>Example description 3</DD>\n        \"\"\"\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())\n\n        self.assertEqual(Bookmark.objects.count(), 3)\n        self.assertEqual(Bookmark.objects.all()[0].shared, False)\n        self.assertEqual(Bookmark.objects.all()[1].shared, False)\n        self.assertEqual(Bookmark.objects.all()[2].shared, False)\n\n        # does map private flag if enabled in options\n        Bookmark.objects.all().delete()\n        import_netscape_html(\n            test_html,\n            self.get_or_create_test_user(),\n            ImportOptions(map_private_flag=True),\n        )\n        bookmark1 = Bookmark.objects.get(url=\"https://example.com/1\")\n        bookmark2 = Bookmark.objects.get(url=\"https://example.com/2\")\n        bookmark3 = Bookmark.objects.get(url=\"https://example.com/3\")\n        self.assertEqual(bookmark1.shared, False)\n        self.assertEqual(bookmark2.shared, False)\n        self.assertEqual(bookmark3.shared, True)\n\n    def test_archived_state(self):\n        test_html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com/1\" ADD_DATE=\"1\" TAGS=\"tag1,tag2,linkding:bookmarks.archived\">Example title 1</A>\n        <DD>Example description 1</DD>\n        <DT><A HREF=\"https://example.com/2\" ADD_DATE=\"1\" PRIVATE=\"1\" TAGS=\"tag1,tag2\">Example title 2</A>\n        <DD>Example description 2</DD>\n        <DT><A HREF=\"https://example.com/3\" ADD_DATE=\"1\" PRIVATE=\"0\">Example title 3</A>\n        <DD>Example description 3</DD>\n        \"\"\"\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())\n\n        self.assertEqual(Bookmark.objects.count(), 3)\n        self.assertEqual(Bookmark.objects.all()[0].is_archived, True)\n        self.assertEqual(Bookmark.objects.all()[1].is_archived, False)\n        self.assertEqual(Bookmark.objects.all()[2].is_archived, False)\n\n        tags = Tag.objects.all()\n        self.assertEqual(len(tags), 2)\n        self.assertEqual(tags[0].name, \"tag1\")\n        self.assertEqual(tags[1].name, \"tag2\")\n\n    def test_notes(self):\n        # initial notes\n        test_html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description[linkding-notes]Example notes[/linkding-notes]\n        \"\"\"\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].description, \"Example description\")\n        self.assertEqual(Bookmark.objects.all()[0].notes, \"Example notes\")\n\n        # update notes\n        test_html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description[linkding-notes]Updated notes[/linkding-notes]\n        \"\"\"\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].description, \"Example description\")\n        self.assertEqual(Bookmark.objects.all()[0].notes, \"Updated notes\")\n\n        # does not override existing notes if empty\n        test_html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description\n        \"\"\"\n        )\n        import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())\n\n        self.assertEqual(Bookmark.objects.count(), 1)\n        self.assertEqual(Bookmark.objects.all()[0].description, \"Example description\")\n        self.assertEqual(Bookmark.objects.all()[0].notes, \"Updated notes\")\n\n    def test_schedule_favicon_loading(self):\n        user = self.get_or_create_test_user()\n        test_html = self.render_html(tags_html=\"\")\n\n        with patch.object(\n            tasks, \"schedule_bookmarks_without_favicons\"\n        ) as mock_schedule_bookmarks_without_favicons:\n            import_netscape_html(test_html, user)\n\n            mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)\n\n    def test_schedule_preview_loading(self):\n        user = self.get_or_create_test_user()\n        test_html = self.render_html(tags_html=\"\")\n\n        with patch.object(\n            tasks, \"schedule_bookmarks_without_previews\"\n        ) as mock_schedule_bookmarks_without_previews:\n            import_netscape_html(test_html, user)\n\n            mock_schedule_bookmarks_without_previews.assert_called_once_with(user)\n"
  },
  {
    "path": "bookmarks/tests/test_layout.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def test_nav_menu_should_respect_share_profile_setting(self):\n        self.user.profile.enable_sharing = False\n        self.user.profile.save()\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{reverse(\"linkding:bookmarks.shared\")}\" class=\"menu-link\">Shared</a>\n        \"\"\",\n            html,\n            count=0,\n        )\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{reverse(\"linkding:bookmarks.shared\")}\" class=\"menu-link\">Shared bookmarks</a>\n        \"\"\",\n            html,\n            count=0,\n        )\n\n        self.user.profile.enable_sharing = True\n        self.user.profile.save()\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{reverse(\"linkding:bookmarks.shared\")}\" class=\"menu-link\">Shared</a>\n        \"\"\",\n            html,\n            count=1,\n        )\n        self.assertInHTML(\n            f\"\"\"\n            <a href=\"{reverse(\"linkding:bookmarks.shared\")}\" class=\"menu-link\">Shared bookmarks</a>\n        \"\"\",\n            html,\n            count=1,\n        )\n\n    def test_metadata_should_respect_prefetch_links_setting(self):\n        settings = GlobalSettings.get()\n        settings.enable_link_prefetch = False\n        settings.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<meta name=\"turbo-prefetch\" content=\"false\">',\n            html,\n            count=1,\n        )\n\n        settings.enable_link_prefetch = True\n        settings.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<meta name=\"turbo-prefetch\" content=\"false\">',\n            html,\n            count=0,\n        )\n\n    def test_does_not_link_custom_css_when_empty(self):\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n        soup = self.make_soup(html)\n\n        link = soup.select_one(\"link[rel='stylesheet'][href*='custom_css']\")\n        self.assertIsNone(link)\n\n    def test_does_link_custom_css_when_not_empty(self):\n        profile = self.get_or_create_test_user().profile\n        profile.custom_css = \"body { background-color: red; }\"\n        profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n        soup = self.make_soup(html)\n\n        link = soup.select_one(\"link[rel='stylesheet'][href*='custom_css']\")\n        self.assertIsNotNone(link)\n\n    def test_custom_css_link_href(self):\n        profile = self.get_or_create_test_user().profile\n        profile.custom_css = \"body { background-color: red; }\"\n        profile.save()\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n        soup = self.make_soup(html)\n\n        link = soup.select_one(\"link[rel='stylesheet'][href*='custom_css']\")\n        expected_url = (\n            reverse(\"linkding:custom_css\") + f\"?hash={profile.custom_css_hash}\"\n        )\n        self.assertEqual(link[\"href\"], expected_url)\n"
  },
  {
    "path": "bookmarks/tests/test_linkding_middleware.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.middlewares import standard_profile\nfrom bookmarks.models import GlobalSettings, UserProfile\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass LinkdingMiddlewareTestCase(TestCase, BookmarkFactoryMixin):\n    def test_unauthenticated_user_should_use_standard_profile_by_default(self):\n        response = self.client.get(reverse(\"login\"))\n\n        self.assertEqual(standard_profile, response.wsgi_request.user_profile)\n\n    def test_unauthenticated_user_should_use_custom_configured_profile(self):\n        guest_user = self.setup_user()\n        guest_user_profile = guest_user.profile\n        guest_user_profile.theme = UserProfile.THEME_DARK\n        guest_user_profile.save()\n\n        global_settings = GlobalSettings.get()\n        global_settings.guest_profile_user = guest_user\n        global_settings.save()\n\n        response = self.client.get(reverse(\"login\"))\n\n        self.assertEqual(guest_user_profile, response.wsgi_request.user_profile)\n\n    def test_authenticated_user_should_use_own_profile(self):\n        guest_user = self.setup_user()\n        guest_user_profile = guest_user.profile\n        guest_user_profile.theme = UserProfile.THEME_DARK\n        guest_user_profile.save()\n\n        global_settings = GlobalSettings.get()\n        global_settings.guest_profile_user = guest_user\n        global_settings.save()\n\n        user = self.get_or_create_test_user()\n        user_profile = user.profile\n        user_profile.theme = UserProfile.THEME_LIGHT\n        user_profile.save()\n        self.client.force_login(user)\n\n        response = self.client.get(reverse(\"login\"), follow=True)\n\n        self.assertEqual(user_profile, response.wsgi_request.user_profile)\n"
  },
  {
    "path": "bookmarks/tests/test_login_view.py",
    "content": "from django.test import TestCase, override_settings\nfrom django.urls import include, path\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\nfrom bookmarks.urls import urlpatterns as base_patterns\n\n# Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled\nurlpatterns = base_patterns + [path(\"oidc/\", include(\"mozilla_django_oidc.urls\"))]\n\n\n@override_settings(ROOT_URLCONF=__name__)\nclass LoginViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def test_failed_login_should_return_401(self):\n        response = self.client.post(\"/login/\", {\"username\": \"test\", \"password\": \"test\"})\n        self.assertEqual(response.status_code, 401)\n\n    def test_successful_login_should_redirect(self):\n        user = self.setup_user(name=\"test\")\n        user.set_password(\"test\")\n        user.save()\n\n        response = self.client.post(\"/login/\", {\"username\": \"test\", \"password\": \"test\"})\n        self.assertEqual(response.status_code, 302)\n\n    def test_should_not_show_oidc_login_by_default(self):\n        response = self.client.get(\"/login/\")\n        soup = self.make_soup(response.content.decode())\n\n        oidc_login_link = soup.find(\"a\", string=\"Login with OIDC\")\n\n        self.assertIsNone(oidc_login_link)\n\n    @override_settings(LD_ENABLE_OIDC=True)\n    def test_should_show_oidc_login_when_enabled(self):\n        response = self.client.get(\"/login/\")\n        soup = self.make_soup(response.content.decode())\n\n        oidc_login_link = soup.find(\"a\", string=\"Login with OIDC\")\n\n        self.assertIsNotNone(oidc_login_link)\n\n        # should have turbo disabled\n        self.assertEqual(\"false\", oidc_login_link.get(\"data-turbo\"))\n\n    def test_should_show_login_form_by_default(self):\n        response = self.client.get(\"/login/\")\n        soup = self.make_soup(response.content.decode())\n\n        form = soup.find(\"form\", {\"action\": \"/login/\"})\n        username_input = soup.find(\"input\", {\"name\": \"username\"})\n        password_input = soup.find(\"input\", {\"name\": \"password\"})\n        submit_button = soup.find(\"input\", {\"type\": \"submit\", \"value\": \"Login\"})\n\n        self.assertIsNotNone(form)\n        self.assertIsNotNone(username_input)\n        self.assertIsNotNone(password_input)\n        self.assertIsNotNone(submit_button)\n\n    @override_settings(LD_DISABLE_LOGIN_FORM=True)\n    def test_should_hide_login_form_when_disabled(self):\n        response = self.client.get(\"/login/\")\n        soup = self.make_soup(response.content.decode())\n\n        form = soup.find(\"form\", {\"action\": \"/login/\"})\n        username_input = soup.find(\"input\", {\"name\": \"username\"})\n        password_input = soup.find(\"input\", {\"name\": \"password\"})\n        submit_button = soup.find(\"input\", {\"type\": \"submit\", \"value\": \"Login\"})\n\n        self.assertIsNone(form)\n        self.assertIsNone(username_input)\n        self.assertIsNone(password_input)\n        self.assertIsNone(submit_button)\n\n    @override_settings(LD_DISABLE_LOGIN_FORM=True, LD_ENABLE_OIDC=True)\n    def test_should_only_show_oidc_login_when_login_disabled_and_oidc_enabled(self):\n        response = self.client.get(\"/login/\")\n        soup = self.make_soup(response.content.decode())\n\n        form = soup.find(\"form\", {\"action\": \"/login/\"})\n        oidc_login_link = soup.find(\"a\", string=\"Login with OIDC\")\n\n        self.assertIsNone(form)\n        self.assertIsNotNone(oidc_login_link)\n"
  },
  {
    "path": "bookmarks/tests/test_metadata_view.py",
    "content": "from django.test import TestCase, override_settings\n\n\nclass MetadataViewTestCase(TestCase):\n    def test_default_manifest(self):\n        response = self.client.get(\"/manifest.json\")\n\n        self.assertEqual(response.status_code, 200)\n\n        response_body = response.json()\n        expected_body = {\n            \"short_name\": \"linkding\",\n            \"name\": \"linkding\",\n            \"description\": \"Self-hosted bookmark service\",\n            \"start_url\": \"bookmarks\",\n            \"display\": \"standalone\",\n            \"scope\": \"/\",\n            \"theme_color\": \"#5856e0\",\n            \"background_color\": \"#ffffff\",\n            \"icons\": [\n                {\n                    \"src\": \"/static/logo.svg\",\n                    \"type\": \"image/svg+xml\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"any\",\n                },\n                {\n                    \"src\": \"/static/logo-512.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"any\",\n                },\n                {\n                    \"src\": \"/static/logo-192.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"192x192\",\n                    \"purpose\": \"any\",\n                },\n                {\n                    \"src\": \"/static/maskable-logo.svg\",\n                    \"type\": \"image/svg+xml\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"maskable\",\n                },\n                {\n                    \"src\": \"/static/maskable-logo-512.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"maskable\",\n                },\n                {\n                    \"src\": \"/static/maskable-logo-192.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"192x192\",\n                    \"purpose\": \"maskable\",\n                },\n            ],\n            \"shortcuts\": [\n                {\n                    \"name\": \"Add bookmark\",\n                    \"url\": \"/bookmarks/new\",\n                },\n                {\n                    \"name\": \"Archived\",\n                    \"url\": \"/bookmarks/archived\",\n                },\n                {\n                    \"name\": \"Unread\",\n                    \"url\": \"/bookmarks?unread=yes\",\n                },\n                {\n                    \"name\": \"Untagged\",\n                    \"url\": \"/bookmarks?q=!untagged\",\n                },\n                {\n                    \"name\": \"Shared\",\n                    \"url\": \"/bookmarks/shared\",\n                },\n            ],\n            \"screenshots\": [\n                {\n                    \"src\": \"/static/linkding-screenshot.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"2158x1160\",\n                    \"form_factor\": \"wide\",\n                }\n            ],\n            \"share_target\": {\n                \"action\": \"/bookmarks/new\",\n                \"method\": \"GET\",\n                \"enctype\": \"application/x-www-form-urlencoded\",\n                \"params\": {\n                    \"url\": \"url\",\n                    \"text\": \"url\",\n                    \"title\": \"title\",\n                },\n            },\n        }\n        self.assertDictEqual(response_body, expected_body)\n\n    @override_settings(LD_CONTEXT_PATH=\"linkding/\")\n    def test_manifest_respects_context_path(self):\n        response = self.client.get(\"/manifest.json\")\n\n        self.assertEqual(response.status_code, 200)\n\n        response_body = response.json()\n        expected_body = {\n            \"short_name\": \"linkding\",\n            \"name\": \"linkding\",\n            \"description\": \"Self-hosted bookmark service\",\n            \"start_url\": \"bookmarks\",\n            \"display\": \"standalone\",\n            \"scope\": \"/linkding/\",\n            \"theme_color\": \"#5856e0\",\n            \"background_color\": \"#ffffff\",\n            \"icons\": [\n                {\n                    \"src\": \"/linkding/static/logo.svg\",\n                    \"type\": \"image/svg+xml\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"any\",\n                },\n                {\n                    \"src\": \"/linkding/static/logo-512.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"any\",\n                },\n                {\n                    \"src\": \"/linkding/static/logo-192.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"192x192\",\n                    \"purpose\": \"any\",\n                },\n                {\n                    \"src\": \"/linkding/static/maskable-logo.svg\",\n                    \"type\": \"image/svg+xml\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"maskable\",\n                },\n                {\n                    \"src\": \"/linkding/static/maskable-logo-512.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"512x512\",\n                    \"purpose\": \"maskable\",\n                },\n                {\n                    \"src\": \"/linkding/static/maskable-logo-192.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"192x192\",\n                    \"purpose\": \"maskable\",\n                },\n            ],\n            \"shortcuts\": [\n                {\n                    \"name\": \"Add bookmark\",\n                    \"url\": \"/linkding/bookmarks/new\",\n                },\n                {\n                    \"name\": \"Archived\",\n                    \"url\": \"/linkding/bookmarks/archived\",\n                },\n                {\n                    \"name\": \"Unread\",\n                    \"url\": \"/linkding/bookmarks?unread=yes\",\n                },\n                {\n                    \"name\": \"Untagged\",\n                    \"url\": \"/linkding/bookmarks?q=!untagged\",\n                },\n                {\n                    \"name\": \"Shared\",\n                    \"url\": \"/linkding/bookmarks/shared\",\n                },\n            ],\n            \"screenshots\": [\n                {\n                    \"src\": \"/linkding/static/linkding-screenshot.png\",\n                    \"type\": \"image/png\",\n                    \"sizes\": \"2158x1160\",\n                    \"form_factor\": \"wide\",\n                }\n            ],\n            \"share_target\": {\n                \"action\": \"/linkding/bookmarks/new\",\n                \"method\": \"GET\",\n                \"enctype\": \"application/x-www-form-urlencoded\",\n                \"params\": {\n                    \"url\": \"url\",\n                    \"text\": \"url\",\n                    \"title\": \"title\",\n                },\n            },\n        }\n        self.assertDictEqual(response_body, expected_body)\n"
  },
  {
    "path": "bookmarks/tests/test_monolith_service.py",
    "content": "import gzip\nimport os\nimport subprocess\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nfrom bookmarks.services import monolith\n\n\nclass MonolithServiceTestCase(TestCase):\n    html_content = \"<html><body><h1>Hello, World!</h1></body></html>\"\n    html_filepath = \"temp.html.gz\"\n    temp_html_filepath = \"temp.html.gz.tmp\"\n\n    def tearDown(self):\n        if os.path.exists(self.html_filepath):\n            os.remove(self.html_filepath)\n        if os.path.exists(self.temp_html_filepath):\n            os.remove(self.temp_html_filepath)\n\n    def create_test_file(self, *args, **kwargs):\n        with open(self.temp_html_filepath, \"w\") as file:\n            file.write(self.html_content)\n\n    def test_create_snapshot(self):\n        with mock.patch(\"subprocess.run\") as mock_run:\n            mock_run.side_effect = self.create_test_file\n\n            monolith.create_snapshot(\"http://example.com\", self.html_filepath)\n\n            self.assertTrue(os.path.exists(self.html_filepath))\n            self.assertFalse(os.path.exists(self.temp_html_filepath))\n\n            with gzip.open(self.html_filepath, \"rt\") as file:\n                content = file.read()\n                self.assertEqual(content, self.html_content)\n\n    def test_create_snapshot_failure(self):\n        with mock.patch(\"subprocess.run\") as mock_run:\n            mock_run.side_effect = subprocess.CalledProcessError(1, \"command\")\n\n            with self.assertRaises(monolith.MonolithError):\n                monolith.create_snapshot(\"http://example.com\", self.html_filepath)\n"
  },
  {
    "path": "bookmarks/tests/test_oidc_support.py",
    "content": "import importlib\nimport os\n\nfrom django.test import TestCase, override_settings\nfrom django.urls import URLResolver\n\nfrom bookmarks import utils\n\n\nclass OidcSupportTest(TestCase):\n    def test_should_not_add_oidc_urls_by_default(self):\n        urls_module = importlib.import_module(\"bookmarks.urls\")\n        importlib.reload(urls_module)\n        oidc_url_found = any(\n            isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == \"oidc/\"\n            for urlpattern in urls_module.urlpatterns\n        )\n\n        self.assertFalse(oidc_url_found)\n\n    @override_settings(LD_ENABLE_OIDC=True)\n    def test_should_add_oidc_urls_when_enabled(self):\n        urls_module = importlib.import_module(\"bookmarks.urls\")\n        importlib.reload(urls_module)\n        oidc_url_found = any(\n            isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == \"oidc/\"\n            for urlpattern in urls_module.urlpatterns\n        )\n\n        self.assertTrue(oidc_url_found)\n\n    def test_should_not_add_oidc_authentication_backend_by_default(self):\n        base_settings = importlib.import_module(\"bookmarks.settings.base\")\n        importlib.reload(base_settings)\n\n        self.assertListEqual(\n            [\"django.contrib.auth.backends.ModelBackend\"],\n            base_settings.AUTHENTICATION_BACKENDS,\n        )\n\n    def test_should_add_oidc_authentication_backend_when_enabled(self):\n        os.environ[\"LD_ENABLE_OIDC\"] = \"True\"\n        base_settings = importlib.import_module(\"bookmarks.settings.base\")\n        importlib.reload(base_settings)\n\n        self.assertListEqual(\n            [\n                \"django.contrib.auth.backends.ModelBackend\",\n                \"mozilla_django_oidc.auth.OIDCAuthenticationBackend\",\n            ],\n            base_settings.AUTHENTICATION_BACKENDS,\n        )\n        del os.environ[\"LD_ENABLE_OIDC\"]  # Remove the temporary environment variable\n\n    def test_default_settings(self):\n        os.environ[\"LD_ENABLE_OIDC\"] = \"True\"\n        base_settings = importlib.import_module(\"bookmarks.settings.base\")\n        importlib.reload(base_settings)\n\n        self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)\n        self.assertEqual(\"openid email profile\", base_settings.OIDC_RP_SCOPES)\n        self.assertEqual(\"email\", base_settings.OIDC_USERNAME_CLAIM)\n\n        del os.environ[\"LD_ENABLE_OIDC\"]  # Remove the temporary environment variable\n\n    @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM=\"email\")\n    def test_username_should_use_email_by_default(self):\n        claims = {\n            \"email\": \"test@example.com\",\n            \"name\": \"test name\",\n            \"given_name\": \"test given name\",\n            \"preferred_username\": \"test preferred username\",\n            \"nickname\": \"test nickname\",\n            \"groups\": [],\n        }\n\n        username = utils.generate_username(claims[\"email\"], claims)\n\n        self.assertEqual(claims[\"email\"], username)\n\n    @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM=\"preferred_username\")\n    def test_username_should_use_custom_claim(self):\n        claims = {\n            \"email\": \"test@example.com\",\n            \"name\": \"test name\",\n            \"given_name\": \"test given name\",\n            \"preferred_username\": \"test preferred username\",\n            \"nickname\": \"test nickname\",\n            \"groups\": [],\n        }\n\n        username = utils.generate_username(claims[\"email\"], claims)\n\n        self.assertEqual(claims[\"preferred_username\"], username)\n\n    @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM=\"nonexistant_claim\")\n    def test_username_should_fallback_to_email_for_non_existing_claim(self):\n        claims = {\n            \"email\": \"test@example.com\",\n            \"name\": \"test name\",\n            \"given_name\": \"test given name\",\n            \"preferred_username\": \"test preferred username\",\n            \"nickname\": \"test nickname\",\n            \"groups\": [],\n        }\n\n        username = utils.generate_username(claims[\"email\"], claims)\n\n        self.assertEqual(claims[\"email\"], username)\n\n    @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM=\"preferred_username\")\n    def test_username_should_fallback_to_email_for_empty_claim(self):\n        claims = {\n            \"email\": \"test@example.com\",\n            \"name\": \"test name\",\n            \"given_name\": \"test given name\",\n            \"preferred_username\": \"\",\n            \"nickname\": \"test nickname\",\n            \"groups\": [],\n        }\n\n        username = utils.generate_username(claims[\"email\"], claims)\n\n        self.assertEqual(claims[\"email\"], username)\n\n    @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM=\"preferred_username\")\n    def test_username_should_be_normalized(self):\n        claims = {\n            \"email\": \"test@example.com\",\n            \"name\": \"test name\",\n            \"given_name\": \"test given name\",\n            \"preferred_username\": \"ＮｏｒｍａｌｉｚｅｄＵｓｅｒ\",\n            \"nickname\": \"test nickname\",\n            \"groups\": [],\n        }\n\n        username = utils.generate_username(claims[\"email\"], claims)\n\n        self.assertEqual(\"NormalizedUser\", username)\n"
  },
  {
    "path": "bookmarks/tests/test_opensearch_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\n\nclass OpenSearchViewTestCase(TestCase):\n    def test_opensearch_configuration(self):\n        response = self.client.get(reverse(\"linkding:opensearch\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(\n            response[\"content-type\"], \"application/opensearchdescription+xml\"\n        )\n\n        base_url = \"http://testserver\"\n        expected_content = f\"\"\"\n            <OpenSearchDescription xmlns=\"http://a9.com/-/spec/opensearch/1.1/\" xmlns:moz=\"http://www.mozilla.org/2006/browser/search/\">\n                <ShortName>Linkding</ShortName>\n                <Description>Linkding</Description>\n                <InputEncoding>UTF-8</InputEncoding>\n                <Image width=\"16\" height=\"16\" type=\"image/x-icon\">{base_url}/static/favicon.ico</Image>\n                <Url type=\"text/html\" template=\"{base_url}/bookmarks?client=opensearch&amp;q={{searchTerms}}\"/>\n            </OpenSearchDescription>\n        \"\"\"\n        content = response.content.decode()\n        self.assertXMLEqual(content, expected_content)\n"
  },
  {
    "path": "bookmarks/tests/test_pagination_tag.py",
    "content": "from django.core.paginator import Paginator\nfrom django.template import RequestContext, Template\nfrom django.test import RequestFactory, TestCase\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass PaginationTagTest(TestCase, BookmarkFactoryMixin):\n    def render_template(\n        self,\n        num_items: int,\n        page_size: int,\n        current_page: int,\n        url: str = \"/test\",\n        frame: str = None,\n    ) -> str:\n        rf = RequestFactory()\n        request = rf.get(url)\n        request.user = self.get_or_create_test_user()\n        request.user_profile = self.get_or_create_test_user().profile\n        paginator = Paginator(range(0, num_items), page_size)\n        page = paginator.page(current_page)\n\n        context_dict = {\"page\": page}\n        if frame:\n            context_dict[\"pagination_frame\"] = frame\n        context = RequestContext(request, context_dict)\n\n        template_to_render = Template(\"{% load pagination %}{% pagination page %}\")\n        return template_to_render.render(context)\n\n    def assertPrevLinkDisabled(self, html: str):\n        self.assertInHTML(\n            \"\"\"\n            <li class=\"page-item disabled\">\n                <a href=\"#\" tabindex=\"-1\">Previous</a>\n            </li>\n            \"\"\",\n            html,\n        )\n\n    def assertPrevLink(\n        self, html: str, page_number: int, href: str = None, frame: str = \"_top\"\n    ):\n        href = href if href else f\"/test?page={page_number}\"\n        self.assertInHTML(\n            f\"\"\"\n            <li class=\"page-item\">\n                <a href=\"{href}\" tabindex=\"-1\" data-turbo-frame=\"{frame}\">Previous</a>\n            </li>\n            \"\"\",\n            html,\n        )\n\n    def assertNextLinkDisabled(self, html: str):\n        self.assertInHTML(\n            \"\"\"\n            <li class=\"page-item disabled\">\n                <a href=\"#\" tabindex=\"-1\">Next</a>\n            </li>\n            \"\"\",\n            html,\n        )\n\n    def assertNextLink(\n        self, html: str, page_number: int, href: str = None, frame: str = \"_top\"\n    ):\n        href = href if href else f\"/test?page={page_number}\"\n        self.assertInHTML(\n            f\"\"\"\n            <li class=\"page-item\">\n                <a href=\"{href}\" tabindex=\"-1\" data-turbo-frame=\"{frame}\">Next</a>\n            </li>\n            \"\"\",\n            html,\n        )\n\n    def assertPageLink(\n        self,\n        html: str,\n        page_number: int,\n        active: bool,\n        count: int = 1,\n        href: str = None,\n        frame: str = \"_top\",\n    ):\n        active_class = \"active\" if active else \"\"\n        href = href if href else f\"/test?page={page_number}\"\n        self.assertInHTML(\n            f\"\"\"\n            <li class=\"page-item {active_class}\">\n                <a href=\"{href}\" data-turbo-frame=\"{frame}\">{page_number}</a>\n            </li>\n            \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertTruncationIndicators(self, html: str, count: int):\n        self.assertInHTML(\n            \"\"\"\n            <li class=\"page-item\">\n                <span>...</span>\n            </li>\n            \"\"\",\n            html,\n            count=count,\n        )\n\n    def test_previous_disabled_on_page_1(self):\n        rendered_template = self.render_template(100, 10, 1)\n        self.assertPrevLinkDisabled(rendered_template)\n\n    def test_previous_enabled_after_page_1(self):\n        for page_number in range(2, 10):\n            rendered_template = self.render_template(100, 10, page_number)\n            self.assertPrevLink(rendered_template, page_number - 1)\n\n    def test_next_disabled_on_last_page(self):\n        rendered_template = self.render_template(100, 10, 10)\n        self.assertNextLinkDisabled(rendered_template)\n\n    def test_next_enabled_before_last_page(self):\n        for page_number in range(1, 9):\n            rendered_template = self.render_template(100, 10, page_number)\n            self.assertNextLink(rendered_template, page_number + 1)\n\n    def test_truncate_pages_start(self):\n        current_page = 1\n        expected_visible_pages = [1, 2, 3, 10]\n        rendered_template = self.render_template(100, 10, current_page)\n        for page_number in range(1, 10):\n            expected_occurrences = 1 if page_number in expected_visible_pages else 0\n            self.assertPageLink(\n                rendered_template,\n                page_number,\n                page_number == current_page,\n                expected_occurrences,\n            )\n        self.assertTruncationIndicators(rendered_template, 1)\n\n    def test_truncate_pages_middle(self):\n        current_page = 5\n        expected_visible_pages = [1, 3, 4, 5, 6, 7, 10]\n        rendered_template = self.render_template(100, 10, current_page)\n        for page_number in range(1, 10):\n            expected_occurrences = 1 if page_number in expected_visible_pages else 0\n            self.assertPageLink(\n                rendered_template,\n                page_number,\n                page_number == current_page,\n                expected_occurrences,\n            )\n        self.assertTruncationIndicators(rendered_template, 2)\n\n    def test_truncate_pages_near_end(self):\n        current_page = 9\n        expected_visible_pages = [1, 7, 8, 9, 10]\n        rendered_template = self.render_template(100, 10, current_page)\n        for page_number in range(1, 10):\n            expected_occurrences = 1 if page_number in expected_visible_pages else 0\n            self.assertPageLink(\n                rendered_template,\n                page_number,\n                page_number == current_page,\n                expected_occurrences,\n            )\n        self.assertTruncationIndicators(rendered_template, 1)\n\n    def test_respects_search_parameters(self):\n        rendered_template = self.render_template(\n            100, 10, 2, url=\"/test?q=cake&sort=title_asc&page=2\"\n        )\n        self.assertPrevLink(\n            rendered_template,\n            1,\n            href=\"/test?q=cake&sort=title_asc&page=1\",\n        )\n        self.assertPageLink(\n            rendered_template,\n            1,\n            False,\n            href=\"/test?q=cake&sort=title_asc&page=1\",\n        )\n        self.assertPageLink(\n            rendered_template,\n            2,\n            True,\n            href=\"/test?q=cake&sort=title_asc&page=2\",\n        )\n        self.assertNextLink(\n            rendered_template,\n            3,\n            href=\"/test?q=cake&sort=title_asc&page=3\",\n        )\n\n    def test_removes_details_parameter(self):\n        rendered_template = self.render_template(\n            100, 10, 2, url=\"/test?details=1&page=2\"\n        )\n        self.assertPrevLink(rendered_template, 1, href=\"/test?page=1\")\n        self.assertPageLink(rendered_template, 1, False, href=\"/test?page=1\")\n        self.assertPageLink(rendered_template, 2, True, href=\"/test?page=2\")\n        self.assertNextLink(rendered_template, 3, href=\"/test?page=3\")\n\n    def test_respects_pagination_frame(self):\n        rendered_template = self.render_template(100, 10, 2, frame=\"my_frame\")\n        self.assertPrevLink(rendered_template, 1, frame=\"my_frame\")\n        self.assertPageLink(rendered_template, 1, False, frame=\"my_frame\")\n        self.assertPageLink(rendered_template, 2, True, frame=\"my_frame\")\n        self.assertNextLink(rendered_template, 3, frame=\"my_frame\")\n"
  },
  {
    "path": "bookmarks/tests/test_parser.py",
    "content": "from django.test import TestCase\n\nfrom bookmarks.models import parse_tag_string\nfrom bookmarks.services.parser import NetscapeBookmark, parse\nfrom bookmarks.tests.helpers import BookmarkHtmlTag, ImportTestMixin\n\n\nclass ParserTestCase(TestCase, ImportTestMixin):\n    def assertTagsEqual(\n        self, bookmarks: list[NetscapeBookmark], html_tags: list[BookmarkHtmlTag]\n    ):\n        self.assertEqual(len(bookmarks), len(html_tags))\n        for bookmark in bookmarks:\n            html_tag = html_tags[bookmarks.index(bookmark)]\n            self.assertEqual(bookmark.href, html_tag.href)\n            self.assertEqual(bookmark.title, html_tag.title)\n            self.assertEqual(bookmark.date_added, html_tag.add_date)\n            self.assertEqual(bookmark.date_modified, html_tag.last_modified)\n            self.assertEqual(bookmark.description, html_tag.description)\n            self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))\n            self.assertEqual(bookmark.to_read, html_tag.to_read)\n            self.assertEqual(bookmark.private, html_tag.private)\n\n    def test_parse_bookmarks(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                last_modified=\"11\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/foo\",\n                title=\"Foo title\",\n                description=\"\",\n                add_date=\"2\",\n                last_modified=\"22\",\n                tags=\"\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/bar\",\n                title=\"Bar title\",\n                description=\"Bar description\",\n                add_date=\"3\",\n                last_modified=\"33\",\n                tags=\"bar-tag, other-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://example.com/baz\",\n                title=\"Baz title\",\n                description=\"Baz description\",\n                add_date=\"4\",\n                to_read=True,\n            ),\n        ]\n        html = self.render_html(html_tags)\n        bookmarks = parse(html)\n\n        self.assertTagsEqual(bookmarks, html_tags)\n\n    def test_no_bookmarks(self):\n        html = self.render_html()\n        bookmarks = parse(html)\n\n        self.assertEqual(bookmarks, [])\n\n    def test_reset_properties_after_adding_bookmark(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                last_modified=\"1\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"\",\n                title=\"\",\n                description=\"\",\n                add_date=\"\",\n                last_modified=\"\",\n                tags=\"\",\n            ),\n        ]\n        html = self.render_html(html_tags)\n        bookmarks = parse(html)\n\n        self.assertTagsEqual(bookmarks, html_tags)\n\n    def test_empty_title(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"\",\n                description=\"Example description\",\n                add_date=\"1\",\n                tags=\"example-tag\",\n            ),\n        ]\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\" TAGS=\"example-tag\"></A>\n        <DD>Example description\n        \"\"\"\n        )\n        bookmarks = parse(html)\n\n        self.assertTagsEqual(bookmarks, html_tags)\n\n    def test_with_closing_description_tag(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://foo.com\",\n                title=\"Foo title\",\n                description=\"\",\n                add_date=\"2\",\n                tags=\"\",\n            ),\n        ]\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\" TAGS=\"example-tag\">Example title</A>\n        <DD>Example description</DD>\n        <DT><A HREF=\"https://foo.com\" ADD_DATE=\"2\">Foo title</A>\n        <DD></DD>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n\n        self.assertTagsEqual(bookmarks, html_tags)\n\n    def test_description_tag_before_anchor_tag(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://foo.com\",\n                title=\"Foo title\",\n                description=\"\",\n                add_date=\"2\",\n                tags=\"\",\n            ),\n        ]\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><DD>Example description</DD>\n        <A HREF=\"https://example.com\" ADD_DATE=\"1\" TAGS=\"example-tag\">Example title</A>\n        <DT><DD></DD>\n        <A HREF=\"https://foo.com\" ADD_DATE=\"2\">Foo title</A>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n\n        self.assertTagsEqual(bookmarks, html_tags)\n\n    def test_with_folders(self):\n        html_tags = [\n            BookmarkHtmlTag(\n                href=\"https://example.com\",\n                title=\"Example title\",\n                description=\"Example description\",\n                add_date=\"1\",\n                tags=\"example-tag\",\n            ),\n            BookmarkHtmlTag(\n                href=\"https://foo.com\",\n                title=\"Foo title\",\n                description=\"\",\n                add_date=\"2\",\n                tags=\"\",\n            ),\n        ]\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DL><p>\n            <DT><H3>Folder 1</H3>\n            <DL><p>\n                <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\" TAGS=\"example-tag\">Example title</A>\n                <DD>Example description\n            </DL><p>\n            <DT><H3>Folder 2</H3>\n            <DL><p>\n                <DT><A HREF=\"https://foo.com\" ADD_DATE=\"2\">Foo title</A>\n            </DL><p>\n        </DL><p>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n\n        self.assertTagsEqual(bookmarks, html_tags)\n\n    def test_private_flag(self):\n        # is private by default\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description</DD>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].private, True)\n\n        # explicitly marked as private\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\" PRIVATE=\"1\">Example title</A>\n        <DD>Example description</DD>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].private, True)\n\n        # explicitly marked as public\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\" PRIVATE=\"0\">Example title</A>\n        <DD>Example description</DD>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].private, False)\n\n    def test_notes(self):\n        # no description, no notes\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].description, \"\")\n        self.assertEqual(bookmarks[0].notes, \"\")\n\n        # description, no notes\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].description, \"Example description\")\n        self.assertEqual(bookmarks[0].notes, \"\")\n\n        # description, notes\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description[linkding-notes]Example notes[/linkding-notes]\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].description, \"Example description\")\n        self.assertEqual(bookmarks[0].notes, \"Example notes\")\n\n        # description, notes without closing tag\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description[linkding-notes]Example notes\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].description, \"Example description\")\n        self.assertEqual(bookmarks[0].notes, \"Example notes\")\n\n        # no description, notes\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">Example title</A>\n        <DD>[linkding-notes]Example notes[/linkding-notes]\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].description, \"\")\n        self.assertEqual(bookmarks[0].notes, \"Example notes\")\n\n        # notes reset between bookmarks\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com/1\" ADD_DATE=\"1\">Example title</A>\n        <DD>[linkding-notes]Example notes[/linkding-notes]\n        <DT><A HREF=\"https://example.com/2\" ADD_DATE=\"1\">Example title</A>\n        <DD>Example description\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].description, \"\")\n        self.assertEqual(bookmarks[0].notes, \"Example notes\")\n        self.assertEqual(bookmarks[1].description, \"Example description\")\n        self.assertEqual(bookmarks[1].notes, \"\")\n\n    def test_unescape_content(self):\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com\" ADD_DATE=\"1\">&lt;style&gt;: The Style Information element</A>\n        <DD>The &lt;style&gt; HTML element contains style information for a document, or part of a document.[linkding-notes]Interesting notes about the &lt;style&gt; HTML element.[/linkding-notes]\n        \"\"\"\n        )\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].title, \"<style>: The Style Information element\")\n        self.assertEqual(\n            bookmarks[0].description,\n            \"The <style> HTML element contains style information for a document, or part of a document.\",\n        )\n        self.assertEqual(\n            bookmarks[0].notes, \"Interesting notes about the <style> HTML element.\"\n        )\n\n    def test_unescape_href_attribute(self):\n        html = self.render_html(\n            tags_html=\"\"\"\n        <DT><A HREF=\"https://example.com&center=123\" ADD_DATE=\"1\">Imported bookmark</A>\n        <DD>Imported bookmark description\n        \"\"\"\n        )\n\n        bookmarks = parse(html)\n        self.assertEqual(bookmarks[0].href, \"https://example.com&center=123\")\n"
  },
  {
    "path": "bookmarks/tests/test_password_change_view.py",
    "content": "from django.contrib.auth.models import User\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.user = User.objects.create_user(\n            \"testuser\", \"test@example.com\", \"initial_password\"\n        )\n        self.client.force_login(self.user)\n\n    def test_change_password(self):\n        form_data = {\n            \"old_password\": \"initial_password\",\n            \"new_password1\": \"new_password\",\n            \"new_password2\": \"new_password\",\n        }\n\n        response = self.client.post(reverse(\"change_password\"), form_data)\n\n        self.assertRedirects(response, reverse(\"password_change_done\"))\n\n    def test_change_password_done(self):\n        form_data = {\n            \"old_password\": \"initial_password\",\n            \"new_password1\": \"new_password\",\n            \"new_password2\": \"new_password\",\n        }\n\n        response = self.client.post(reverse(\"change_password\"), form_data, follow=True)\n\n        self.assertContains(response, \"Your password was changed successfully\")\n\n    def test_should_return_error_for_invalid_old_password(self):\n        form_data = {\n            \"old_password\": \"wrong_password\",\n            \"new_password1\": \"new_password\",\n            \"new_password2\": \"new_password\",\n        }\n\n        response = self.client.post(reverse(\"change_password\"), form_data)\n\n        self.assertEqual(response.status_code, 422)\n        self.assertIn(\"old_password\", response.context_data[\"form\"].errors)\n\n    def test_should_return_error_for_mismatching_new_password(self):\n        form_data = {\n            \"old_password\": \"initial_password\",\n            \"new_password1\": \"new_password\",\n            \"new_password2\": \"wrong_password\",\n        }\n\n        response = self.client.post(reverse(\"change_password\"), form_data)\n\n        self.assertEqual(response.status_code, 422)\n        self.assertIn(\"new_password2\", response.context_data[\"form\"].errors)\n"
  },
  {
    "path": "bookmarks/tests/test_preview_image_loader.py",
    "content": "import io\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom django.conf import settings\nfrom django.test import TestCase\n\nfrom bookmarks.services import preview_image_loader\n\nmock_image_data = b\"mock_image\"\n\n\nclass MockStreamingResponse:\n    def __init__(\n        self,\n        url,\n        data=mock_image_data,\n        content_type=\"image/png\",\n        content_length=None,\n        status_code=200,\n    ):\n        self.url = url\n        self.chunks = [data]\n        self.status_code = status_code\n        if not content_length:\n            content_length = len(data)\n        self.headers = {\"Content-Type\": content_type, \"Content-Length\": content_length}\n\n    def iter_content(self, **kwargs):\n        return self.chunks\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n\nclass PreviewImageLoaderTestCase(TestCase):\n    def setUp(self) -> None:\n        self.temp_folder = tempfile.TemporaryDirectory()\n        self.settings_override = self.settings(LD_PREVIEW_FOLDER=self.temp_folder.name)\n        self.settings_override.enable()\n        self.mock_load_website_metadata_patcher = mock.patch(\n            \"bookmarks.services.website_loader.load_website_metadata\"\n        )\n        self.mock_load_website_metadata = (\n            self.mock_load_website_metadata_patcher.start()\n        )\n        self.mock_load_website_metadata.return_value = mock.Mock(\n            preview_image=\"https://example.com/image.png\"\n        )\n\n    def tearDown(self) -> None:\n        self.temp_folder.cleanup()\n        self.settings_override.disable()\n        self.mock_load_website_metadata_patcher.stop()\n\n    def create_mock_response(\n        self,\n        url=\"https://example.com/image.png\",\n        icon_data=mock_image_data,\n        content_type=\"image/png\",\n        content_length=None,\n        status_code=200,\n    ):\n        if not content_length:\n            content_length = len(icon_data)\n        mock_response = mock.Mock()\n        mock_response.raw = io.BytesIO(icon_data)\n        return MockStreamingResponse(\n            url, icon_data, content_type, content_length, status_code\n        )\n\n    def get_image_path(self, filename):\n        return Path(os.path.join(settings.LD_PREVIEW_FOLDER, filename))\n\n    def assertImageExists(self, filename, data):\n        self.assertTrue(self.get_image_path(filename).exists())\n        self.assertEqual(self.get_image_path(filename).read_bytes(), data)\n\n    def assertNoImageExists(self):\n        self.assertFalse(os.listdir(settings.LD_PREVIEW_FOLDER))\n\n    def test_load_preview_image(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertIsNotNone(file)\n            self.assertImageExists(file, mock_image_data)\n\n    def test_load_preview_image_returns_none_if_no_preview_image_detected(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n            self.mock_load_website_metadata.return_value = mock.Mock(preview_image=None)\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertIsNone(file)\n            self.assertNoImageExists()\n\n    def test_load_preview_image_returns_none_for_invalid_status_code(self):\n        invalid_status_codes = [199, 300, 400, 500]\n\n        for status_code in invalid_status_codes:\n            with mock.patch(\"requests.get\") as mock_get:\n                mock_get.return_value = self.create_mock_response(\n                    status_code=status_code\n                )\n\n                file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n                self.assertIsNone(file)\n                self.assertNoImageExists()\n\n    def test_load_preview_image_returns_none_if_content_length_exceeds_limit(self):\n        # exceeds max size\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(\n                content_length=settings.LD_PREVIEW_MAX_SIZE + 1\n            )\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertIsNone(file)\n            self.assertNoImageExists()\n\n        # equals max size\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(\n                content_length=settings.LD_PREVIEW_MAX_SIZE\n            )\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertIsNotNone(file)\n            self.assertImageExists(file, mock_image_data)\n\n    def test_load_preview_image_returns_none_for_invalid_content_type(self):\n        invalid_content_types = [\"text/html\", \"application/json\"]\n\n        for content_type in invalid_content_types:\n            with mock.patch(\"requests.get\") as mock_get:\n                mock_get.return_value = self.create_mock_response(\n                    content_type=content_type\n                )\n\n                file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n                self.assertIsNone(file)\n                self.assertNoImageExists()\n\n        valid_content_types = [\"image/png\", \"image/jpeg\", \"image/gif\"]\n\n        for content_type in valid_content_types:\n            with mock.patch(\"requests.get\") as mock_get:\n                mock_get.return_value = self.create_mock_response(\n                    content_type=content_type\n                )\n\n                file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n                self.assertIsNotNone(file)\n                self.assertImageExists(file, mock_image_data)\n\n    def test_load_preview_image_returns_none_if_download_exceeds_content_length(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(content_length=1)\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertIsNone(file)\n            self.assertNoImageExists()\n\n    def test_load_preview_image_creates_folder_if_not_exists(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response()\n\n            folder = Path(settings.LD_PREVIEW_FOLDER)\n            folder.rmdir()\n\n            self.assertFalse(folder.exists())\n\n            preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertTrue(folder.exists())\n\n    def test_guess_file_extension(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(content_type=\"image/png\")\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertImageExists(file, mock_image_data)\n            self.assertEqual(\"png\", file.split(\".\")[-1])\n\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = self.create_mock_response(content_type=\"image/jpeg\")\n\n            file = preview_image_loader.load_preview_image(\"https://example.com\")\n\n            self.assertImageExists(file, mock_image_data)\n            self.assertEqual(\"jpg\", file.split(\".\")[-1])\n"
  },
  {
    "path": "bookmarks/tests/test_queries.py",
    "content": "import datetime\nimport operator\n\nfrom django.db.models import QuerySet\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom bookmarks import queries\nfrom bookmarks.models import BookmarkBundle, BookmarkSearch, UserProfile\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence\nfrom bookmarks.utils import unique\n\n\nclass QueriesBasicTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.profile = self.get_or_create_test_user().profile\n\n    def setup_bookmark_search_data(self) -> None:\n        tag1 = self.setup_tag(name=\"tag1\")\n        tag2 = self.setup_tag(name=\"tag2\")\n        self.setup_tag(name=\"unused_tag1\")\n\n        self.other_bookmarks = [\n            self.setup_bookmark(),\n            self.setup_bookmark(),\n            self.setup_bookmark(),\n        ]\n        self.term1_bookmarks = [\n            self.setup_bookmark(url=\"http://example.com/term1\"),\n            self.setup_bookmark(title=random_sentence(including_word=\"term1\")),\n            self.setup_bookmark(title=random_sentence(including_word=\"TERM1\")),\n            self.setup_bookmark(description=random_sentence(including_word=\"term1\")),\n            self.setup_bookmark(description=random_sentence(including_word=\"TERM1\")),\n            self.setup_bookmark(notes=random_sentence(including_word=\"term1\")),\n            self.setup_bookmark(notes=random_sentence(including_word=\"TERM1\")),\n        ]\n        self.term1_term2_bookmarks = [\n            self.setup_bookmark(url=\"http://example.com/term1/term2\"),\n            self.setup_bookmark(\n                title=random_sentence(including_word=\"term1\"),\n                description=random_sentence(including_word=\"term2\"),\n            ),\n            self.setup_bookmark(\n                description=random_sentence(including_word=\"term1\"),\n                title=random_sentence(including_word=\"term2\"),\n            ),\n        ]\n        self.tag1_bookmarks = [\n            self.setup_bookmark(tags=[tag1]),\n            self.setup_bookmark(title=random_sentence(), tags=[tag1]),\n            self.setup_bookmark(description=random_sentence(), tags=[tag1]),\n        ]\n        self.tag1_as_term_bookmarks = [\n            self.setup_bookmark(url=\"http://example.com/tag1\"),\n            self.setup_bookmark(title=random_sentence(including_word=\"tag1\")),\n            self.setup_bookmark(description=random_sentence(including_word=\"tag1\")),\n        ]\n        self.term1_tag1_bookmarks = [\n            self.setup_bookmark(url=\"http://example.com/term1\", tags=[tag1]),\n            self.setup_bookmark(\n                title=random_sentence(including_word=\"term1\"), tags=[tag1]\n            ),\n            self.setup_bookmark(\n                description=random_sentence(including_word=\"term1\"), tags=[tag1]\n            ),\n        ]\n        self.tag2_bookmarks = [\n            self.setup_bookmark(tags=[tag2]),\n        ]\n        self.tag1_tag2_bookmarks = [\n            self.setup_bookmark(tags=[tag1, tag2]),\n        ]\n\n    def setup_tag_search_data(self):\n        tag1 = self.setup_tag(name=\"tag1\")\n        tag2 = self.setup_tag(name=\"tag2\")\n        self.setup_tag(name=\"unused_tag1\")\n\n        self.other_bookmarks = [\n            self.setup_bookmark(tags=[self.setup_tag()]),\n            self.setup_bookmark(tags=[self.setup_tag()]),\n            self.setup_bookmark(tags=[self.setup_tag()]),\n        ]\n        self.term1_bookmarks = [\n            self.setup_bookmark(\n                url=\"http://example.com/term1\", tags=[self.setup_tag()]\n            ),\n            self.setup_bookmark(\n                title=random_sentence(including_word=\"term1\"), tags=[self.setup_tag()]\n            ),\n            self.setup_bookmark(\n                title=random_sentence(including_word=\"TERM1\"), tags=[self.setup_tag()]\n            ),\n            self.setup_bookmark(\n                description=random_sentence(including_word=\"term1\"),\n                tags=[self.setup_tag()],\n            ),\n            self.setup_bookmark(\n                description=random_sentence(including_word=\"TERM1\"),\n                tags=[self.setup_tag()],\n            ),\n            self.setup_bookmark(\n                notes=random_sentence(including_word=\"term1\"), tags=[self.setup_tag()]\n            ),\n            self.setup_bookmark(\n                notes=random_sentence(including_word=\"TERM1\"), tags=[self.setup_tag()]\n            ),\n        ]\n        self.term1_term2_bookmarks = [\n            self.setup_bookmark(\n                url=\"http://example.com/term1/term2\", tags=[self.setup_tag()]\n            ),\n            self.setup_bookmark(\n                title=random_sentence(including_word=\"term1\"),\n                description=random_sentence(including_word=\"term2\"),\n                tags=[self.setup_tag()],\n            ),\n            self.setup_bookmark(\n                description=random_sentence(including_word=\"term1\"),\n                title=random_sentence(including_word=\"term2\"),\n                tags=[self.setup_tag()],\n            ),\n        ]\n        self.tag1_bookmarks = [\n            self.setup_bookmark(tags=[tag1, self.setup_tag()]),\n            self.setup_bookmark(title=random_sentence(), tags=[tag1, self.setup_tag()]),\n            self.setup_bookmark(\n                description=random_sentence(), tags=[tag1, self.setup_tag()]\n            ),\n        ]\n        self.tag1_as_term_bookmarks = [\n            self.setup_bookmark(url=\"http://example.com/tag1\"),\n            self.setup_bookmark(title=random_sentence(including_word=\"tag1\")),\n            self.setup_bookmark(description=random_sentence(including_word=\"tag1\")),\n        ]\n        self.term1_tag1_bookmarks = [\n            self.setup_bookmark(\n                url=\"http://example.com/term1\", tags=[tag1, self.setup_tag()]\n            ),\n            self.setup_bookmark(\n                title=random_sentence(including_word=\"term1\"),\n                tags=[tag1, self.setup_tag()],\n            ),\n            self.setup_bookmark(\n                description=random_sentence(including_word=\"term1\"),\n                tags=[tag1, self.setup_tag()],\n            ),\n        ]\n        self.tag2_bookmarks = [\n            self.setup_bookmark(tags=[tag2, self.setup_tag()]),\n        ]\n        self.tag1_tag2_bookmarks = [\n            self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),\n        ]\n\n    def assertQueryResult(self, query: QuerySet, item_lists: list[list]):\n        expected_items = []\n        for item_list in item_lists:\n            expected_items = expected_items + item_list\n\n        expected_items = unique(expected_items, operator.attrgetter(\"id\"))\n\n        self.assertCountEqual(list(query), expected_items)\n\n    def test_query_bookmarks_should_return_all_for_empty_query(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=\"\"))\n        self.assertQueryResult(\n            query,\n            [\n                self.other_bookmarks,\n                self.term1_bookmarks,\n                self.term1_term2_bookmarks,\n                self.tag1_bookmarks,\n                self.tag1_as_term_bookmarks,\n                self.term1_tag1_bookmarks,\n                self.tag2_bookmarks,\n                self.tag1_tag2_bookmarks,\n            ],\n        )\n\n    def test_query_bookmarks_should_search_single_term(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term1\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.term1_bookmarks,\n                self.term1_term2_bookmarks,\n                self.term1_tag1_bookmarks,\n            ],\n        )\n\n    def test_query_bookmarks_should_search_multiple_terms(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term2 term1\")\n        )\n\n        self.assertQueryResult(query, [self.term1_term2_bookmarks])\n\n    def test_query_bookmarks_should_search_single_tag(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"#tag1\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks],\n        )\n\n    def test_query_bookmarks_should_search_multiple_tags(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"#tag1 #tag2\")\n        )\n\n        self.assertQueryResult(query, [self.tag1_tag2_bookmarks])\n\n    def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"#Tag1 #TAG2\")\n        )\n\n        self.assertQueryResult(query, [self.tag1_tag2_bookmarks])\n\n    def test_query_bookmarks_should_search_terms_and_tags_combined(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term1 #tag1\")\n        )\n\n        self.assertQueryResult(query, [self.term1_tag1_bookmarks])\n\n    def test_query_bookmarks_in_strict_mode_should_not_search_tags_as_terms(self):\n        self.setup_bookmark_search_data()\n\n        self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT\n        self.profile.save()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"tag1\")\n        )\n        self.assertQueryResult(query, [self.tag1_as_term_bookmarks])\n\n    def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self):\n        self.setup_bookmark_search_data()\n\n        self.profile.tag_search = UserProfile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"tag1\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.tag1_bookmarks,\n                self.tag1_as_term_bookmarks,\n                self.tag1_tag2_bookmarks,\n                self.term1_tag1_bookmarks,\n            ],\n        )\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"tag1 term1\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.term1_tag1_bookmarks,\n            ],\n        )\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"tag1 tag2\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.tag1_tag2_bookmarks,\n            ],\n        )\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"tag1 #tag2\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.tag1_tag2_bookmarks,\n            ],\n        )\n\n    def test_query_bookmarks_should_return_no_matches(self):\n        self.setup_bookmark_search_data()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term3\")\n        )\n        self.assertQueryResult(query, [])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term1 term3\")\n        )\n        self.assertQueryResult(query, [])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term1 #tag2\")\n        )\n        self.assertQueryResult(query, [])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"#tag3\")\n        )\n        self.assertQueryResult(query, [])\n\n        # Unused tag\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"#unused_tag1\")\n        )\n        self.assertQueryResult(query, [])\n\n        # Unused tag combined with tag that is used\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"#tag1 #unused_tag1\")\n        )\n        self.assertQueryResult(query, [])\n\n        # Unused tag combined with term that is used\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"term1 #unused_tag1\")\n        )\n        self.assertQueryResult(query, [])\n\n    def test_query_bookmarks_should_not_return_archived_bookmarks(self):\n        bookmark1 = self.setup_bookmark()\n        bookmark2 = self.setup_bookmark()\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True)\n\n        query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=\"\"))\n\n        self.assertQueryResult(query, [[bookmark1, bookmark2]])\n\n    def test_query_archived_bookmarks_should_not_return_unarchived_bookmarks(self):\n        bookmark1 = self.setup_bookmark(is_archived=True)\n        bookmark2 = self.setup_bookmark(is_archived=True)\n        self.setup_bookmark()\n        self.setup_bookmark()\n        self.setup_bookmark()\n\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [[bookmark1, bookmark2]])\n\n    def test_query_bookmarks_should_only_return_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        owned_bookmarks = [\n            self.setup_bookmark(),\n            self.setup_bookmark(),\n            self.setup_bookmark(),\n        ]\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n        self.setup_bookmark(user=other_user)\n\n        query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=\"\"))\n\n        self.assertQueryResult(query, [owned_bookmarks])\n\n    def test_query_archived_bookmarks_should_only_return_user_owned_bookmarks(self):\n        other_user = self.setup_user()\n        owned_bookmarks = [\n            self.setup_bookmark(is_archived=True),\n            self.setup_bookmark(is_archived=True),\n            self.setup_bookmark(is_archived=True),\n        ]\n        self.setup_bookmark(is_archived=True, user=other_user)\n        self.setup_bookmark(is_archived=True, user=other_user)\n        self.setup_bookmark(is_archived=True, user=other_user)\n\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [owned_bookmarks])\n\n    def test_query_bookmarks_untagged_should_return_untagged_bookmarks_only(self):\n        tag = self.setup_tag()\n        untagged_bookmark = self.setup_bookmark()\n        self.setup_bookmark(tags=[tag])\n        self.setup_bookmark(tags=[tag])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged\")\n        )\n        self.assertCountEqual(list(query), [untagged_bookmark])\n\n    def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):\n        tag = self.setup_tag()\n        untagged_bookmark = self.setup_bookmark(title=\"term1\")\n        self.setup_bookmark(title=\"term2\")\n        self.setup_bookmark(tags=[tag])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged term1\")\n        )\n        self.assertCountEqual(list(query), [untagged_bookmark])\n\n    def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):\n        tag = self.setup_tag()\n        self.setup_bookmark()\n        self.setup_bookmark(tags=[tag])\n        self.setup_bookmark(tags=[tag])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=f\"!untagged #{tag.name}\")\n        )\n        self.assertCountEqual(list(query), [])\n\n    def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(\n        self,\n    ):\n        tag = self.setup_tag()\n        untagged_bookmark = self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True, tags=[tag])\n        self.setup_bookmark(is_archived=True, tags=[tag])\n\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged\")\n        )\n        self.assertCountEqual(list(query), [untagged_bookmark])\n\n    def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(\n        self,\n    ):\n        tag = self.setup_tag()\n        untagged_bookmark = self.setup_bookmark(is_archived=True, title=\"term1\")\n        self.setup_bookmark(is_archived=True, title=\"term2\")\n        self.setup_bookmark(is_archived=True, tags=[tag])\n\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged term1\")\n        )\n        self.assertCountEqual(list(query), [untagged_bookmark])\n\n    def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):\n        tag = self.setup_tag()\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True, tags=[tag])\n        self.setup_bookmark(is_archived=True, tags=[tag])\n\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=f\"!untagged #{tag.name}\")\n        )\n        self.assertCountEqual(list(query), [])\n\n    def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):\n        unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)\n        read_bookmarks = self.setup_numbered_bookmarks(5, unread=False)\n\n        # Legacy query filter\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"!unread\")\n        )\n        self.assertCountEqual(list(query), unread_bookmarks)\n\n        # Bookmark search filter - off\n        query = queries.query_bookmarks(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_OFF),\n        )\n        self.assertCountEqual(list(query), read_bookmarks + unread_bookmarks)\n\n        # Bookmark search filter - yes\n        query = queries.query_bookmarks(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_YES),\n        )\n        self.assertCountEqual(list(query), unread_bookmarks)\n\n        # Bookmark search filter - no\n        query = queries.query_bookmarks(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_NO),\n        )\n        self.assertCountEqual(list(query), read_bookmarks)\n\n    def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):\n        unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True, archived=True)\n        read_bookmarks = self.setup_numbered_bookmarks(5, unread=False, archived=True)\n\n        # Legacy query filter\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"!unread\")\n        )\n        self.assertCountEqual(list(query), unread_bookmarks)\n\n        # Bookmark search filter - off\n        query = queries.query_archived_bookmarks(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_OFF),\n        )\n        self.assertCountEqual(list(query), read_bookmarks + unread_bookmarks)\n\n        # Bookmark search filter - yes\n        query = queries.query_archived_bookmarks(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_YES),\n        )\n        self.assertCountEqual(list(query), unread_bookmarks)\n\n        # Bookmark search filter - no\n        query = queries.query_archived_bookmarks(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_NO),\n        )\n        self.assertCountEqual(list(query), read_bookmarks)\n\n    def test_query_bookmarks_filter_shared(self):\n        unshared_bookmarks = self.setup_numbered_bookmarks(5)\n        shared_bookmarks = self.setup_numbered_bookmarks(5, shared=True)\n\n        # Filter is off\n        search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_OFF)\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), unshared_bookmarks + shared_bookmarks)\n\n        # Filter for shared\n        search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_SHARED)\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), shared_bookmarks)\n\n        # Filter for unshared\n        search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_UNSHARED)\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), unshared_bookmarks)\n\n    def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.other_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_term2_bookmarks),\n                self.get_tags_from_bookmarks(self.tag1_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),\n                self.get_tags_from_bookmarks(self.tag2_bookmarks),\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_search_single_term(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term1\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.term1_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_term2_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_search_multiple_terms(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term2 term1\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.term1_term2_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_search_single_tag(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"#tag1\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.tag1_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_search_multiple_tags(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"#tag1 #tag2\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"#Tag1 #TAG2\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_search_term_and_tag_combined(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term1 #tag1\")\n        )\n\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_in_strict_mode_should_not_search_tags_as_terms(self):\n        self.setup_tag_search_data()\n\n        self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT\n        self.profile.save()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"tag1\")\n        )\n        self.assertQueryResult(\n            query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks)\n        )\n\n    def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self):\n        self.setup_tag_search_data()\n\n        self.profile.tag_search = UserProfile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"tag1\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.tag1_bookmarks),\n                self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks),\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n                self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),\n            ],\n        )\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"tag1 term1\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),\n            ],\n        )\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"tag1 tag2\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n            ],\n        )\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"tag1 #tag2\")\n        )\n        self.assertQueryResult(\n            query,\n            [\n                self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),\n            ],\n        )\n\n    def test_query_bookmark_tags_should_return_no_matches(self):\n        self.setup_tag_search_data()\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term3\")\n        )\n        self.assertQueryResult(query, [])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term1 term3\")\n        )\n        self.assertQueryResult(query, [])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term1 #tag2\")\n        )\n        self.assertQueryResult(query, [])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"#tag3\")\n        )\n        self.assertQueryResult(query, [])\n\n        # Unused tag\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"#unused_tag1\")\n        )\n        self.assertQueryResult(query, [])\n\n        # Unused tag combined with tag that is used\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"#tag1 #unused_tag1\")\n        )\n        self.assertQueryResult(query, [])\n\n        # Unused tag combined with term that is used\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"term1 #unused_tag1\")\n        )\n        self.assertQueryResult(query, [])\n\n    def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark()\n        self.setup_bookmark(is_archived=True, tags=[tag2])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [[tag1]])\n\n    def test_query_bookmark_tags_should_return_distinct_tags(self):\n        tag = self.setup_tag()\n        self.setup_bookmark(tags=[tag])\n        self.setup_bookmark(tags=[tag])\n        self.setup_bookmark(tags=[tag])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [[tag]])\n\n    def test_query_archived_bookmark_tags_should_return_tags_for_archived_bookmarks_only(\n        self,\n    ):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark()\n        self.setup_bookmark(is_archived=True, tags=[tag2])\n\n        query = queries.query_archived_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [[tag2]])\n\n    def test_query_archived_bookmark_tags_should_return_distinct_tags(self):\n        tag = self.setup_tag()\n        self.setup_bookmark(is_archived=True, tags=[tag])\n        self.setup_bookmark(is_archived=True, tags=[tag])\n        self.setup_bookmark(is_archived=True, tags=[tag])\n\n        query = queries.query_archived_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [[tag]])\n\n    def test_query_bookmark_tags_should_only_return_user_owned_tags(self):\n        other_user = self.setup_user()\n        owned_bookmarks = [\n            self.setup_bookmark(tags=[self.setup_tag()]),\n            self.setup_bookmark(tags=[self.setup_tag()]),\n            self.setup_bookmark(tags=[self.setup_tag()]),\n        ]\n        self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])\n        self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])\n        self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])\n\n    def test_query_archived_bookmark_tags_should_only_return_user_owned_tags(self):\n        other_user = self.setup_user()\n        owned_bookmarks = [\n            self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),\n            self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),\n            self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),\n        ]\n        self.setup_bookmark(\n            is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)]\n        )\n        self.setup_bookmark(\n            is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)]\n        )\n        self.setup_bookmark(\n            is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)]\n        )\n\n        query = queries.query_archived_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"\")\n        )\n\n        self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])\n\n    def test_query_bookmark_tags_untagged_should_never_return_any_tags(self):\n        tag = self.setup_tag()\n        self.setup_bookmark()\n        self.setup_bookmark(title=\"term1\")\n        self.setup_bookmark(title=\"term1\", tags=[tag])\n        self.setup_bookmark(tags=[tag])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged\")\n        )\n        self.assertCountEqual(list(query), [])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged term1\")\n        )\n        self.assertCountEqual(list(query), [])\n\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=f\"!untagged #{tag.name}\")\n        )\n        self.assertCountEqual(list(query), [])\n\n    def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):\n        tag = self.setup_tag()\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(is_archived=True, title=\"term1\")\n        self.setup_bookmark(is_archived=True, title=\"term1\", tags=[tag])\n        self.setup_bookmark(is_archived=True, tags=[tag])\n\n        query = queries.query_archived_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged\")\n        )\n        self.assertCountEqual(list(query), [])\n\n        query = queries.query_archived_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"!untagged term1\")\n        )\n        self.assertCountEqual(list(query), [])\n\n        query = queries.query_archived_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=f\"!untagged #{tag.name}\")\n        )\n        self.assertCountEqual(list(query), [])\n\n    def test_query_bookmark_tags_filter_unread(self):\n        unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True, with_tags=True)\n        read_bookmarks = self.setup_numbered_bookmarks(5, unread=False, with_tags=True)\n        unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)\n        read_tags = self.get_tags_from_bookmarks(read_bookmarks)\n\n        # Legacy query filter\n        query = queries.query_bookmark_tags(\n            self.user, self.profile, BookmarkSearch(q=\"!unread\")\n        )\n        self.assertCountEqual(list(query), unread_tags)\n\n        # Bookmark search filter - off\n        query = queries.query_bookmark_tags(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_OFF),\n        )\n        self.assertCountEqual(list(query), read_tags + unread_tags)\n\n        # Bookmark search filter - yes\n        query = queries.query_bookmark_tags(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_YES),\n        )\n        self.assertCountEqual(list(query), unread_tags)\n\n        # Bookmark search filter - no\n        query = queries.query_bookmark_tags(\n            self.user,\n            self.profile,\n            BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_NO),\n        )\n        self.assertCountEqual(list(query), read_tags)\n\n    def test_query_bookmark_tags_filter_shared(self):\n        unshared_bookmarks = self.setup_numbered_bookmarks(5, with_tags=True)\n        shared_bookmarks = self.setup_numbered_bookmarks(5, with_tags=True, shared=True)\n\n        unshared_tags = self.get_tags_from_bookmarks(unshared_bookmarks)\n        shared_tags = self.get_tags_from_bookmarks(shared_bookmarks)\n        all_tags = unshared_tags + shared_tags\n\n        # Filter is off\n        search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_OFF)\n        query = queries.query_bookmark_tags(self.user, self.profile, search)\n        self.assertCountEqual(list(query), all_tags)\n\n        # Filter for shared\n        search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_SHARED)\n        query = queries.query_bookmark_tags(self.user, self.profile, search)\n        self.assertCountEqual(list(query), shared_tags)\n\n        # Filter for unshared\n        search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_UNSHARED)\n        query = queries.query_bookmark_tags(self.user, self.profile, search)\n        self.assertCountEqual(list(query), unshared_tags)\n\n    def test_query_shared_bookmarks(self):\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        user4 = self.setup_user(enable_sharing=False)\n        tag = self.setup_tag()\n\n        shared_bookmarks = [\n            self.setup_bookmark(user=user1, shared=True, title=\"test title\"),\n            self.setup_bookmark(user=user2, shared=True),\n            self.setup_bookmark(user=user3, shared=True, tags=[tag]),\n        ]\n\n        # Unshared bookmarks\n        self.setup_bookmark(user=user1, shared=False, title=\"test title\")\n        self.setup_bookmark(user=user2, shared=False)\n        self.setup_bookmark(user=user3, shared=False, tags=[tag])\n        self.setup_bookmark(user=user4, shared=True, tags=[tag])\n\n        # Should return shared bookmarks from all users\n        query_set = queries.query_shared_bookmarks(\n            None, self.profile, BookmarkSearch(q=\"\"), False\n        )\n        self.assertQueryResult(query_set, [shared_bookmarks])\n\n        # Should respect search query\n        query_set = queries.query_shared_bookmarks(\n            None, self.profile, BookmarkSearch(q=\"test title\"), False\n        )\n        self.assertQueryResult(query_set, [[shared_bookmarks[0]]])\n\n        query_set = queries.query_shared_bookmarks(\n            None, self.profile, BookmarkSearch(q=f\"#{tag.name}\"), False\n        )\n        self.assertQueryResult(query_set, [[shared_bookmarks[2]]])\n\n    def test_query_publicly_shared_bookmarks(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        bookmark1 = self.setup_bookmark(user=user1, shared=True)\n        self.setup_bookmark(user=user2, shared=True)\n\n        query_set = queries.query_shared_bookmarks(\n            None, self.profile, BookmarkSearch(q=\"\"), True\n        )\n        self.assertQueryResult(query_set, [[bookmark1]])\n\n    def test_query_shared_bookmark_tags(self):\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n        user3 = self.setup_user(enable_sharing=True)\n        user4 = self.setup_user(enable_sharing=False)\n\n        shared_tags = [\n            self.setup_tag(user=user1),\n            self.setup_tag(user=user2),\n            self.setup_tag(user=user3),\n        ]\n\n        self.setup_bookmark(user=user1, shared=True, tags=[shared_tags[0]])\n        self.setup_bookmark(user=user2, shared=True, tags=[shared_tags[1]])\n        self.setup_bookmark(user=user3, shared=True, tags=[shared_tags[2]])\n\n        self.setup_bookmark(user=user1, shared=False, tags=[self.setup_tag(user=user1)])\n        self.setup_bookmark(user=user2, shared=False, tags=[self.setup_tag(user=user2)])\n        self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)])\n        self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)])\n\n        query_set = queries.query_shared_bookmark_tags(\n            None, self.profile, BookmarkSearch(q=\"\"), False\n        )\n\n        self.assertQueryResult(query_set, [shared_tags])\n\n    def test_query_publicly_shared_bookmark_tags(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        tag1 = self.setup_tag(user=user1)\n        tag2 = self.setup_tag(user=user2)\n\n        self.setup_bookmark(user=user1, shared=True, tags=[tag1])\n        self.setup_bookmark(user=user2, shared=True, tags=[tag2])\n\n        query_set = queries.query_shared_bookmark_tags(\n            None, self.profile, BookmarkSearch(q=\"\"), True\n        )\n\n        self.assertQueryResult(query_set, [[tag1]])\n\n    def test_query_shared_bookmark_users(self):\n        users_with_shared_bookmarks = [\n            self.setup_user(enable_sharing=True),\n            self.setup_user(enable_sharing=True),\n        ]\n        users_without_shared_bookmarks = [\n            self.setup_user(enable_sharing=True),\n            self.setup_user(enable_sharing=True),\n            self.setup_user(enable_sharing=False),\n        ]\n\n        # Shared bookmarks\n        self.setup_bookmark(\n            user=users_with_shared_bookmarks[0], shared=True, title=\"test title\"\n        )\n        self.setup_bookmark(user=users_with_shared_bookmarks[1], shared=True)\n\n        # Unshared bookmarks\n        self.setup_bookmark(\n            user=users_without_shared_bookmarks[0], shared=False, title=\"test title\"\n        )\n        self.setup_bookmark(user=users_without_shared_bookmarks[1], shared=False)\n        self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True)\n\n        # Should return users with shared bookmarks\n        query_set = queries.query_shared_bookmark_users(\n            self.profile, BookmarkSearch(q=\"\"), False\n        )\n        self.assertQueryResult(query_set, [users_with_shared_bookmarks])\n\n        # Should respect search query\n        query_set = queries.query_shared_bookmark_users(\n            self.profile, BookmarkSearch(q=\"test title\"), False\n        )\n        self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])\n\n    def test_query_publicly_shared_bookmark_users(self):\n        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        self.setup_bookmark(user=user1, shared=True)\n        self.setup_bookmark(user=user2, shared=True)\n\n        query_set = queries.query_shared_bookmark_users(\n            self.profile, BookmarkSearch(q=\"\"), True\n        )\n        self.assertQueryResult(query_set, [[user1]])\n\n    def test_sorty_by_date_added_asc(self):\n        search = BookmarkSearch(sort=BookmarkSearch.SORT_ADDED_ASC)\n\n        bookmarks = [\n            self.setup_bookmark(\n                added=timezone.datetime(2020, 1, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2021, 2, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2022, 3, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2023, 4, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2022, 5, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2021, 6, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2020, 7, 1, tzinfo=datetime.UTC)\n            ),\n        ]\n        sorted_bookmarks = sorted(bookmarks, key=lambda b: b.date_added)\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertEqual(list(query), sorted_bookmarks)\n\n    def test_sorty_by_date_added_desc(self):\n        search = BookmarkSearch(sort=BookmarkSearch.SORT_ADDED_DESC)\n\n        bookmarks = [\n            self.setup_bookmark(\n                added=timezone.datetime(2020, 1, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2021, 2, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2022, 3, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2023, 4, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2022, 5, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2021, 6, 1, tzinfo=datetime.UTC)\n            ),\n            self.setup_bookmark(\n                added=timezone.datetime(2020, 7, 1, tzinfo=datetime.UTC)\n            ),\n        ]\n        sorted_bookmarks = sorted(bookmarks, key=lambda b: b.date_added, reverse=True)\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertEqual(list(query), sorted_bookmarks)\n\n    def setup_title_sort_data(self):\n        # lots of combinations to test effective title logic\n        bookmarks = [\n            self.setup_bookmark(title=\"a_1_1\"),\n            self.setup_bookmark(title=\"A_1_2\"),\n            self.setup_bookmark(title=\"b_1_1\"),\n            self.setup_bookmark(title=\"B_1_2\"),\n            self.setup_bookmark(title=\"\", url=\"a_3_1\"),\n            self.setup_bookmark(title=\"\", url=\"A_3_2\"),\n            self.setup_bookmark(title=\"\", url=\"b_3_1\"),\n            self.setup_bookmark(title=\"\", url=\"B_3_2\"),\n            self.setup_bookmark(title=\"a_5_1\", url=\"0\"),\n            self.setup_bookmark(title=\"A_5_2\", url=\"0\"),\n            self.setup_bookmark(title=\"b_5_1\", url=\"0\"),\n            self.setup_bookmark(title=\"B_5_2\", url=\"0\"),\n            self.setup_bookmark(title=\"\", url=\"0\"),\n            self.setup_bookmark(title=\"\", url=\"0\"),\n            self.setup_bookmark(title=\"\", url=\"0\"),\n            self.setup_bookmark(title=\"\", url=\"0\"),\n        ]\n        return bookmarks\n\n    def test_sort_by_title_asc(self):\n        search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_ASC)\n\n        bookmarks = self.setup_title_sort_data()\n        sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower())\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n\n        # Use resolved title for comparison as Postgres returns bookmarks with same resolved title in random order\n        expected_effective_titles = [b.resolved_title for b in sorted_bookmarks]\n        actual_effective_titles = [b.resolved_title for b in query]\n        self.assertEqual(expected_effective_titles, actual_effective_titles)\n\n    def test_sort_by_title_desc(self):\n        search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC)\n\n        bookmarks = self.setup_title_sort_data()\n        sorted_bookmarks = sorted(\n            bookmarks, key=lambda b: b.resolved_title.lower(), reverse=True\n        )\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n\n        # Use resolved title for comparison as Postgres returns bookmarks with same resolved title in random order\n        expected_effective_titles = [b.resolved_title for b in sorted_bookmarks]\n        actual_effective_titles = [b.resolved_title for b in query]\n        self.assertEqual(expected_effective_titles, actual_effective_titles)\n\n    def test_query_bookmarks_filter_modified_since(self):\n        # Create bookmarks with different modification dates\n        older_bookmark = self.setup_bookmark(title=\"old bookmark\")\n        recent_bookmark = self.setup_bookmark(title=\"recent bookmark\")\n\n        # Modify date field on bookmark directly to test modified_since\n        older_bookmark.date_modified = timezone.datetime(\n            2025, 1, 1, tzinfo=datetime.UTC\n        )\n        older_bookmark.save()\n        recent_bookmark.date_modified = timezone.datetime(\n            2025, 5, 15, tzinfo=datetime.UTC\n        )\n        recent_bookmark.save()\n\n        # Test with date between the two bookmarks\n        search = BookmarkSearch(modified_since=\"2025-03-01T00:00:00Z\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [recent_bookmark])\n\n        # Test with date before both bookmarks\n        search = BookmarkSearch(modified_since=\"2024-12-31T00:00:00Z\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])\n\n        # Test with date after both bookmarks\n        search = BookmarkSearch(modified_since=\"2025-05-16T00:00:00Z\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [])\n\n        # Test with no modified_since - should return all bookmarks\n        search = BookmarkSearch()\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])\n\n        # Test with invalid date format - should be ignored\n        search = BookmarkSearch(modified_since=\"invalid-date\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])\n\n    def test_query_bookmarks_filter_added_since(self):\n        # Create bookmarks with different dates\n        older_bookmark = self.setup_bookmark(\n            title=\"old bookmark\",\n            added=timezone.datetime(2025, 1, 1, tzinfo=datetime.UTC),\n        )\n        recent_bookmark = self.setup_bookmark(\n            title=\"recent bookmark\",\n            added=timezone.datetime(2025, 5, 15, tzinfo=datetime.UTC),\n        )\n\n        # Test with date between the two bookmarks\n        search = BookmarkSearch(added_since=\"2025-03-01T00:00:00Z\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [recent_bookmark])\n\n        # Test with date before both bookmarks\n        search = BookmarkSearch(added_since=\"2024-12-31T00:00:00Z\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])\n\n        # Test with date after both bookmarks\n        search = BookmarkSearch(added_since=\"2025-05-16T00:00:00Z\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [])\n\n        # Test with no added_since - should return all bookmarks\n        search = BookmarkSearch()\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])\n\n        # Test with invalid date format - should be ignored\n        search = BookmarkSearch(added_since=\"invalid-date\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])\n\n    def test_query_bookmarks_with_bundle_search_terms(self):\n        bundle = self.setup_bundle(search=\"search_term_A search_term_B\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(\n                title=\"search_term_A content\", description=\"search_term_B also here\"\n            ),\n            self.setup_bookmark(url=\"http://example.com/search_term_A/search_term_B\"),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(title=\"search_term_A only\")\n        self.setup_bookmark(description=\"search_term_B only\")\n        self.setup_bookmark(title=\"unrelated content\")\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_search_and_bundle_search_terms(self):\n        bundle = self.setup_bundle(search=\"bundle_term_B\")\n        search = BookmarkSearch(q=\"search_term_A\", bundle=bundle)\n\n        matching_bookmarks = [\n            self.setup_bookmark(\n                title=\"search_term_A content\", description=\"bundle_term_B also here\"\n            )\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(title=\"search_term_A only\")\n        self.setup_bookmark(description=\"bundle_term_B only\")\n        self.setup_bookmark(title=\"unrelated content\")\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_bundle_any_tags(self):\n        bundle = self.setup_bundle(any_tags=\"bundleTag1 bundleTag2\")\n\n        tag1 = self.setup_tag(name=\"bundleTag1\")\n        tag2 = self.setup_tag(name=\"bundleTag2\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(tags=[tag1]),\n            self.setup_bookmark(tags=[tag2]),\n            self.setup_bookmark(tags=[tag1, tag2]),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(tags=[other_tag])\n        self.setup_bookmark()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):\n        bundle = self.setup_bundle(any_tags=\"bundleTagA bundleTagB\")\n        search = BookmarkSearch(q=\"#searchTag1 #searchTag2\", bundle=bundle)\n\n        search_tag1 = self.setup_tag(name=\"searchTag1\")\n        search_tag2 = self.setup_tag(name=\"searchTag2\")\n        bundle_tag_a = self.setup_tag(name=\"bundleTagA\")\n        bundle_tag_b = self.setup_tag(name=\"bundleTagB\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),\n            self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),\n            self.setup_bookmark(\n                tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]\n            ),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])\n        self.setup_bookmark(tags=[search_tag1, search_tag2])\n        self.setup_bookmark(tags=[search_tag1, bundle_tag_a])\n        self.setup_bookmark(tags=[search_tag2, bundle_tag_b])\n        self.setup_bookmark(tags=[bundle_tag_a])\n        self.setup_bookmark(tags=[bundle_tag_b])\n        self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])\n        self.setup_bookmark(tags=[other_tag])\n        self.setup_bookmark()\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_bundle_all_tags(self):\n        bundle = self.setup_bundle(all_tags=\"bundleTag1 bundleTag2\")\n\n        tag1 = self.setup_tag(name=\"bundleTag1\")\n        tag2 = self.setup_tag(name=\"bundleTag2\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n\n        matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark(tags=[tag2])\n        self.setup_bookmark(tags=[tag1, other_tag])\n        self.setup_bookmark(tags=[other_tag])\n        self.setup_bookmark()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):\n        bundle = self.setup_bundle(all_tags=\"bundleTagA bundleTagB\")\n        search = BookmarkSearch(q=\"#searchTag1 #searchTag2\", bundle=bundle)\n\n        search_tag1 = self.setup_tag(name=\"searchTag1\")\n        search_tag2 = self.setup_tag(name=\"searchTag2\")\n        bundle_tag_a = self.setup_tag(name=\"bundleTagA\")\n        bundle_tag_b = self.setup_tag(name=\"bundleTagB\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(\n                tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]\n            )\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])\n        self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])\n        self.setup_bookmark(tags=[search_tag1, search_tag2])\n        self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])\n        self.setup_bookmark(tags=[search_tag1, bundle_tag_a])\n        self.setup_bookmark(tags=[other_tag])\n        self.setup_bookmark()\n\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_bundle_excluded_tags(self):\n        bundle = self.setup_bundle(excluded_tags=\"excludeTag1 excludeTag2\")\n\n        exclude_tag1 = self.setup_tag(name=\"excludeTag1\")\n        exclude_tag2 = self.setup_tag(name=\"excludeTag2\")\n        keep_tag = self.setup_tag(name=\"keepTag\")\n        keep_other_tag = self.setup_tag(name=\"keepOtherTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(tags=[keep_tag]),\n            self.setup_bookmark(tags=[keep_other_tag]),\n            self.setup_bookmark(tags=[keep_tag, keep_other_tag]),\n            self.setup_bookmark(),\n        ]\n\n        # Bookmarks that should not be returned\n        self.setup_bookmark(tags=[exclude_tag1])\n        self.setup_bookmark(tags=[exclude_tag2])\n        self.setup_bookmark(tags=[exclude_tag1, keep_tag])\n        self.setup_bookmark(tags=[exclude_tag2, keep_tag])\n        self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])\n        self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_bundle_combined_tags(self):\n        bundle = self.setup_bundle(\n            any_tags=\"anyTagA anyTagB\",\n            all_tags=\"allTag1 allTag2\",\n            excluded_tags=\"excludedTag\",\n        )\n\n        any_tag_a = self.setup_tag(name=\"anyTagA\")\n        any_tag_b = self.setup_tag(name=\"anyTagB\")\n        all_tag_1 = self.setup_tag(name=\"allTag1\")\n        all_tag_2 = self.setup_tag(name=\"allTag2\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n        excluded_tag = self.setup_tag(name=\"excludedTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),\n            self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),\n            self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),\n            self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),\n            self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(tags=[any_tag_a, all_tag_1])\n        self.setup_bookmark(tags=[any_tag_b, all_tag_2])\n        self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])\n        self.setup_bookmark(tags=[all_tag_1, all_tag_2])\n        self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])\n        self.setup_bookmark(tags=[any_tag_a])\n        self.setup_bookmark(tags=[any_tag_b])\n        self.setup_bookmark(tags=[all_tag_1])\n        self.setup_bookmark(tags=[all_tag_2])\n        self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])\n        self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])\n        self.setup_bookmark(tags=[other_tag])\n        self.setup_bookmark()\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_bookmarks_with_bundle_filter_unread(self):\n        unread_bookmarks = [\n            self.setup_bookmark(unread=True),\n            self.setup_bookmark(unread=True),\n        ]\n        read_bookmarks = [\n            self.setup_bookmark(unread=False),\n            self.setup_bookmark(unread=False),\n        ]\n\n        # Filter unread\n        bundle = self.setup_bundle(filter_unread=BookmarkBundle.FILTER_STATE_YES)\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [unread_bookmarks])\n\n        # Filter read\n        bundle = self.setup_bundle(filter_unread=BookmarkBundle.FILTER_STATE_NO)\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [read_bookmarks])\n\n        # Filter off\n        bundle = self.setup_bundle(filter_unread=BookmarkBundle.FILTER_STATE_OFF)\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [unread_bookmarks, read_bookmarks])\n\n    def test_query_bookmarks_with_bundle_filter_shared(self):\n        shared_bookmarks = [\n            self.setup_bookmark(shared=True),\n            self.setup_bookmark(shared=True),\n        ]\n        unshared_bookmarks = [\n            self.setup_bookmark(shared=False),\n            self.setup_bookmark(shared=False),\n        ]\n\n        # Filter shared\n        bundle = self.setup_bundle(filter_shared=BookmarkBundle.FILTER_STATE_YES)\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [shared_bookmarks])\n\n        # Filter unshared\n        bundle = self.setup_bundle(filter_shared=BookmarkBundle.FILTER_STATE_NO)\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [unshared_bookmarks])\n\n        # Filter off\n        bundle = self.setup_bundle(filter_shared=BookmarkBundle.FILTER_STATE_OFF)\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [shared_bookmarks, unshared_bookmarks])\n\n    def test_query_bookmarks_with_bundle_unread_shared_filters_combined(self):\n        bundle = self.setup_bundle(\n            search=\"python\",\n            filter_unread=BookmarkBundle.FILTER_STATE_YES,\n            filter_shared=BookmarkBundle.FILTER_STATE_NO,\n        )\n\n        matching_bookmarks = [\n            self.setup_bookmark(title=\"Python Tutorial\", unread=True, shared=False),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(title=\"Python Guide\", unread=False, shared=False)\n        self.setup_bookmark(title=\"Python Docs\", unread=True, shared=True)\n        self.setup_bookmark(title=\"Java Guide\", unread=True, shared=False)\n\n        query = queries.query_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_archived_bookmarks_with_bundle(self):\n        bundle = self.setup_bundle(any_tags=\"bundleTag1 bundleTag2\")\n\n        tag1 = self.setup_tag(name=\"bundleTag1\")\n        tag2 = self.setup_tag(name=\"bundleTag2\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(is_archived=True, tags=[tag1]),\n            self.setup_bookmark(is_archived=True, tags=[tag2]),\n            self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(is_archived=True, tags=[other_tag])\n        self.setup_bookmark(is_archived=True)\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark(tags=[tag2])\n        self.setup_bookmark(tags=[tag1, tag2])\n\n        query = queries.query_archived_bookmarks(\n            self.user, self.profile, BookmarkSearch(q=\"\", bundle=bundle)\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n    def test_query_shared_bookmarks_with_bundle(self):\n        user1 = self.setup_user(enable_sharing=True)\n        user2 = self.setup_user(enable_sharing=True)\n\n        bundle = self.setup_bundle(any_tags=\"bundleTag1 bundleTag2\")\n\n        tag1 = self.setup_tag(name=\"bundleTag1\")\n        tag2 = self.setup_tag(name=\"bundleTag2\")\n        other_tag = self.setup_tag(name=\"otherTag\")\n\n        matching_bookmarks = [\n            self.setup_bookmark(user=user1, shared=True, tags=[tag1]),\n            self.setup_bookmark(user=user2, shared=True, tags=[tag2]),\n            self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),\n        ]\n\n        # Bookmarks that should not match\n        self.setup_bookmark(user=user1, shared=True, tags=[other_tag])\n        self.setup_bookmark(user=user2, shared=True)\n        self.setup_bookmark(user=user1, shared=False, tags=[tag1])\n        self.setup_bookmark(user=user2, shared=False, tags=[tag2])\n        self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2])\n\n        query = queries.query_shared_bookmarks(\n            None, self.profile, BookmarkSearch(q=\"\", bundle=bundle), False\n        )\n        self.assertQueryResult(query, [matching_bookmarks])\n\n\n# Legacy search should be covered by basic test suite which was effectively the\n# full test suite before advanced search was introduced.\nclass QueriesLegacySearchTestCase(QueriesBasicTestCase):\n    def setUp(self):\n        super().setUp()\n        self.profile.legacy_search = True\n        self.profile.save()\n\n\nclass QueriesAdvancedSearchTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.user = self.get_or_create_test_user()\n        self.profile = self.user.profile\n\n        self.python_bookmark = self.setup_bookmark(\n            title=\"Python Tutorial\",\n            tags=[self.setup_tag(name=\"python\"), self.setup_tag(name=\"tutorial\")],\n        )\n        self.java_bookmark = self.setup_bookmark(\n            title=\"Java Guide\",\n            tags=[self.setup_tag(name=\"java\"), self.setup_tag(name=\"programming\")],\n        )\n        self.deprecated_python_bookmark = self.setup_bookmark(\n            title=\"Old Python Guide\",\n            tags=[self.setup_tag(name=\"python\"), self.setup_tag(name=\"deprecated\")],\n        )\n        self.javascript_tutorial = self.setup_bookmark(\n            title=\"JavaScript Basics\",\n            tags=[self.setup_tag(name=\"javascript\"), self.setup_tag(name=\"tutorial\")],\n        )\n        self.web_development = self.setup_bookmark(\n            title=\"Web Development with React\",\n            description=\"Modern web development\",\n            tags=[self.setup_tag(name=\"react\"), self.setup_tag(name=\"web\")],\n        )\n\n    def test_explicit_and_operator(self):\n        search = BookmarkSearch(q=\"python AND tutorial\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.python_bookmark])\n\n    def test_or_operator(self):\n        search = BookmarkSearch(q=\"#python OR #java\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query),\n            [self.python_bookmark, self.java_bookmark, self.deprecated_python_bookmark],\n        )\n\n    def test_not_operator(self):\n        search = BookmarkSearch(q=\"#python AND NOT #deprecated\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.python_bookmark])\n\n    def test_implicit_and_between_terms(self):\n        search = BookmarkSearch(q=\"web development\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.web_development])\n\n        search = BookmarkSearch(q=\"python tutorial\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.python_bookmark])\n\n    def test_implicit_and_between_tags(self):\n        search = BookmarkSearch(q=\"#python #tutorial\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.python_bookmark])\n\n    def test_nested_and_expression(self):\n        search = BookmarkSearch(q=\"nonexistingterm OR (#python AND #tutorial)\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.python_bookmark])\n\n        search = BookmarkSearch(\n            q=\"(#javascript AND #tutorial) OR (#python AND #tutorial)\"\n        )\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query), [self.javascript_tutorial, self.python_bookmark]\n        )\n\n    def test_mixed_terms_and_tags_with_operators(self):\n        # Set lax mode to allow term matching against tags\n        self.profile.tag_search = self.profile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        search = BookmarkSearch(q=\"(tutorial OR guide) AND #python\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query), [self.python_bookmark, self.deprecated_python_bookmark]\n        )\n\n    def test_parentheses(self):\n        # Set lax mode to allow term matching against tags\n        self.profile.tag_search = self.profile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        # Without parentheses\n        search = BookmarkSearch(q=\"python AND tutorial OR javascript AND tutorial\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query), [self.python_bookmark, self.javascript_tutorial]\n        )\n\n        # With parentheses\n        search = BookmarkSearch(q=\"(python OR javascript) AND tutorial\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query), [self.python_bookmark, self.javascript_tutorial]\n        )\n\n    def test_complex_query_with_all_operators(self):\n        # Set lax mode to allow term matching against tags\n        self.profile.tag_search = self.profile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        search = BookmarkSearch(\n            q=\"(#python OR #javascript) AND tutorial AND NOT #deprecated\"\n        )\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query), [self.python_bookmark, self.javascript_tutorial]\n        )\n\n    def test_quoted_strings_with_operators(self):\n        # Set lax mode to allow term matching against tags\n        self.profile.tag_search = self.profile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        search = BookmarkSearch(q='\"Web Development\" OR tutorial')\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(\n            list(query),\n            [self.web_development, self.python_bookmark, self.javascript_tutorial],\n        )\n\n    def test_implicit_and_with_quoted_strings(self):\n        search = BookmarkSearch(q='\"Web Development\" react')\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [self.web_development])\n\n    def test_empty_query(self):\n        # empty query returns all bookmarks\n        search = BookmarkSearch(q=\"\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        expected = [\n            self.python_bookmark,\n            self.java_bookmark,\n            self.deprecated_python_bookmark,\n            self.javascript_tutorial,\n            self.web_development,\n        ]\n        self.assertCountEqual(list(query), expected)\n\n    def test_unparseable_query_returns_no_results(self):\n        # Use a query that causes a parse error (unclosed parenthesis)\n        search = BookmarkSearch(q=\"(python AND tutorial\")\n        query = queries.query_bookmarks(self.user, self.profile, search)\n        self.assertCountEqual(list(query), [])\n\n\nclass GetTagsForQueryTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.user = self.get_or_create_test_user()\n        self.profile = self.user.profile\n\n    def test_returns_tags_matching_query(self):\n        python_tag = self.setup_tag(name=\"python\")\n        django_tag = self.setup_tag(name=\"django\")\n        self.setup_tag(name=\"unused\")\n\n        result = queries.get_tags_for_query(\n            self.user, self.profile, \"#python and #django\"\n        )\n        self.assertCountEqual(list(result), [python_tag, django_tag])\n\n    def test_case_insensitive_matching(self):\n        python_tag = self.setup_tag(name=\"Python\")\n\n        result = queries.get_tags_for_query(self.user, self.profile, \"#python\")\n        self.assertCountEqual(list(result), [python_tag])\n\n        # having two tags with the same name returns both for now\n        other_python_tag = self.setup_tag(name=\"python\")\n\n        result = queries.get_tags_for_query(self.user, self.profile, \"#python\")\n        self.assertCountEqual(list(result), [python_tag, other_python_tag])\n\n    def test_lax_mode_includes_terms(self):\n        python_tag = self.setup_tag(name=\"python\")\n        django_tag = self.setup_tag(name=\"django\")\n\n        self.profile.tag_search = UserProfile.TAG_SEARCH_LAX\n        self.profile.save()\n\n        result = queries.get_tags_for_query(\n            self.user, self.profile, \"#python and django\"\n        )\n        self.assertCountEqual(list(result), [python_tag, django_tag])\n\n    def test_strict_mode_excludes_terms(self):\n        python_tag = self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n\n        result = queries.get_tags_for_query(\n            self.user, self.profile, \"#python and django\"\n        )\n        self.assertCountEqual(list(result), [python_tag])\n\n    def test_only_returns_user_tags(self):\n        python_tag = self.setup_tag(name=\"python\")\n\n        other_user = self.setup_user()\n        other_python = self.setup_tag(name=\"python\", user=other_user)\n        other_django = self.setup_tag(name=\"django\", user=other_user)\n\n        result = queries.get_tags_for_query(\n            self.user, self.profile, \"#python and #django\"\n        )\n        self.assertCountEqual(list(result), [python_tag])\n        self.assertNotIn(other_python, list(result))\n        self.assertNotIn(other_django, list(result))\n\n    def test_empty_query_returns_no_tags(self):\n        self.setup_tag(name=\"python\")\n\n        result = queries.get_tags_for_query(self.user, self.profile, \"\")\n        self.assertCountEqual(list(result), [])\n\n    def test_query_with_no_tags_returns_empty(self):\n        self.setup_tag(name=\"python\")\n\n        result = queries.get_tags_for_query(self.user, self.profile, \"!unread\")\n        self.assertCountEqual(list(result), [])\n\n    def test_nonexistent_tag_returns_empty(self):\n        self.setup_tag(name=\"python\")\n\n        result = queries.get_tags_for_query(self.user, self.profile, \"#ruby\")\n        self.assertCountEqual(list(result), [])\n\n\nclass GetSharedTagsForQueryTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self):\n        self.user = self.get_or_create_test_user()\n        self.profile = self.user.profile\n        self.profile.enable_sharing = True\n        self.profile.save()\n\n    def test_returns_tags_from_shared_bookmarks(self):\n        python_tag = self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n        self.setup_bookmark(shared=True, tags=[python_tag])\n\n        result = queries.get_shared_tags_for_query(\n            None, self.profile, \"#python and #django\", public_only=False\n        )\n        self.assertCountEqual(list(result), [python_tag])\n\n    def test_excludes_tags_from_non_shared_bookmarks(self):\n        python_tag = self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n        self.setup_bookmark(shared=False, tags=[python_tag])\n\n        result = queries.get_shared_tags_for_query(\n            None, self.profile, \"#python and #django\", public_only=False\n        )\n        self.assertCountEqual(list(result), [])\n\n    def test_respects_sharing_enabled_setting(self):\n        self.profile.enable_sharing = False\n        self.profile.save()\n\n        python_tag = self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n        self.setup_bookmark(shared=True, tags=[python_tag])\n\n        result = queries.get_shared_tags_for_query(\n            None, self.profile, \"#python and #django\", public_only=False\n        )\n        self.assertCountEqual(list(result), [])\n\n    def test_public_only_flag(self):\n        # public sharing disabled\n        python_tag = self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n        self.setup_bookmark(shared=True, tags=[python_tag])\n\n        result = queries.get_shared_tags_for_query(\n            None, self.profile, \"#python and #django\", public_only=True\n        )\n        self.assertCountEqual(list(result), [])\n\n        # public sharing enabled\n        self.profile.enable_public_sharing = True\n        self.profile.save()\n\n        result = queries.get_shared_tags_for_query(\n            None, self.profile, \"#python and #django\", public_only=True\n        )\n        self.assertCountEqual(list(result), [python_tag])\n\n    def test_filters_by_user(self):\n        python_tag = self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n        self.setup_bookmark(shared=True, tags=[python_tag])\n\n        other_user = self.setup_user()\n        other_user.profile.enable_sharing = True\n        other_user.profile.save()\n        other_tag = self.setup_tag(name=\"python\", user=other_user)\n        self.setup_bookmark(shared=True, tags=[other_tag], user=other_user)\n\n        result = queries.get_shared_tags_for_query(\n            self.user, self.profile, \"#python and #django\", public_only=False\n        )\n        self.assertCountEqual(list(result), [python_tag])\n        self.assertNotIn(other_tag, list(result))\n"
  },
  {
    "path": "bookmarks/tests/test_root_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import GlobalSettings\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass RootViewTestCase(TestCase, BookmarkFactoryMixin):\n    def test_unauthenticated_user_redirect_to_login_by_default(self):\n        response = self.client.get(reverse(\"linkding:root\"))\n        self.assertRedirects(response, reverse(\"login\"))\n\n    def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings(\n        self,\n    ):\n        settings = GlobalSettings.get()\n        settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS\n        settings.save()\n\n        response = self.client.get(reverse(\"linkding:root\"))\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.shared\"))\n\n    def test_authenticated_user_always_redirected_to_bookmarks(self):\n        self.client.force_login(self.get_or_create_test_user())\n\n        response = self.client.get(reverse(\"linkding:root\"))\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n        settings = GlobalSettings.get()\n        settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS\n        settings.save()\n\n        response = self.client.get(reverse(\"linkding:root\"))\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n        settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN\n        settings.save()\n\n        response = self.client.get(reverse(\"linkding:root\"))\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n"
  },
  {
    "path": "bookmarks/tests/test_search_query_parser.py",
    "content": "from django.test import TestCase\n\nfrom bookmarks.models import UserProfile\nfrom bookmarks.services.search_query_parser import (\n    AndExpression,\n    NotExpression,\n    OrExpression,\n    SearchExpression,\n    SearchQueryParseError,\n    SearchQueryTokenizer,\n    SpecialKeywordExpression,\n    TagExpression,\n    TermExpression,\n    TokenType,\n    expression_to_string,\n    extract_tag_names_from_query,\n    parse_search_query,\n    strip_tag_from_query,\n)\n\n\ndef _term(term: str) -> TermExpression:\n    return TermExpression(term)\n\n\ndef _tag(tag: str) -> TagExpression:\n    return TagExpression(tag)\n\n\ndef _and(left: SearchExpression, right: SearchExpression) -> AndExpression:\n    return AndExpression(left, right)\n\n\ndef _or(left: SearchExpression, right: SearchExpression) -> OrExpression:\n    return OrExpression(left, right)\n\n\ndef _not(operand: SearchExpression) -> NotExpression:\n    return NotExpression(operand)\n\n\ndef _keyword(keyword: str) -> SpecialKeywordExpression:\n    return SpecialKeywordExpression(keyword)\n\n\nclass SearchQueryTokenizerTest(TestCase):\n    def test_empty_query(self):\n        tokenizer = SearchQueryTokenizer(\"\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 1)\n        self.assertEqual(tokens[0].type, TokenType.EOF)\n\n    def test_whitespace_only_query(self):\n        tokenizer = SearchQueryTokenizer(\"   \")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 1)\n        self.assertEqual(tokens[0].type, TokenType.EOF)\n\n    def test_single_term(self):\n        tokenizer = SearchQueryTokenizer(\"programming\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_multiple_terms(self):\n        tokenizer = SearchQueryTokenizer(\"programming books streaming\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.TERM)\n        self.assertEqual(tokens[1].value, \"books\")\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"streaming\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_hyphenated_term(self):\n        tokenizer = SearchQueryTokenizer(\"client-side\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"client-side\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_and_operator(self):\n        tokenizer = SearchQueryTokenizer(\"programming and books\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[1].value, \"and\")\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"books\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_or_operator(self):\n        tokenizer = SearchQueryTokenizer(\"programming or books\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.OR)\n        self.assertEqual(tokens[1].value, \"or\")\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"books\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_not_operator(self):\n        tokenizer = SearchQueryTokenizer(\"programming not books\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.NOT)\n        self.assertEqual(tokens[1].value, \"not\")\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"books\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_case_insensitive_operators(self):\n        tokenizer = SearchQueryTokenizer(\n            \"programming AND books OR streaming NOT videos\"\n        )\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 8)\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[3].type, TokenType.OR)\n        self.assertEqual(tokens[5].type, TokenType.NOT)\n\n    def test_parentheses(self):\n        tokenizer = SearchQueryTokenizer(\"(programming or books) and streaming\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 8)\n        self.assertEqual(tokens[0].type, TokenType.LPAREN)\n        self.assertEqual(tokens[1].type, TokenType.TERM)\n        self.assertEqual(tokens[1].value, \"programming\")\n        self.assertEqual(tokens[2].type, TokenType.OR)\n        self.assertEqual(tokens[3].type, TokenType.TERM)\n        self.assertEqual(tokens[3].value, \"books\")\n        self.assertEqual(tokens[4].type, TokenType.RPAREN)\n        self.assertEqual(tokens[5].type, TokenType.AND)\n        self.assertEqual(tokens[6].type, TokenType.TERM)\n        self.assertEqual(tokens[6].value, \"streaming\")\n        self.assertEqual(tokens[7].type, TokenType.EOF)\n\n    def test_operator_as_part_of_term(self):\n        # Terms containing operator words should be treated as terms\n        tokenizer = SearchQueryTokenizer(\"android notarization\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 3)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"android\")\n        self.assertEqual(tokens[1].type, TokenType.TERM)\n        self.assertEqual(tokens[1].value, \"notarization\")\n        self.assertEqual(tokens[2].type, TokenType.EOF)\n\n    def test_extra_whitespace(self):\n        tokenizer = SearchQueryTokenizer(\"  programming   and    books  \")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"books\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_quoted_strings(self):\n        # Double quotes\n        tokenizer = SearchQueryTokenizer('\"good and bad\"')\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"good and bad\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n        # Single quotes\n        tokenizer = SearchQueryTokenizer(\"'hello world'\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"hello world\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_quoted_strings_with_operators(self):\n        tokenizer = SearchQueryTokenizer('\"good and bad\" or programming')\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"good and bad\")\n        self.assertEqual(tokens[1].type, TokenType.OR)\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"programming\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_escaped_quotes(self):\n        # Escaped double quote within double quotes\n        tokenizer = SearchQueryTokenizer('\"say \\\\\"hello\\\\\"\"')\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, 'say \"hello\"')\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n        # Escaped single quote within single quotes\n        tokenizer = SearchQueryTokenizer(\"'don\\\\'t worry'\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"don't worry\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_unclosed_quotes(self):\n        # Unclosed quote should be handled gracefully\n        tokenizer = SearchQueryTokenizer('\"unclosed quote')\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"unclosed quote\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_tags(self):\n        # Basic tag\n        tokenizer = SearchQueryTokenizer(\"#python\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TAG)\n        self.assertEqual(tokens[0].value, \"python\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n        # Tag with hyphens\n        tokenizer = SearchQueryTokenizer(\"#machine-learning\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.TAG)\n        self.assertEqual(tokens[0].value, \"machine-learning\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_tags_with_operators(self):\n        tokenizer = SearchQueryTokenizer(\"#python and #django\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TAG)\n        self.assertEqual(tokens[0].value, \"python\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[2].type, TokenType.TAG)\n        self.assertEqual(tokens[2].value, \"django\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_tags_mixed_with_terms(self):\n        tokenizer = SearchQueryTokenizer(\"programming and #python and web\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 6)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"programming\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[2].type, TokenType.TAG)\n        self.assertEqual(tokens[2].value, \"python\")\n        self.assertEqual(tokens[3].type, TokenType.AND)\n        self.assertEqual(tokens[4].type, TokenType.TERM)\n        self.assertEqual(tokens[4].value, \"web\")\n        self.assertEqual(tokens[5].type, TokenType.EOF)\n\n    def test_empty_tag(self):\n        # Tag with just # should be ignored (no token created)\n        tokenizer = SearchQueryTokenizer(\"# \")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 1)\n        self.assertEqual(tokens[0].type, TokenType.EOF)\n\n        # Empty tag at end of string\n        tokenizer = SearchQueryTokenizer(\"#\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 1)\n        self.assertEqual(tokens[0].type, TokenType.EOF)\n\n        # Empty tag mixed with other terms\n        tokenizer = SearchQueryTokenizer(\"python # and django\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.TERM)\n        self.assertEqual(tokens[0].value, \"python\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[2].type, TokenType.TERM)\n        self.assertEqual(tokens[2].value, \"django\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_special_keywords(self):\n        tokenizer = SearchQueryTokenizer(\"!unread\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.SPECIAL_KEYWORD)\n        self.assertEqual(tokens[0].value, \"unread\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n        tokenizer = SearchQueryTokenizer(\"!untagged\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 2)\n        self.assertEqual(tokens[0].type, TokenType.SPECIAL_KEYWORD)\n        self.assertEqual(tokens[0].value, \"untagged\")\n        self.assertEqual(tokens[1].type, TokenType.EOF)\n\n    def test_special_keywords_with_operators(self):\n        tokenizer = SearchQueryTokenizer(\"!unread and !untagged\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 4)\n        self.assertEqual(tokens[0].type, TokenType.SPECIAL_KEYWORD)\n        self.assertEqual(tokens[0].value, \"unread\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[2].type, TokenType.SPECIAL_KEYWORD)\n        self.assertEqual(tokens[2].value, \"untagged\")\n        self.assertEqual(tokens[3].type, TokenType.EOF)\n\n    def test_special_keywords_mixed_with_terms_and_tags(self):\n        tokenizer = SearchQueryTokenizer(\"!unread and #python and tutorial\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 6)\n        self.assertEqual(tokens[0].type, TokenType.SPECIAL_KEYWORD)\n        self.assertEqual(tokens[0].value, \"unread\")\n        self.assertEqual(tokens[1].type, TokenType.AND)\n        self.assertEqual(tokens[2].type, TokenType.TAG)\n        self.assertEqual(tokens[2].value, \"python\")\n        self.assertEqual(tokens[3].type, TokenType.AND)\n        self.assertEqual(tokens[4].type, TokenType.TERM)\n        self.assertEqual(tokens[4].value, \"tutorial\")\n        self.assertEqual(tokens[5].type, TokenType.EOF)\n\n    def test_empty_special_keyword(self):\n        # Special keyword with just ! should be ignored (no token created)\n        tokenizer = SearchQueryTokenizer(\"! \")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 1)\n        self.assertEqual(tokens[0].type, TokenType.EOF)\n\n        # Empty special keyword at end of string\n        tokenizer = SearchQueryTokenizer(\"!\")\n        tokens = tokenizer.tokenize()\n        self.assertEqual(len(tokens), 1)\n        self.assertEqual(tokens[0].type, TokenType.EOF)\n\n\nclass SearchQueryParserTest(TestCase):\n    \"\"\"Test cases for the search query parser.\"\"\"\n\n    def test_empty_query(self):\n        result = parse_search_query(\"\")\n        self.assertIsNone(result)\n\n    def test_whitespace_only_query(self):\n        result = parse_search_query(\"   \")\n        self.assertIsNone(result)\n\n    def test_single_term(self):\n        result = parse_search_query(\"programming\")\n        expected = _term(\"programming\")\n        self.assertEqual(result, expected)\n\n    def test_and_expression(self):\n        result = parse_search_query(\"programming and books\")\n        expected = _and(_term(\"programming\"), _term(\"books\"))\n        self.assertEqual(result, expected)\n\n    def test_or_expression(self):\n        result = parse_search_query(\"programming or books\")\n        expected = _or(_term(\"programming\"), _term(\"books\"))\n        self.assertEqual(result, expected)\n\n    def test_not_expression(self):\n        result = parse_search_query(\"not programming\")\n        expected = _not(_term(\"programming\"))\n        self.assertEqual(result, expected)\n\n    def test_operator_precedence_and_over_or(self):\n        # \"a or b and c\" should parse as \"a or (b and c)\"\n        result = parse_search_query(\"programming or books and streaming\")\n        expected = _or(_term(\"programming\"), _and(_term(\"books\"), _term(\"streaming\")))\n        self.assertEqual(result, expected)\n\n    def test_operator_precedence_not_over_and(self):\n        # \"not a and b\" should parse as \"(not a) and b\"\n        result = parse_search_query(\"not programming and books\")\n        expected = _and(_not(_term(\"programming\")), _term(\"books\"))\n        self.assertEqual(result, expected)\n\n    def test_multiple_and_operators(self):\n        # \"a and b and c\" should parse as \"(a and b) and c\" (left associative)\n        result = parse_search_query(\"programming and books and streaming\")\n        expected = _and(_and(_term(\"programming\"), _term(\"books\")), _term(\"streaming\"))\n        self.assertEqual(result, expected)\n\n    def test_multiple_or_operators(self):\n        # \"a or b or c\" should parse as \"(a or b) or c\" (left associative)\n        result = parse_search_query(\"programming or books or streaming\")\n        expected = _or(_or(_term(\"programming\"), _term(\"books\")), _term(\"streaming\"))\n        self.assertEqual(result, expected)\n\n    def test_multiple_not_operators(self):\n        result = parse_search_query(\"not not programming\")\n        expected = _not(_not(_term(\"programming\")))\n        self.assertEqual(result, expected)\n\n    def test_parentheses_basic(self):\n        result = parse_search_query(\"(programming)\")\n        expected = _term(\"programming\")\n        self.assertEqual(result, expected)\n\n    def test_parentheses_change_precedence(self):\n        # \"(a or b) and c\" should parse as \"(a or b) and c\"\n        result = parse_search_query(\"(programming or books) and streaming\")\n        expected = _and(_or(_term(\"programming\"), _term(\"books\")), _term(\"streaming\"))\n        self.assertEqual(result, expected)\n\n    def test_nested_parentheses(self):\n        result = parse_search_query(\"((programming))\")\n        expected = _term(\"programming\")\n        self.assertEqual(result, expected)\n\n    def test_complex_expression(self):\n        result = parse_search_query(\n            \"programming and (books or streaming) and not client-side\"\n        )\n        # Should be parsed as \"(programming and (books or streaming)) and (not client-side)\"\n        expected = _and(\n            _and(_term(\"programming\"), _or(_term(\"books\"), _term(\"streaming\"))),\n            _not(_term(\"client-side\")),\n        )\n        self.assertEqual(result, expected)\n\n    def test_hyphenated_terms(self):\n        result = parse_search_query(\"client-side\")\n        expected = _term(\"client-side\")\n        self.assertEqual(result, expected)\n\n    def test_case_insensitive_operators(self):\n        result = parse_search_query(\"programming AND books OR streaming\")\n        expected = _or(_and(_term(\"programming\"), _term(\"books\")), _term(\"streaming\"))\n        self.assertEqual(result, expected)\n\n        # Test implicit AND with NOT\n        result = parse_search_query(\"programming AND books OR streaming NOT videos\")\n        expected = _or(\n            _and(_term(\"programming\"), _term(\"books\")),\n            _and(_term(\"streaming\"), _not(_term(\"videos\"))),\n        )\n        self.assertEqual(result, expected)\n\n    def test_case_insensitive_operators_with_explicit_operators(self):\n        result = parse_search_query(\"programming AND books OR streaming AND NOT videos\")\n        # Should parse as: (programming AND books) OR (streaming AND (NOT videos))\n        expected = _or(\n            _and(_term(\"programming\"), _term(\"books\")),\n            _and(_term(\"streaming\"), _not(_term(\"videos\"))),\n        )\n        self.assertEqual(result, expected)\n\n    def test_single_character_terms(self):\n        result = parse_search_query(\"a and b\")\n        expected = _and(_term(\"a\"), _term(\"b\"))\n        self.assertEqual(result, expected)\n\n    def test_numeric_terms(self):\n        result = parse_search_query(\"123 and 456\")\n        expected = _and(_term(\"123\"), _term(\"456\"))\n        self.assertEqual(result, expected)\n\n    def test_special_characters_in_terms(self):\n        result = parse_search_query(\"test@example.com and file.txt\")\n        expected = _and(_term(\"test@example.com\"), _term(\"file.txt\"))\n        self.assertEqual(result, expected)\n\n    def test_url_terms(self):\n        result = parse_search_query(\"https://example.com/foo/bar\")\n        expected = _term(\"https://example.com/foo/bar\")\n        self.assertEqual(result, expected)\n\n    def test_url_with_operators(self):\n        result = parse_search_query(\"https://github.com or https://gitlab.com\")\n        expected = _or(_term(\"https://github.com\"), _term(\"https://gitlab.com\"))\n        self.assertEqual(result, expected)\n\n    def test_quoted_strings(self):\n        # Basic quoted string\n        result = parse_search_query('\"good and bad\"')\n        expected = _term(\"good and bad\")\n        self.assertEqual(result, expected)\n\n        # Single quotes\n        result = parse_search_query(\"'hello world'\")\n        expected = _term(\"hello world\")\n        self.assertEqual(result, expected)\n\n    def test_quoted_strings_with_operators(self):\n        # Quoted string with OR\n        result = parse_search_query('\"good and bad\" or programming')\n        expected = _or(_term(\"good and bad\"), _term(\"programming\"))\n        self.assertEqual(result, expected)\n\n        # Quoted string with AND\n        result = parse_search_query('documentation and \"API reference\"')\n        expected = _and(_term(\"documentation\"), _term(\"API reference\"))\n        self.assertEqual(result, expected)\n\n        # Quoted string with NOT\n        result = parse_search_query('programming and not \"bad practices\"')\n        expected = _and(_term(\"programming\"), _not(_term(\"bad practices\")))\n        self.assertEqual(result, expected)\n\n    def test_multiple_quoted_strings(self):\n        result = parse_search_query('\"hello world\" and \"goodbye moon\"')\n        expected = _and(_term(\"hello world\"), _term(\"goodbye moon\"))\n        self.assertEqual(result, expected)\n\n    def test_quoted_strings_with_parentheses(self):\n        result = parse_search_query('(\"good morning\" or \"good evening\") and coffee')\n        expected = _and(\n            _or(_term(\"good morning\"), _term(\"good evening\")), _term(\"coffee\")\n        )\n        self.assertEqual(result, expected)\n\n    def test_escaped_quotes_in_terms(self):\n        result = parse_search_query('\"say \\\\\"hello\\\\\"\"')\n        expected = _term('say \"hello\"')\n        self.assertEqual(result, expected)\n\n    def test_tags(self):\n        # Basic tag\n        result = parse_search_query(\"#python\")\n        expected = _tag(\"python\")\n        self.assertEqual(result, expected)\n\n        # Tag with hyphens\n        result = parse_search_query(\"#machine-learning\")\n        expected = _tag(\"machine-learning\")\n        self.assertEqual(result, expected)\n\n    def test_tags_with_operators(self):\n        # Tag with AND\n        result = parse_search_query(\"#python and #django\")\n        expected = _and(_tag(\"python\"), _tag(\"django\"))\n        self.assertEqual(result, expected)\n\n        # Tag with OR\n        result = parse_search_query(\"#frontend or #backend\")\n        expected = _or(_tag(\"frontend\"), _tag(\"backend\"))\n        self.assertEqual(result, expected)\n\n        # Tag with NOT\n        result = parse_search_query(\"not #deprecated\")\n        expected = _not(_tag(\"deprecated\"))\n        self.assertEqual(result, expected)\n\n    def test_tags_mixed_with_terms(self):\n        result = parse_search_query(\"programming and #python and tutorial\")\n        expected = _and(_and(_term(\"programming\"), _tag(\"python\")), _term(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n    def test_tags_with_quoted_strings(self):\n        result = parse_search_query('\"machine learning\" and #python')\n        expected = _and(_term(\"machine learning\"), _tag(\"python\"))\n        self.assertEqual(result, expected)\n\n    def test_tags_with_parentheses(self):\n        result = parse_search_query(\"(#frontend or #backend) and javascript\")\n        expected = _and(_or(_tag(\"frontend\"), _tag(\"backend\")), _term(\"javascript\"))\n        self.assertEqual(result, expected)\n\n    def test_empty_tags_ignored(self):\n        # Test single empty tag\n        result = parse_search_query(\"#\")\n        expected = None  # Empty query\n        self.assertEqual(result, expected)\n\n        # Test query that's just an empty tag and whitespace\n        result = parse_search_query(\"# \")\n        expected = None  # Empty query\n        self.assertEqual(result, expected)\n\n    def test_special_keywords(self):\n        result = parse_search_query(\"!unread\")\n        expected = _keyword(\"unread\")\n        self.assertEqual(result, expected)\n\n        result = parse_search_query(\"!untagged\")\n        expected = _keyword(\"untagged\")\n        self.assertEqual(result, expected)\n\n    def test_special_keywords_with_operators(self):\n        # Special keyword with AND\n        result = parse_search_query(\"!unread and !untagged\")\n        expected = _and(_keyword(\"unread\"), _keyword(\"untagged\"))\n        self.assertEqual(result, expected)\n\n        # Special keyword with OR\n        result = parse_search_query(\"!unread or !untagged\")\n        expected = _or(_keyword(\"unread\"), _keyword(\"untagged\"))\n        self.assertEqual(result, expected)\n\n        # Special keyword with NOT\n        result = parse_search_query(\"not !unread\")\n        expected = _not(_keyword(\"unread\"))\n        self.assertEqual(result, expected)\n\n    def test_special_keywords_mixed_with_terms_and_tags(self):\n        result = parse_search_query(\"!unread and #python and tutorial\")\n        expected = _and(_and(_keyword(\"unread\"), _tag(\"python\")), _term(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n    def test_special_keywords_with_quoted_strings(self):\n        result = parse_search_query('\"machine learning\" and !unread')\n        expected = _and(_term(\"machine learning\"), _keyword(\"unread\"))\n        self.assertEqual(result, expected)\n\n    def test_special_keywords_with_parentheses(self):\n        result = parse_search_query(\"(!unread or !untagged) and javascript\")\n        expected = _and(\n            _or(_keyword(\"unread\"), _keyword(\"untagged\")), _term(\"javascript\")\n        )\n        self.assertEqual(result, expected)\n\n    def test_special_keywords_within_quoted_string(self):\n        result = parse_search_query(\"'!unread and !untagged'\")\n        expected = _term(\"!unread and !untagged\")\n        self.assertEqual(result, expected)\n\n    def test_implicit_and_basic(self):\n        # Basic implicit AND between terms\n        result = parse_search_query(\"programming book\")\n        expected = _and(_term(\"programming\"), _term(\"book\"))\n        self.assertEqual(result, expected)\n\n        # Three terms with implicit AND\n        result = parse_search_query(\"python machine learning\")\n        expected = _and(_and(_term(\"python\"), _term(\"machine\")), _term(\"learning\"))\n        self.assertEqual(result, expected)\n\n    def test_implicit_and_with_tags(self):\n        # Implicit AND between term and tag\n        result = parse_search_query(\"tutorial #python\")\n        expected = _and(_term(\"tutorial\"), _tag(\"python\"))\n        self.assertEqual(result, expected)\n\n        # Implicit AND between tag and term\n        result = parse_search_query(\"#javascript tutorial\")\n        expected = _and(_tag(\"javascript\"), _term(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n        # Multiple tags with implicit AND\n        result = parse_search_query(\"#python #django #tutorial\")\n        expected = _and(_and(_tag(\"python\"), _tag(\"django\")), _tag(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n    def test_implicit_and_with_quoted_strings(self):\n        # Implicit AND with quoted strings\n        result = parse_search_query('\"machine learning\" tutorial')\n        expected = _and(_term(\"machine learning\"), _term(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n        # Mixed types with implicit AND\n        result = parse_search_query('\"deep learning\" #python tutorial')\n        expected = _and(_and(_term(\"deep learning\"), _tag(\"python\")), _term(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n    def test_implicit_and_with_explicit_operators(self):\n        # Mixed implicit and explicit AND\n        result = parse_search_query(\"python tutorial and django\")\n        expected = _and(_and(_term(\"python\"), _term(\"tutorial\")), _term(\"django\"))\n        self.assertEqual(result, expected)\n\n        # Implicit AND with OR\n        result = parse_search_query(\"python tutorial or java guide\")\n        expected = _or(\n            _and(_term(\"python\"), _term(\"tutorial\")),\n            _and(_term(\"java\"), _term(\"guide\")),\n        )\n        self.assertEqual(result, expected)\n\n    def test_implicit_and_with_not(self):\n        # NOT with implicit AND\n        result = parse_search_query(\"not deprecated tutorial\")\n        expected = _and(_not(_term(\"deprecated\")), _term(\"tutorial\"))\n        self.assertEqual(result, expected)\n\n        # Implicit AND with NOT at end\n        result = parse_search_query(\"python tutorial not deprecated\")\n        expected = _and(\n            _and(_term(\"python\"), _term(\"tutorial\")), _not(_term(\"deprecated\"))\n        )\n        self.assertEqual(result, expected)\n\n    def test_implicit_and_with_parentheses(self):\n        # Parentheses with implicit AND\n        result = parse_search_query(\"(python tutorial) or java\")\n        expected = _or(_and(_term(\"python\"), _term(\"tutorial\")), _term(\"java\"))\n        self.assertEqual(result, expected)\n\n        # Complex parentheses with implicit AND\n        result = parse_search_query(\n            \"(machine learning #python) and (web development #javascript)\"\n        )\n        expected = _and(\n            _and(_and(_term(\"machine\"), _term(\"learning\")), _tag(\"python\")),\n            _and(_and(_term(\"web\"), _term(\"development\")), _tag(\"javascript\")),\n        )\n        self.assertEqual(result, expected)\n\n    def test_complex_precedence_with_implicit_and(self):\n        result = parse_search_query(\"python tutorial or javascript guide\")\n        expected = _or(\n            _and(_term(\"python\"), _term(\"tutorial\")),\n            _and(_term(\"javascript\"), _term(\"guide\")),\n        )\n        self.assertEqual(result, expected)\n\n        result = parse_search_query(\n            \"machine learning and (python or r) tutorial #beginner\"\n        )\n        expected = _and(\n            _and(\n                _and(\n                    _and(_term(\"machine\"), _term(\"learning\")),\n                    _or(_term(\"python\"), _term(\"r\")),\n                ),\n                _term(\"tutorial\"),\n            ),\n            _tag(\"beginner\"),\n        )\n        self.assertEqual(result, expected)\n\n    def test_operator_words_as_substrings(self):\n        # Terms that contain operator words as substrings should be treated as terms\n        result = parse_search_query(\"android and notification\")\n        expected = _and(_term(\"android\"), _term(\"notification\"))\n        self.assertEqual(result, expected)\n\n    def test_complex_queries(self):\n        test_cases = [\n            (\n                \"(programming or software) and not client-side and (javascript or python)\",\n                _and(\n                    _and(\n                        _or(_term(\"programming\"), _term(\"software\")),\n                        _not(_term(\"client-side\")),\n                    ),\n                    _or(_term(\"javascript\"), _term(\"python\")),\n                ),\n            ),\n            (\n                \"(machine-learning or ai) and python and not deprecated\",\n                _and(\n                    _and(\n                        _or(_term(\"machine-learning\"), _term(\"ai\")),\n                        _term(\"python\"),\n                    ),\n                    _not(_term(\"deprecated\")),\n                ),\n            ),\n            (\n                \"frontend and (react or vue or angular) and not jquery\",\n                _and(\n                    _and(\n                        _term(\"frontend\"),\n                        _or(\n                            _or(_term(\"react\"), _term(\"vue\")),\n                            _term(\"angular\"),\n                        ),\n                    ),\n                    _not(_term(\"jquery\")),\n                ),\n            ),\n            (\n                '\"machine learning\" and (python or r) and not \"deep learning\"',\n                _and(\n                    _and(\n                        _term(\"machine learning\"),\n                        _or(_term(\"python\"), _term(\"r\")),\n                    ),\n                    _not(_term(\"deep learning\")),\n                ),\n            ),\n            (\n                \"(#python or #javascript) and tutorial and not #deprecated\",\n                _and(\n                    _and(\n                        _or(_tag(\"python\"), _tag(\"javascript\")),\n                        _term(\"tutorial\"),\n                    ),\n                    _not(_tag(\"deprecated\")),\n                ),\n            ),\n            (\n                \"machine learning tutorial #python beginner\",\n                _and(\n                    _and(\n                        _and(\n                            _and(_term(\"machine\"), _term(\"learning\")), _term(\"tutorial\")\n                        ),\n                        _tag(\"python\"),\n                    ),\n                    _term(\"beginner\"),\n                ),\n            ),\n        ]\n\n        for query, expected_ast in test_cases:\n            with self.subTest(query=query):\n                result = parse_search_query(query)\n                self.assertEqual(result, expected_ast, f\"Failed for query: {query}\")\n\n\nclass SearchQueryParserErrorTest(TestCase):\n    def test_unmatched_left_parenthesis(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"(programming and books\")\n        self.assertIn(\"Expected RPAREN\", str(cm.exception))\n\n    def test_unmatched_right_parenthesis(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"programming and books)\")\n        self.assertIn(\"Unexpected token\", str(cm.exception))\n\n    def test_empty_parentheses(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"()\")\n        self.assertIn(\"Unexpected token RPAREN\", str(cm.exception))\n\n    def test_operator_without_operand(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"and\")\n        self.assertIn(\"Unexpected token AND\", str(cm.exception))\n\n    def test_trailing_operator(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"programming and\")\n        self.assertIn(\"Unexpected token EOF\", str(cm.exception))\n\n    def test_consecutive_operators(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"programming and or books\")\n        self.assertIn(\"Unexpected token OR\", str(cm.exception))\n\n    def test_not_without_operand(self):\n        with self.assertRaises(SearchQueryParseError) as cm:\n            parse_search_query(\"not\")\n        self.assertIn(\"Unexpected token EOF\", str(cm.exception))\n\n\nclass ExpressionToStringTest(TestCase):\n    def test_simple_term(self):\n        expr = _term(\"python\")\n        self.assertEqual(expression_to_string(expr), \"python\")\n\n    def test_simple_tag(self):\n        expr = _tag(\"python\")\n        self.assertEqual(expression_to_string(expr), \"#python\")\n\n    def test_simple_keyword(self):\n        expr = _keyword(\"unread\")\n        self.assertEqual(expression_to_string(expr), \"!unread\")\n\n    def test_term_with_spaces(self):\n        expr = _term(\"machine learning\")\n        self.assertEqual(expression_to_string(expr), '\"machine learning\"')\n\n    def test_term_with_quotes(self):\n        expr = _term('say \"hello\"')\n        self.assertEqual(expression_to_string(expr), '\"say \\\\\"hello\\\\\"\"')\n\n    def test_and_expression_implicit(self):\n        expr = _and(_term(\"python\"), _term(\"tutorial\"))\n        self.assertEqual(expression_to_string(expr), \"python tutorial\")\n\n    def test_and_expression_with_tags(self):\n        expr = _and(_tag(\"python\"), _tag(\"django\"))\n        self.assertEqual(expression_to_string(expr), \"#python #django\")\n\n    def test_and_expression_complex(self):\n        expr = _and(_or(_term(\"python\"), _term(\"ruby\")), _term(\"tutorial\"))\n        self.assertEqual(expression_to_string(expr), \"(python or ruby) tutorial\")\n\n    def test_or_expression(self):\n        expr = _or(_term(\"python\"), _term(\"ruby\"))\n        self.assertEqual(expression_to_string(expr), \"python or ruby\")\n\n    def test_or_expression_with_and(self):\n        expr = _or(_and(_term(\"python\"), _term(\"tutorial\")), _term(\"ruby\"))\n        self.assertEqual(expression_to_string(expr), \"python tutorial or ruby\")\n\n    def test_not_expression(self):\n        expr = _not(_term(\"deprecated\"))\n        self.assertEqual(expression_to_string(expr), \"not deprecated\")\n\n    def test_not_with_tag(self):\n        expr = _not(_tag(\"deprecated\"))\n        self.assertEqual(expression_to_string(expr), \"not #deprecated\")\n\n    def test_not_with_and(self):\n        expr = _not(_and(_term(\"python\"), _term(\"deprecated\")))\n        self.assertEqual(expression_to_string(expr), \"not (python deprecated)\")\n\n    def test_complex_nested_expression(self):\n        expr = _and(\n            _or(_term(\"python\"), _term(\"ruby\")),\n            _or(_term(\"tutorial\"), _term(\"guide\")),\n        )\n        result = expression_to_string(expr)\n        self.assertEqual(result, \"(python or ruby) (tutorial or guide)\")\n\n    def test_implicit_and_chain(self):\n        expr = _and(_and(_term(\"machine\"), _term(\"learning\")), _term(\"tutorial\"))\n        self.assertEqual(expression_to_string(expr), \"machine learning tutorial\")\n\n    def test_none_expression(self):\n        self.assertEqual(expression_to_string(None), \"\")\n\n    def test_round_trip(self):\n        test_cases = [\n            \"#python\",\n            \"python tutorial\",\n            \"#python #django\",\n            \"python or ruby\",\n            \"not deprecated\",\n            \"(python or ruby) and tutorial\",\n            \"tutorial and (python or ruby)\",\n            \"(python or ruby) tutorial\",\n            \"tutorial (python or ruby)\",\n        ]\n\n        for query in test_cases:\n            with self.subTest(query=query):\n                ast = parse_search_query(query)\n                result = expression_to_string(ast)\n                ast2 = parse_search_query(result)\n                self.assertEqual(ast, ast2)\n\n\nclass StripTagFromQueryTest(TestCase):\n    def test_single_tag(self):\n        result = strip_tag_from_query(\"#books\", \"books\")\n        self.assertEqual(result, \"\")\n\n    def test_tag_with_and(self):\n        result = strip_tag_from_query(\"#history and #books\", \"books\")\n        self.assertEqual(result, \"#history\")\n\n    def test_tag_with_and_not(self):\n        result = strip_tag_from_query(\"#history and not #books\", \"books\")\n        self.assertEqual(result, \"#history\")\n\n    def test_implicit_and_with_term_and_tags(self):\n        result = strip_tag_from_query(\"roman #history #books\", \"books\")\n        self.assertEqual(result, \"roman #history\")\n\n    def test_tag_in_or_expression(self):\n        result = strip_tag_from_query(\"roman and (#history or #books)\", \"books\")\n        self.assertEqual(result, \"roman #history\")\n\n    def test_complex_or_with_and(self):\n        result = strip_tag_from_query(\n            \"(roman and #books) or (greek and #books)\", \"books\"\n        )\n        self.assertEqual(result, \"roman or greek\")\n\n    def test_case_insensitive(self):\n        result = strip_tag_from_query(\"#Books and #History\", \"books\")\n        self.assertEqual(result, \"#History\")\n\n    def test_tag_not_present(self):\n        result = strip_tag_from_query(\"#history and #science\", \"books\")\n        self.assertEqual(result, \"#history #science\")\n\n    def test_multiple_same_tags(self):\n        result = strip_tag_from_query(\"#books or #books\", \"books\")\n        self.assertEqual(result, \"\")\n\n    def test_nested_parentheses(self):\n        result = strip_tag_from_query(\"((#books and tutorial) or guide)\", \"books\")\n        self.assertEqual(result, \"tutorial or guide\")\n\n    def test_not_expression_with_tag(self):\n        result = strip_tag_from_query(\"tutorial and not #books\", \"books\")\n        self.assertEqual(result, \"tutorial\")\n\n    def test_only_not_tag(self):\n        result = strip_tag_from_query(\"not #books\", \"books\")\n        self.assertEqual(result, \"\")\n\n    def test_complex_query(self):\n        result = strip_tag_from_query(\n            \"(#python or #ruby) and tutorial and not #books\", \"books\"\n        )\n        self.assertEqual(result, \"(#python or #ruby) tutorial\")\n\n    def test_empty_query(self):\n        result = strip_tag_from_query(\"\", \"books\")\n        self.assertEqual(result, \"\")\n\n    def test_whitespace_only(self):\n        result = strip_tag_from_query(\"   \", \"books\")\n        self.assertEqual(result, \"\")\n\n    def test_special_keywords_preserved(self):\n        result = strip_tag_from_query(\"!unread and #books\", \"books\")\n        self.assertEqual(result, \"!unread\")\n\n    def test_quoted_terms_preserved(self):\n        result = strip_tag_from_query('\"machine learning\" and #books', \"books\")\n        self.assertEqual(result, '\"machine learning\"')\n\n    def test_all_tags_in_and_chain(self):\n        result = strip_tag_from_query(\"#books and #books and #books\", \"books\")\n        self.assertEqual(result, \"\")\n\n    def test_tag_similar_name(self):\n        # Should not remove #book when removing #books\n        result = strip_tag_from_query(\"#book and #books\", \"books\")\n        self.assertEqual(result, \"#book\")\n\n    def test_invalid_query_returns_original(self):\n        # If query is malformed, should return original\n        result = strip_tag_from_query(\"(unclosed paren\", \"books\")\n        self.assertEqual(result, \"(unclosed paren\")\n\n    def test_implicit_and_in_output(self):\n        result = strip_tag_from_query(\"python tutorial #books #django\", \"books\")\n        self.assertEqual(result, \"python tutorial #django\")\n\n    def test_nested_or_simplify_parenthesis(self):\n        result = strip_tag_from_query(\n            \"(#books or tutorial) and (#books or guide)\", \"books\"\n        )\n        self.assertEqual(result, \"tutorial guide\")\n\n    def test_nested_or_preserve_parenthesis(self):\n        result = strip_tag_from_query(\n            \"(#books or tutorial or guide) and (#books or help or lesson)\", \"books\"\n        )\n        self.assertEqual(result, \"(tutorial or guide) (help or lesson)\")\n\n    def test_left_side_removed(self):\n        result = strip_tag_from_query(\"#books and python\", \"books\")\n        self.assertEqual(result, \"python\")\n\n    def test_right_side_removed(self):\n        result = strip_tag_from_query(\"python and #books\", \"books\")\n        self.assertEqual(result, \"python\")\n\n\nclass StripTagFromQueryLaxSearchTest(TestCase):\n    def setUp(self):\n        self.lax_profile = type(\n            \"UserProfile\", (), {\"tag_search\": UserProfile.TAG_SEARCH_LAX}\n        )()\n        self.strict_profile = type(\n            \"UserProfile\", (), {\"tag_search\": UserProfile.TAG_SEARCH_STRICT}\n        )()\n\n    def test_lax_search_removes_matching_term(self):\n        result = strip_tag_from_query(\"books\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"\")\n\n    def test_lax_search_removes_term_case_insensitive(self):\n        result = strip_tag_from_query(\"Books\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"\")\n\n        result = strip_tag_from_query(\"BOOKS\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"\")\n\n    def test_lax_search_multiple_terms(self):\n        result = strip_tag_from_query(\"books and history\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"history\")\n\n    def test_lax_search_preserves_non_matching_terms(self):\n        result = strip_tag_from_query(\"history and science\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"history science\")\n\n    def test_lax_search_removes_both_tag_and_term(self):\n        result = strip_tag_from_query(\"books #books\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"\")\n\n    def test_lax_search_mixed_tag_and_term(self):\n        result = strip_tag_from_query(\n            \"books and #history and #books\", \"books\", self.lax_profile\n        )\n        self.assertEqual(result, \"#history\")\n\n    def test_lax_search_term_in_or_expression(self):\n        result = strip_tag_from_query(\n            \"(books or history) and guide\", \"books\", self.lax_profile\n        )\n        self.assertEqual(result, \"history guide\")\n\n    def test_lax_search_term_in_not_expression(self):\n        result = strip_tag_from_query(\n            \"history and not books\", \"books\", self.lax_profile\n        )\n        self.assertEqual(result, \"history\")\n\n    def test_lax_search_only_not_term(self):\n        result = strip_tag_from_query(\"not books\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"\")\n\n    def test_lax_search_complex_query(self):\n        result = strip_tag_from_query(\n            \"(books or #books) and (history or guide)\", \"books\", self.lax_profile\n        )\n        self.assertEqual(result, \"history or guide\")\n\n    def test_lax_search_quoted_term_with_same_name(self):\n        result = strip_tag_from_query('\"books\" and history', \"books\", self.lax_profile)\n        self.assertEqual(result, \"history\")\n\n    def test_lax_search_partial_match_not_removed(self):\n        result = strip_tag_from_query(\"bookshelf\", \"books\", self.lax_profile)\n        self.assertEqual(result, \"bookshelf\")\n\n    def test_lax_search_multiple_occurrences(self):\n        result = strip_tag_from_query(\n            \"books or books or history\", \"books\", self.lax_profile\n        )\n        self.assertEqual(result, \"history\")\n\n    def test_lax_search_nested_expressions(self):\n        result = strip_tag_from_query(\n            \"((books and tutorial) or guide) and history\", \"books\", self.lax_profile\n        )\n        self.assertEqual(result, \"(tutorial or guide) history\")\n\n    def test_strict_search_preserves_terms(self):\n        result = strip_tag_from_query(\"books\", \"books\", self.strict_profile)\n        self.assertEqual(result, \"books\")\n\n    def test_strict_search_preserves_terms_with_tags(self):\n        result = strip_tag_from_query(\"books #books\", \"books\", self.strict_profile)\n        self.assertEqual(result, \"books\")\n\n    def test_no_profile_defaults_to_strict(self):\n        result = strip_tag_from_query(\"books #books\", \"books\", None)\n        self.assertEqual(result, \"books\")\n\n\nclass ExtractTagNamesFromQueryTest(TestCase):\n    def test_empty_query(self):\n        result = extract_tag_names_from_query(\"\")\n        self.assertEqual(result, [])\n\n    def test_whitespace_query(self):\n        result = extract_tag_names_from_query(\"   \")\n        self.assertEqual(result, [])\n\n    def test_single_tag(self):\n        result = extract_tag_names_from_query(\"#python\")\n        self.assertEqual(result, [\"python\"])\n\n    def test_multiple_tags(self):\n        result = extract_tag_names_from_query(\"#python and #django\")\n        self.assertEqual(result, [\"django\", \"python\"])\n\n    def test_tags_with_or(self):\n        result = extract_tag_names_from_query(\"#python or #ruby\")\n        self.assertEqual(result, [\"python\", \"ruby\"])\n\n    def test_tags_with_not(self):\n        result = extract_tag_names_from_query(\"not #deprecated\")\n        self.assertEqual(result, [\"deprecated\"])\n\n    def test_tags_in_complex_query(self):\n        result = extract_tag_names_from_query(\n            \"(#python or #ruby) and #tutorial and not #deprecated\"\n        )\n        self.assertEqual(result, [\"deprecated\", \"python\", \"ruby\", \"tutorial\"])\n\n    def test_duplicate_tags(self):\n        result = extract_tag_names_from_query(\"#python and #python\")\n        self.assertEqual(result, [\"python\"])\n\n    def test_case_insensitive_deduplication(self):\n        result = extract_tag_names_from_query(\"#Python and #PYTHON and #python\")\n        self.assertEqual(result, [\"python\"])\n\n    def test_mixed_tags_and_terms(self):\n        result = extract_tag_names_from_query(\"tutorial #python guide #django\")\n        self.assertEqual(result, [\"django\", \"python\"])\n\n    def test_only_terms_no_tags(self):\n        result = extract_tag_names_from_query(\"tutorial guide\")\n        self.assertEqual(result, [])\n\n    def test_special_keywords_not_extracted(self):\n        result = extract_tag_names_from_query(\"!unread and #python\")\n        self.assertEqual(result, [\"python\"])\n\n    def test_tags_in_nested_parentheses(self):\n        result = extract_tag_names_from_query(\"((#python and #django) or #ruby)\")\n        self.assertEqual(result, [\"django\", \"python\", \"ruby\"])\n\n    def test_invalid_query_returns_empty(self):\n        result = extract_tag_names_from_query(\"(unclosed paren\")\n        self.assertEqual(result, [])\n\n    def test_tags_with_hyphens(self):\n        result = extract_tag_names_from_query(\"#machine-learning and #deep-learning\")\n        self.assertEqual(result, [\"deep-learning\", \"machine-learning\"])\n\n\nclass ExtractTagNamesFromQueryLaxSearchTest(TestCase):\n    def setUp(self):\n        self.lax_profile = type(\n            \"UserProfile\", (), {\"tag_search\": UserProfile.TAG_SEARCH_LAX}\n        )()\n        self.strict_profile = type(\n            \"UserProfile\", (), {\"tag_search\": UserProfile.TAG_SEARCH_STRICT}\n        )()\n\n    def test_lax_search_extracts_terms(self):\n        result = extract_tag_names_from_query(\"python and django\", self.lax_profile)\n        self.assertEqual(result, [\"django\", \"python\"])\n\n    def test_lax_search_mixed_tags_and_terms(self):\n        result = extract_tag_names_from_query(\n            \"tutorial #python guide #django\", self.lax_profile\n        )\n        self.assertEqual(result, [\"django\", \"guide\", \"python\", \"tutorial\"])\n\n    def test_lax_search_deduplicates_tags_and_terms(self):\n        result = extract_tag_names_from_query(\"python #python\", self.lax_profile)\n        self.assertEqual(result, [\"python\"])\n\n    def test_lax_search_case_insensitive_dedup(self):\n        result = extract_tag_names_from_query(\"Python #python PYTHON\", self.lax_profile)\n        self.assertEqual(result, [\"python\"])\n\n    def test_lax_search_terms_in_or_expression(self):\n        result = extract_tag_names_from_query(\n            \"(python or ruby) and tutorial\", self.lax_profile\n        )\n        self.assertEqual(result, [\"python\", \"ruby\", \"tutorial\"])\n\n    def test_lax_search_terms_in_not_expression(self):\n        result = extract_tag_names_from_query(\n            \"tutorial and not deprecated\", self.lax_profile\n        )\n        self.assertEqual(result, [\"deprecated\", \"tutorial\"])\n\n    def test_lax_search_quoted_terms(self):\n        result = extract_tag_names_from_query(\n            '\"machine learning\" and #python', self.lax_profile\n        )\n        self.assertEqual(result, [\"machine learning\", \"python\"])\n\n    def test_lax_search_complex_query(self):\n        result = extract_tag_names_from_query(\n            \"(python or #ruby) and tutorial and not #deprecated\", self.lax_profile\n        )\n        self.assertEqual(result, [\"deprecated\", \"python\", \"ruby\", \"tutorial\"])\n\n    def test_lax_search_special_keywords_not_extracted(self):\n        result = extract_tag_names_from_query(\n            \"!unread and python and #django\", self.lax_profile\n        )\n        self.assertEqual(result, [\"django\", \"python\"])\n\n    def test_strict_search_ignores_terms(self):\n        result = extract_tag_names_from_query(\"python and django\", self.strict_profile)\n        self.assertEqual(result, [])\n\n    def test_strict_search_only_tags(self):\n        result = extract_tag_names_from_query(\n            \"tutorial #python guide #django\", self.strict_profile\n        )\n        self.assertEqual(result, [\"django\", \"python\"])\n\n    def test_no_profile_defaults_to_strict(self):\n        result = extract_tag_names_from_query(\"python #django\", None)\n        self.assertEqual(result, [\"django\"])\n"
  },
  {
    "path": "bookmarks/tests/test_settings_export_view.py",
    "content": "import datetime\nfrom unittest.mock import patch\n\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Bookmark\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def assertFormErrorHint(self, response, text: str):\n        self.assertContains(response, '<div class=\"has-error\">')\n        self.assertContains(response, text)\n\n    def test_should_export_successfully(self):\n        self.setup_bookmark(tags=[self.setup_tag()])\n        self.setup_bookmark(tags=[self.setup_tag()])\n        self.setup_bookmark(tags=[self.setup_tag()])\n        self.setup_bookmark(tags=[self.setup_tag()], is_archived=True)\n        self.setup_bookmark(tags=[self.setup_tag()], is_archived=True)\n        self.setup_bookmark(tags=[self.setup_tag()], is_archived=True)\n\n        response = self.client.get(reverse(\"linkding:settings.export\"), follow=True)\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/plain; charset=UTF-8\")\n\n        for bookmark in Bookmark.objects.all():\n            self.assertContains(response, bookmark.url)\n\n    def test_should_only_export_user_bookmarks(self):\n        other_user = self.setup_user()\n        owned_bookmarks = [\n            self.setup_bookmark(tags=[self.setup_tag()]),\n            self.setup_bookmark(tags=[self.setup_tag()]),\n            self.setup_bookmark(tags=[self.setup_tag()]),\n        ]\n        non_owned_bookmarks = [\n            self.setup_bookmark(tags=[self.setup_tag()], user=other_user),\n            self.setup_bookmark(tags=[self.setup_tag()], user=other_user),\n            self.setup_bookmark(tags=[self.setup_tag()], user=other_user),\n        ]\n\n        response = self.client.get(reverse(\"linkding:settings.export\"), follow=True)\n\n        text = response.content.decode(\"utf-8\")\n\n        for bookmark in owned_bookmarks:\n            self.assertIn(bookmark.url, text)\n\n        for bookmark in non_owned_bookmarks:\n            self.assertNotIn(bookmark.url, text)\n\n    def test_should_check_authentication(self):\n        self.client.logout()\n        response = self.client.get(reverse(\"linkding:settings.export\"), follow=True)\n\n        self.assertRedirects(\n            response, reverse(\"login\") + \"?next=\" + reverse(\"linkding:settings.export\")\n        )\n\n    def test_should_show_hint_when_export_raises_error(self):\n        with patch(\n            \"bookmarks.services.exporter.export_netscape_html\"\n        ) as mock_export_netscape_html:\n            mock_export_netscape_html.side_effect = Exception(\"Nope\")\n            response = self.client.get(reverse(\"linkding:settings.export\"), follow=True)\n\n            self.assertTemplateUsed(response, \"settings/general.html\")\n            self.assertFormErrorHint(\n                response, \"An error occurred during bookmark export.\"\n            )\n\n    def test_filename_includes_date_and_time(self):\n        self.setup_bookmark()\n\n        # Mock timezone.now to return a fixed datetime for predictable filename\n        fixed_time = datetime.datetime(2023, 5, 15, 14, 30, 45, tzinfo=datetime.UTC)\n\n        with patch(\"bookmarks.views.settings.timezone.now\", return_value=fixed_time):\n            response = self.client.get(reverse(\"linkding:settings.export\"), follow=True)\n\n        self.assertEqual(response.status_code, 200)\n        expected_filename = 'attachment; filename=\"bookmarks_2023-05-15_14-30-45.html\"'\n        self.assertEqual(response[\"Content-Disposition\"], expected_filename)\n"
  },
  {
    "path": "bookmarks/tests/test_settings_general_view.py",
    "content": "import hashlib\nimport random\nfrom unittest.mock import Mock, patch\n\nimport requests\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\nfrom requests import RequestException\n\nfrom bookmarks.models import GlobalSettings, UserProfile\nfrom bookmarks.services import tasks\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\nfrom bookmarks.views.settings import app_version, get_version_info\n\n\nclass SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def create_profile_form_data(self, overrides=None):\n        if not overrides:\n            overrides = {}\n        form_data = {\n            \"update_profile\": \"\",\n            \"theme\": UserProfile.THEME_AUTO,\n            \"bookmark_date_display\": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,\n            \"bookmark_description_display\": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,\n            \"bookmark_description_max_lines\": 1,\n            \"bookmark_link_target\": UserProfile.BOOKMARK_LINK_TARGET_BLANK,\n            \"web_archive_integration\": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,\n            \"enable_sharing\": False,\n            \"enable_public_sharing\": False,\n            \"enable_favicons\": False,\n            \"enable_preview_images\": False,\n            \"enable_automatic_html_snapshots\": True,\n            \"tag_search\": UserProfile.TAG_SEARCH_STRICT,\n            \"tag_grouping\": UserProfile.TAG_GROUPING_ALPHABETICAL,\n            \"display_url\": False,\n            \"display_view_bookmark_action\": True,\n            \"display_edit_bookmark_action\": True,\n            \"display_archive_bookmark_action\": True,\n            \"display_remove_bookmark_action\": True,\n            \"permanent_notes\": False,\n            \"custom_css\": \"\",\n            \"auto_tagging_rules\": \"\",\n            \"items_per_page\": \"30\",\n            \"sticky_pagination\": False,\n            \"collapse_side_panel\": False,\n            \"hide_bundles\": False,\n            \"legacy_search\": False,\n        }\n\n        return {**form_data, **overrides}\n\n    def assertSuccessMessage(self, html, message: str, count=1):\n        self.assertInHTML(\n            f\"\"\"\n            <div class=\"toast toast-success mb-4\">{message}</div>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def assertErrorMessage(self, html, message: str, count=1):\n        self.assertInHTML(\n            f\"\"\"\n            <div class=\"toast toast-error mb-4\">{message}</div>\n        \"\"\",\n            html,\n            count=count,\n        )\n\n    def test_should_render_successfully(self):\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n\n        self.assertEqual(response.status_code, 200)\n\n    def test_should_check_authentication(self):\n        self.client.logout()\n        response = self.client.get(reverse(\"linkding:settings.general\"), follow=True)\n\n        self.assertRedirects(\n            response,\n            reverse(\"login\") + \"?next=\" + reverse(\"linkding:settings.general\"),\n        )\n\n        response = self.client.get(reverse(\"linkding:settings.update\"), follow=True)\n\n        self.assertRedirects(\n            response,\n            reverse(\"login\") + \"?next=\" + reverse(\"linkding:settings.update\"),\n        )\n\n    def test_update_profile(self):\n        form_data = {\n            \"update_profile\": \"\",\n            \"theme\": UserProfile.THEME_DARK,\n            \"bookmark_date_display\": UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,\n            \"bookmark_description_display\": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE,\n            \"bookmark_description_max_lines\": 3,\n            \"bookmark_link_target\": UserProfile.BOOKMARK_LINK_TARGET_SELF,\n            \"web_archive_integration\": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,\n            \"enable_sharing\": True,\n            \"enable_public_sharing\": True,\n            \"enable_favicons\": True,\n            \"enable_preview_images\": True,\n            \"enable_automatic_html_snapshots\": False,\n            \"tag_search\": UserProfile.TAG_SEARCH_LAX,\n            \"tag_grouping\": UserProfile.TAG_GROUPING_DISABLED,\n            \"display_url\": True,\n            \"display_view_bookmark_action\": False,\n            \"display_edit_bookmark_action\": False,\n            \"display_archive_bookmark_action\": False,\n            \"display_remove_bookmark_action\": False,\n            \"permanent_notes\": True,\n            \"default_mark_unread\": True,\n            \"default_mark_shared\": True,\n            \"custom_css\": \"body { background-color: #000; }\",\n            \"auto_tagging_rules\": \"example.com tag\",\n            \"items_per_page\": \"10\",\n            \"sticky_pagination\": True,\n            \"collapse_side_panel\": True,\n            \"hide_bundles\": True,\n            \"legacy_search\": True,\n        }\n        response = self.client.post(\n            reverse(\"linkding:settings.update\"), form_data, follow=True\n        )\n        html = response.content.decode()\n\n        self.user.profile.refresh_from_db()\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(self.user.profile.theme, form_data[\"theme\"])\n        self.assertEqual(\n            self.user.profile.bookmark_date_display, form_data[\"bookmark_date_display\"]\n        )\n        self.assertEqual(\n            self.user.profile.bookmark_description_display,\n            form_data[\"bookmark_description_display\"],\n        )\n        self.assertEqual(\n            self.user.profile.bookmark_description_max_lines,\n            form_data[\"bookmark_description_max_lines\"],\n        )\n        self.assertEqual(\n            self.user.profile.bookmark_link_target, form_data[\"bookmark_link_target\"]\n        )\n        self.assertEqual(\n            self.user.profile.web_archive_integration,\n            form_data[\"web_archive_integration\"],\n        )\n        self.assertEqual(self.user.profile.enable_sharing, form_data[\"enable_sharing\"])\n        self.assertEqual(\n            self.user.profile.enable_public_sharing, form_data[\"enable_public_sharing\"]\n        )\n        self.assertEqual(\n            self.user.profile.enable_favicons, form_data[\"enable_favicons\"]\n        )\n        self.assertEqual(\n            self.user.profile.enable_preview_images, form_data[\"enable_preview_images\"]\n        )\n        self.assertEqual(\n            self.user.profile.enable_automatic_html_snapshots,\n            form_data[\"enable_automatic_html_snapshots\"],\n        )\n        self.assertEqual(self.user.profile.tag_search, form_data[\"tag_search\"])\n        self.assertEqual(self.user.profile.tag_grouping, form_data[\"tag_grouping\"])\n        self.assertEqual(self.user.profile.display_url, form_data[\"display_url\"])\n        self.assertEqual(\n            self.user.profile.display_view_bookmark_action,\n            form_data[\"display_view_bookmark_action\"],\n        )\n        self.assertEqual(\n            self.user.profile.display_edit_bookmark_action,\n            form_data[\"display_edit_bookmark_action\"],\n        )\n        self.assertEqual(\n            self.user.profile.display_archive_bookmark_action,\n            form_data[\"display_archive_bookmark_action\"],\n        )\n        self.assertEqual(\n            self.user.profile.display_remove_bookmark_action,\n            form_data[\"display_remove_bookmark_action\"],\n        )\n        self.assertEqual(\n            self.user.profile.permanent_notes, form_data[\"permanent_notes\"]\n        )\n        self.assertEqual(\n            self.user.profile.default_mark_unread, form_data[\"default_mark_unread\"]\n        )\n        self.assertEqual(\n            self.user.profile.default_mark_shared, form_data[\"default_mark_shared\"]\n        )\n        self.assertEqual(self.user.profile.custom_css, form_data[\"custom_css\"])\n        self.assertEqual(\n            self.user.profile.auto_tagging_rules, form_data[\"auto_tagging_rules\"]\n        )\n        self.assertEqual(\n            self.user.profile.items_per_page, int(form_data[\"items_per_page\"])\n        )\n        self.assertEqual(\n            self.user.profile.sticky_pagination, form_data[\"sticky_pagination\"]\n        )\n        self.assertEqual(\n            self.user.profile.collapse_side_panel, form_data[\"collapse_side_panel\"]\n        )\n        self.assertEqual(self.user.profile.hide_bundles, form_data[\"hide_bundles\"])\n        self.assertEqual(self.user.profile.legacy_search, form_data[\"legacy_search\"])\n\n        self.assertSuccessMessage(html, \"Profile updated\")\n\n    def test_update_profile_with_invalid_form_returns_422(self):\n        form_data = self.create_profile_form_data({\"items_per_page\": \"-1\"})\n        response = self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n        self.assertEqual(response.status_code, 422)\n\n    def test_update_profile_should_not_be_called_without_respective_form_action(self):\n        form_data = {\n            \"theme\": UserProfile.THEME_DARK,\n        }\n        response = self.client.post(\n            reverse(\"linkding:settings.update\"), form_data, follow=True\n        )\n        html = response.content.decode()\n\n        self.user.profile.refresh_from_db()\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)\n        self.assertSuccessMessage(html, \"Profile updated\", count=0)\n\n    def test_update_profile_updates_custom_css_hash(self):\n        form_data = self.create_profile_form_data(\n            {\n                \"custom_css\": \"body { background-color: #000; }\",\n            }\n        )\n        self.client.post(reverse(\"linkding:settings.update\"), form_data, follow=True)\n        self.user.profile.refresh_from_db()\n\n        expected_hash = hashlib.md5(form_data[\"custom_css\"].encode(\"utf-8\")).hexdigest()\n        self.assertEqual(expected_hash, self.user.profile.custom_css_hash)\n\n        form_data[\"custom_css\"] = \"body { background-color: #fff; }\"\n        self.client.post(reverse(\"linkding:settings.update\"), form_data, follow=True)\n        self.user.profile.refresh_from_db()\n\n        expected_hash = hashlib.md5(form_data[\"custom_css\"].encode(\"utf-8\")).hexdigest()\n        self.assertEqual(expected_hash, self.user.profile.custom_css_hash)\n\n        form_data[\"custom_css\"] = \"\"\n        self.client.post(reverse(\"linkding:settings.update\"), form_data, follow=True)\n        self.user.profile.refresh_from_db()\n\n        self.assertEqual(\"\", self.user.profile.custom_css_hash)\n\n    def test_enable_favicons_should_schedule_icon_update(self):\n        with patch.object(\n            tasks, \"schedule_bookmarks_without_favicons\"\n        ) as mock_schedule_bookmarks_without_favicons:\n            # Enabling favicons schedules update\n            form_data = self.create_profile_form_data(\n                {\n                    \"enable_favicons\": True,\n                }\n            )\n            self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n            mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user)\n\n            # No update scheduled if favicons are already enabled\n            mock_schedule_bookmarks_without_favicons.reset_mock()\n\n            self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n            mock_schedule_bookmarks_without_favicons.assert_not_called()\n\n            # No update scheduled when disabling favicons\n            form_data = self.create_profile_form_data(\n                {\n                    \"enable_favicons\": False,\n                }\n            )\n\n            self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n            mock_schedule_bookmarks_without_favicons.assert_not_called()\n\n    def test_refresh_favicons(self):\n        with patch.object(\n            tasks, \"schedule_refresh_favicons\"\n        ) as mock_schedule_refresh_favicons:\n            form_data = {\n                \"refresh_favicons\": \"\",\n            }\n            response = self.client.post(\n                reverse(\"linkding:settings.update\"), form_data, follow=True\n            )\n            html = response.content.decode()\n\n            mock_schedule_refresh_favicons.assert_called_once()\n            self.assertSuccessMessage(\n                html, \"Scheduled favicon update. This may take a while...\"\n            )\n\n    def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):\n        with patch.object(\n            tasks, \"schedule_refresh_favicons\"\n        ) as mock_schedule_refresh_favicons:\n            form_data = {}\n            response = self.client.post(reverse(\"linkding:settings.update\"), form_data)\n            html = response.content.decode()\n\n            mock_schedule_refresh_favicons.assert_not_called()\n            self.assertSuccessMessage(\n                html, \"Scheduled favicon update. This may take a while...\", count=0\n            )\n\n    def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = True\n        profile.save()\n\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <button class=\"btn mt-2\" name=\"refresh_favicons\">Refresh Favicons</button>\n        \"\"\",\n            html,\n            count=1,\n        )\n\n    def test_refresh_favicons_should_not_be_visible_when_favicons_disabled_in_profile(\n        self,\n    ):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = False\n        profile.save()\n\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <button class=\"btn mt-2\" name=\"refresh_favicons\">Refresh Favicons</button>\n        \"\"\",\n            html,\n            count=0,\n        )\n\n    @override_settings(LD_ENABLE_REFRESH_FAVICONS=False)\n    def test_refresh_favicons_should_not_be_visible_when_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_favicons = True\n        profile.save()\n\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <button class=\"btn mt-2\" name=\"refresh_favicons\">Refresh Favicons</button>\n        \"\"\",\n            html,\n            count=0,\n        )\n\n    def test_enable_preview_image_should_schedule_preview_update(self):\n        with patch.object(\n            tasks, \"schedule_bookmarks_without_previews\"\n        ) as mock_schedule_bookmarks_without_previews:\n            # Enabling favicons schedules update\n            form_data = self.create_profile_form_data(\n                {\n                    \"enable_preview_images\": True,\n                }\n            )\n            self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n            mock_schedule_bookmarks_without_previews.assert_called_once_with(self.user)\n\n            # No update scheduled if favicons are already enabled\n            mock_schedule_bookmarks_without_previews.reset_mock()\n\n            self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n            mock_schedule_bookmarks_without_previews.assert_not_called()\n\n            # No update scheduled when disabling favicons\n            form_data = self.create_profile_form_data(\n                {\n                    \"enable_preview_images\": False,\n                }\n            )\n\n            self.client.post(reverse(\"linkding:settings.update\"), form_data)\n\n            mock_schedule_bookmarks_without_previews.assert_not_called()\n\n    def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(\n        self,\n    ):\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <input type=\"checkbox\" name=\"enable_automatic_html_snapshots\"\n            aria-describedby=\"id_enable_automatic_html_snapshots_help\"\n            id=\"id_enable_automatic_html_snapshots\" checked=\"\">\n            \"\"\",\n            html,\n            count=0,\n        )\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_automatic_html_snapshots_should_be_visible_when_snapshots_supported(\n        self,\n    ):\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <input type=\"checkbox\" name=\"enable_automatic_html_snapshots\"\n            aria-describedby=\"id_enable_automatic_html_snapshots_help\"\n            id=\"id_enable_automatic_html_snapshots\" checked=\"\">\n            \"\"\",\n            html,\n            count=1,\n        )\n\n    def test_about_shows_version_info(self):\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            f\"\"\"\n            <tr>\n                <td>Version</td>\n                <td>{get_version_info(random.random())}</td>\n            </tr>\n        \"\"\",\n            html,\n        )\n\n    def test_get_version_info_just_displays_latest_when_versions_are_equal(self):\n        latest_version_response_mock = Mock(\n            status_code=200, json=lambda: {\"name\": f\"v{app_version}\"}\n        )\n        with patch.object(requests, \"get\", return_value=latest_version_response_mock):\n            version_info = get_version_info(random.random())\n            self.assertEqual(version_info, f\"{app_version} (latest)\")\n\n    def test_get_version_info_shows_latest_version_when_versions_are_not_equal(self):\n        latest_version_response_mock = Mock(\n            status_code=200, json=lambda: {\"name\": \"v123.0.1\"}\n        )\n        with patch.object(requests, \"get\", return_value=latest_version_response_mock):\n            version_info = get_version_info(random.random())\n            self.assertEqual(version_info, f\"{app_version} (latest: 123.0.1)\")\n\n    def test_get_version_info_silently_ignores_request_errors(self):\n        with patch.object(requests, \"get\", side_effect=RequestException()):\n            version_info = get_version_info(random.random())\n            self.assertEqual(version_info, f\"{app_version}\")\n\n    def test_get_version_info_handles_invalid_response(self):\n        latest_version_response_mock = Mock(status_code=403, json=lambda: {})\n        with patch.object(requests, \"get\", return_value=latest_version_response_mock):\n            version_info = get_version_info(random.random())\n            self.assertEqual(version_info, app_version)\n\n        latest_version_response_mock = Mock(status_code=200, json=lambda: {})\n        with patch.object(requests, \"get\", return_value=latest_version_response_mock):\n            version_info = get_version_info(random.random())\n            self.assertEqual(version_info, app_version)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_missing_html_snapshots(self):\n        with patch.object(\n            tasks, \"create_missing_html_snapshots\"\n        ) as mock_create_missing_html_snapshots:\n            mock_create_missing_html_snapshots.return_value = 5\n            form_data = {\n                \"create_missing_html_snapshots\": \"\",\n            }\n            response = self.client.post(\n                reverse(\"linkding:settings.update\"), form_data, follow=True\n            )\n            html = response.content.decode()\n\n            self.assertEqual(response.status_code, 200)\n            mock_create_missing_html_snapshots.assert_called_once()\n            self.assertSuccessMessage(\n                html, \"Queued 5 missing snapshots. This may take a while...\"\n            )\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_missing_html_snapshots_no_missing_snapshots(self):\n        with patch.object(\n            tasks, \"create_missing_html_snapshots\"\n        ) as mock_create_missing_html_snapshots:\n            mock_create_missing_html_snapshots.return_value = 0\n            form_data = {\n                \"create_missing_html_snapshots\": \"\",\n            }\n            response = self.client.post(\n                reverse(\"linkding:settings.update\"), form_data, follow=True\n            )\n            html = response.content.decode()\n\n            self.assertEqual(response.status_code, 200)\n            mock_create_missing_html_snapshots.assert_called_once()\n            self.assertSuccessMessage(html, \"No missing snapshots found.\")\n\n    def test_create_missing_html_snapshots_should_not_be_called_without_respective_form_action(\n        self,\n    ):\n        with patch.object(\n            tasks, \"create_missing_html_snapshots\"\n        ) as mock_create_missing_html_snapshots:\n            mock_create_missing_html_snapshots.return_value = 5\n            form_data = {}\n            response = self.client.post(\n                reverse(\"linkding:settings.update\"), form_data, follow=True\n            )\n            html = response.content.decode()\n\n            self.assertEqual(response.status_code, 200)\n            mock_create_missing_html_snapshots.assert_not_called()\n            self.assertSuccessMessage(\n                html, \"Queued 5 missing snapshots. This may take a while...\", count=0\n            )\n\n    def test_update_global_settings(self):\n        superuser = self.setup_superuser()\n        self.client.force_login(superuser)\n        selectable_user = self.setup_user()\n\n        # Update global settings\n        form_data = {\n            \"update_global_settings\": \"\",\n            \"landing_page\": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,\n            \"guest_profile_user\": selectable_user.id,\n        }\n        response = self.client.post(\n            reverse(\"linkding:settings.update\"), form_data, follow=True\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertSuccessMessage(response.content.decode(), \"Global settings updated\")\n\n        global_settings = GlobalSettings.get()\n        self.assertEqual(global_settings.landing_page, form_data[\"landing_page\"])\n        self.assertEqual(global_settings.guest_profile_user, selectable_user)\n\n        # Revert settings\n        form_data = {\n            \"update_global_settings\": \"\",\n            \"landing_page\": GlobalSettings.LANDING_PAGE_LOGIN,\n            \"guest_profile_user\": \"\",\n        }\n        response = self.client.post(\n            reverse(\"linkding:settings.update\"), form_data, follow=True\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertSuccessMessage(response.content.decode(), \"Global settings updated\")\n\n        global_settings = GlobalSettings.get()\n        global_settings.refresh_from_db()\n        self.assertEqual(global_settings.landing_page, form_data[\"landing_page\"])\n        self.assertIsNone(global_settings.guest_profile_user)\n\n    def test_update_global_settings_should_not_be_called_without_respective_form_action(\n        self,\n    ):\n        superuser = self.setup_superuser()\n        self.client.force_login(superuser)\n\n        form_data = {\n            \"landing_page\": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,\n        }\n        response = self.client.post(\n            reverse(\"linkding:settings.update\"), form_data, follow=True\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertSuccessMessage(\n            response.content.decode(), \"Global settings updated\", count=0\n        )\n\n    def test_update_global_settings_checks_for_superuser(self):\n        form_data = {\n            \"update_global_settings\": \"\",\n            \"landing_page\": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,\n        }\n        response = self.client.post(reverse(\"linkding:settings.update\"), form_data)\n        self.assertEqual(response.status_code, 403)\n\n    def test_global_settings_only_visible_for_superuser(self):\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<h2 id=\"global-settings-heading\">Global settings</h2>',\n            html,\n            count=0,\n        )\n\n        superuser = self.setup_superuser()\n        self.client.force_login(superuser)\n\n        response = self.client.get(reverse(\"linkding:settings.general\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            '<h2 id=\"global-settings-heading\">Global settings</h2>',\n            html,\n            count=1,\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_settings_import_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Bookmark\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging\n\n\nclass SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def assertSuccessMessage(self, response, message: str):\n        self.assertInHTML(\n            f\"\"\"\n            <div class=\"toast toast-success mb-4\">{message}</div>\n        \"\"\",\n            response.content.decode(\"utf-8\"),\n        )\n\n    def assertNoSuccessMessage(self, response):\n        self.assertNotContains(response, '<div class=\"toast toast-success mb-4\">')\n\n    def assertErrorMessage(self, response, message: str):\n        self.assertInHTML(\n            f\"\"\"\n            <div class=\"toast toast-error mb-4\">{message}</div>\n        \"\"\",\n            response.content.decode(\"utf-8\"),\n        )\n\n    def assertNoErrorMessage(self, response):\n        self.assertNotContains(response, '<div class=\"toast toast-error mb-4\">')\n\n    def test_should_import_successfully(self):\n        with open(\n            \"bookmarks/tests/resources/simple_valid_import_file.html\"\n        ) as import_file:\n            response = self.client.post(\n                reverse(\"linkding:settings.import\"),\n                {\"import_file\": import_file},\n                follow=True,\n            )\n\n            self.assertRedirects(response, reverse(\"linkding:settings.general\"))\n            self.assertSuccessMessage(\n                response, \"3 bookmarks were successfully imported.\"\n            )\n            self.assertNoErrorMessage(response)\n\n    def test_should_check_authentication(self):\n        self.client.logout()\n        response = self.client.get(reverse(\"linkding:settings.import\"), follow=True)\n\n        self.assertRedirects(\n            response, reverse(\"login\") + \"?next=\" + reverse(\"linkding:settings.import\")\n        )\n\n    def test_should_show_hint_if_there_is_no_file(self):\n        response = self.client.post(reverse(\"linkding:settings.import\"), follow=True)\n\n        self.assertRedirects(response, reverse(\"linkding:settings.general\"))\n        self.assertNoSuccessMessage(response)\n        self.assertErrorMessage(response, \"Please select a file to import.\")\n\n    @disable_logging\n    def test_should_show_hint_if_import_raises_exception(self):\n        with open(\n            \"bookmarks/tests/resources/invalid_import_file.png\", \"rb\"\n        ) as import_file:\n            response = self.client.post(\n                reverse(\"linkding:settings.import\"),\n                {\"import_file\": import_file},\n                follow=True,\n            )\n\n            self.assertRedirects(response, reverse(\"linkding:settings.general\"))\n            self.assertNoSuccessMessage(response)\n            self.assertErrorMessage(\n                response, \"An error occurred during bookmark import.\"\n            )\n\n    @disable_logging\n    def test_should_show_respective_hints_if_not_all_bookmarks_were_imported_successfully(\n        self,\n    ):\n        with open(\n            \"bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html\"\n        ) as import_file:\n            response = self.client.post(\n                reverse(\"linkding:settings.import\"),\n                {\"import_file\": import_file},\n                follow=True,\n            )\n\n            self.assertRedirects(response, reverse(\"linkding:settings.general\"))\n            self.assertSuccessMessage(\n                response, \"2 bookmarks were successfully imported.\"\n            )\n            self.assertErrorMessage(\n                response,\n                \"1 bookmarks could not be imported. Please check the logs for more details.\",\n            )\n\n    def test_should_respect_map_private_flag_option(self):\n        with open(\n            \"bookmarks/tests/resources/simple_valid_import_file.html\"\n        ) as import_file:\n            self.client.post(\n                reverse(\"linkding:settings.import\"),\n                {\"import_file\": import_file},\n                follow=True,\n            )\n\n            self.assertEqual(Bookmark.objects.count(), 3)\n            self.assertEqual(Bookmark.objects.all()[0].shared, False)\n            self.assertEqual(Bookmark.objects.all()[1].shared, False)\n            self.assertEqual(Bookmark.objects.all()[2].shared, False)\n\n        Bookmark.objects.all().delete()\n\n        with open(\n            \"bookmarks/tests/resources/simple_valid_import_file.html\"\n        ) as import_file:\n            self.client.post(\n                reverse(\"linkding:settings.import\"),\n                {\"import_file\": import_file, \"map_private_flag\": \"on\"},\n                follow=True,\n            )\n\n            self.assertEqual(Bookmark.objects.count(), 3)\n            self.assertEqual(Bookmark.objects.all()[0].shared, True)\n            self.assertEqual(Bookmark.objects.all()[1].shared, True)\n            self.assertEqual(Bookmark.objects.all()[2].shared, True)\n"
  },
  {
    "path": "bookmarks/tests/test_settings_integrations_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import ApiToken, FeedToken\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def test_should_render_successfully(self):\n        response = self.client.get(reverse(\"linkding:settings.integrations\"))\n\n        self.assertEqual(response.status_code, 200)\n\n    def test_should_check_authentication(self):\n        self.client.logout()\n        response = self.client.get(\n            reverse(\"linkding:settings.integrations\"), follow=True\n        )\n\n        self.assertRedirects(\n            response,\n            reverse(\"login\") + \"?next=\" + reverse(\"linkding:settings.integrations\"),\n        )\n\n    def test_create_api_token(self):\n        response = self.client.post(\n            reverse(\"linkding:settings.integrations.create_api_token\"),\n            {\"name\": \"My Test Token\"},\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:settings.integrations\"))\n        self.assertEqual(ApiToken.objects.count(), 1)\n        token = ApiToken.objects.first()\n        self.assertEqual(token.user, self.user)\n        self.assertEqual(token.name, \"My Test Token\")\n\n    def test_create_api_token_with_empty_name(self):\n        self.client.post(\n            reverse(\"linkding:settings.integrations.create_api_token\"),\n            {\"name\": \"\"},\n        )\n\n        self.assertEqual(ApiToken.objects.count(), 1)\n        token = ApiToken.objects.first()\n        self.assertEqual(token.name, \"API Token\")\n\n    def test_create_api_token_shows_key_once(self):\n        self.client.post(\n            reverse(\"linkding:settings.integrations.create_api_token\"),\n            {\"name\": \"My Token\"},\n        )\n\n        # First load should show the token\n        response = self.client.get(reverse(\"linkding:settings.integrations\"))\n        token = ApiToken.objects.first()\n        self.assertContains(response, token.key)\n\n        # Second load should not show the token\n        response = self.client.get(reverse(\"linkding:settings.integrations\"))\n        self.assertNotContains(response, token.key)\n\n    def test_delete_api_token(self):\n        token = self.setup_api_token(name=\"To Delete\")\n\n        response = self.client.post(\n            reverse(\"linkding:settings.integrations.delete_api_token\"),\n            {\"token_id\": token.id},\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:settings.integrations\"))\n        self.assertEqual(ApiToken.objects.count(), 0)\n\n    def test_delete_api_token_wrong_user(self):\n        other_user = self.setup_user(name=\"other\")\n        token = self.setup_api_token(user=other_user, name=\"Other's Token\")\n\n        response = self.client.post(\n            reverse(\"linkding:settings.integrations.delete_api_token\"),\n            {\"token_id\": token.id},\n        )\n\n        self.assertEqual(response.status_code, 404)\n        self.assertEqual(ApiToken.objects.count(), 1)\n\n    def test_list_api_tokens(self):\n        self.setup_api_token(name=\"Token 1\")\n        self.setup_api_token(name=\"Token 2\")\n\n        other_user = self.setup_user(name=\"other\")\n        self.setup_api_token(user=other_user, name=\"Other's Token\")\n\n        response = self.client.get(reverse(\"linkding:settings.integrations\"))\n        soup = self.make_soup(response.content.decode())\n\n        section = soup.find(\"turbo-frame\", id=\"api-section\")\n        table = section.find(\"table\")\n        rows = table.find_all(\"tr\")\n\n        self.assertEqual(len(rows), 3)\n\n        first_row_cells = rows[1].find_all(\"td\")\n        self.assertEqual(first_row_cells[0].get_text(strip=True), \"Token 2\")\n        self.assertIsNotNone(first_row_cells[1].get_text(strip=True))\n\n        second_row_cells = rows[2].find_all(\"td\")\n        self.assertEqual(second_row_cells[0].get_text(strip=True), \"Token 1\")\n        self.assertIsNotNone(second_row_cells[1].get_text(strip=True))\n\n    def test_should_generate_feed_token_if_not_exists(self):\n        self.assertEqual(FeedToken.objects.count(), 0)\n\n        self.client.get(reverse(\"linkding:settings.integrations\"))\n\n        self.assertEqual(FeedToken.objects.count(), 1)\n        token = FeedToken.objects.first()\n        self.assertEqual(token.user, self.user)\n\n    def test_should_not_generate_feed_token_if_exists(self):\n        FeedToken.objects.get_or_create(user=self.user)\n        self.assertEqual(FeedToken.objects.count(), 1)\n\n        self.client.get(reverse(\"linkding:settings.integrations\"))\n\n        self.assertEqual(FeedToken.objects.count(), 1)\n\n    def test_should_display_feed_urls(self):\n        response = self.client.get(reverse(\"linkding:settings.integrations\"))\n        html = response.content.decode()\n\n        token = FeedToken.objects.first()\n        self.assertInHTML(\n            f'<a target=\"_blank\" href=\"/feeds/{token.key}/all\">All bookmarks</a>',\n            html,\n        )\n        self.assertInHTML(\n            f'<a target=\"_blank\" href=\"/feeds/{token.key}/unread\">Unread bookmarks</a>',\n            html,\n        )\n        self.assertInHTML(\n            f'<a target=\"_blank\" href=\"/feeds/{token.key}/shared\">Shared bookmarks</a>',\n            html,\n        )\n        self.assertInHTML(\n            '<a target=\"_blank\" href=\"/feeds/shared\">Public shared bookmarks</a>',\n            html,\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_singlefile_service.py",
    "content": "import os\nimport subprocess\nimport tempfile\nfrom unittest import mock\n\nfrom django.test import TestCase, override_settings\n\nfrom bookmarks.services import singlefile\n\n\nclass SingleFileServiceTestCase(TestCase):\n    def setUp(self):\n        self.temp_html_filepath = None\n\n    def tearDown(self):\n        if self.temp_html_filepath and os.path.exists(self.temp_html_filepath):\n            os.remove(self.temp_html_filepath)\n\n    def create_test_file(self, *args, **kwargs):\n        self.temp_html_filepath = tempfile.mkstemp(suffix=\".tmp\")[1]\n\n    def test_create_snapshot_failure(self):\n        # subprocess fails - which it probably doesn't as single-file doesn't return exit codes\n        with mock.patch(\"subprocess.Popen\") as mock_popen:\n            mock_popen.side_effect = subprocess.CalledProcessError(1, \"command\")\n\n            with self.assertRaises(singlefile.SingleFileError):\n                singlefile.create_snapshot(\"http://example.com\", \"nonexistentfile.tmp\")\n\n        # so also check that it raises error if output file isn't created\n        with (\n            mock.patch(\"subprocess.Popen\"),\n            self.assertRaises(singlefile.SingleFileError),\n        ):\n            singlefile.create_snapshot(\"http://example.com\", \"nonexistentfile.tmp\")\n\n    def test_create_snapshot_empty_options(self):\n        mock_process = mock.Mock()\n        mock_process.wait.return_value = 0\n        self.create_test_file()\n\n        with mock.patch(\"subprocess.Popen\") as mock_popen:\n            singlefile.create_snapshot(\"http://example.com\", self.temp_html_filepath)\n\n            expected_args = [\n                \"single-file\",\n                '--browser-arg=\"--headless=new\"',\n                '--browser-arg=\"--user-data-dir=./chromium-profile\"',\n                '--browser-arg=\"--no-sandbox\"',\n                '--browser-arg=\"--load-extension=uBOLite.chromium.mv3\"',\n                \"http://example.com\",\n                self.temp_html_filepath,\n            ]\n            mock_popen.assert_called_with(expected_args, start_new_session=True)\n\n    @override_settings(\n        LD_SINGLEFILE_OPTIONS='--some-option \"some value\" --another-option \"another value\" --third-option=\"third value\"'\n    )\n    def test_create_snapshot_custom_options(self):\n        mock_process = mock.Mock()\n        mock_process.wait.return_value = 0\n        self.create_test_file()\n\n        with mock.patch(\"subprocess.Popen\") as mock_popen:\n            singlefile.create_snapshot(\"http://example.com\", self.temp_html_filepath)\n\n            expected_args = [\n                \"single-file\",\n                '--browser-arg=\"--headless=new\"',\n                '--browser-arg=\"--user-data-dir=./chromium-profile\"',\n                '--browser-arg=\"--no-sandbox\"',\n                '--browser-arg=\"--load-extension=uBOLite.chromium.mv3\"',\n                \"--some-option\",\n                \"some value\",\n                \"--another-option\",\n                \"another value\",\n                \"--third-option=third value\",\n                \"http://example.com\",\n                self.temp_html_filepath,\n            ]\n            mock_popen.assert_called_with(expected_args, start_new_session=True)\n\n    def test_create_snapshot_default_timeout_setting(self):\n        mock_process = mock.Mock()\n        mock_process.wait.return_value = 0\n        self.create_test_file()\n\n        with mock.patch(\"subprocess.Popen\", return_value=mock_process):\n            singlefile.create_snapshot(\"http://example.com\", self.temp_html_filepath)\n\n            mock_process.wait.assert_called_with(timeout=120)\n\n    @override_settings(LD_SINGLEFILE_TIMEOUT_SEC=180)\n    def test_create_snapshot_custom_timeout_setting(self):\n        mock_process = mock.Mock()\n        mock_process.wait.return_value = 0\n        self.create_test_file()\n\n        with mock.patch(\"subprocess.Popen\", return_value=mock_process):\n            singlefile.create_snapshot(\"http://example.com\", self.temp_html_filepath)\n\n            mock_process.wait.assert_called_with(timeout=180)\n"
  },
  {
    "path": "bookmarks/tests/test_tag_cloud_template.py",
    "content": "from django.contrib.auth.models import AnonymousUser, User\nfrom django.http import HttpResponse\nfrom django.template import RequestContext, Template\nfrom django.test import RequestFactory, TestCase\n\nfrom bookmarks.middlewares import LinkdingMiddleware\nfrom bookmarks.models import BookmarkSearch, UserProfile\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\nfrom bookmarks.views import contexts\n\n\nclass TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def render_template(\n        self,\n        context_type: type[contexts.TagCloudContext] = contexts.ActiveTagCloudContext,\n        url: str = \"/test\",\n        user: User | AnonymousUser = None,\n    ):\n        rf = RequestFactory()\n        request = rf.get(url)\n        request.user = user or self.get_or_create_test_user()\n        middleware = LinkdingMiddleware(lambda r: HttpResponse())\n        middleware(request)\n\n        search = BookmarkSearch.from_request(\n            request, request.GET, request.user_profile.search_preferences\n        )\n        tag_cloud_context = context_type(request, search)\n        context = RequestContext(request, {\"tag_cloud\": tag_cloud_context})\n        template_to_render = Template(\"{% include 'bookmarks/tag_cloud.html' %}\")\n        return template_to_render.render(context)\n\n    def assertTagGroups(\n        self,\n        rendered_template: str,\n        groups: list[list[str]],\n        highlight_first_char: bool = True,\n    ):\n        soup = self.make_soup(rendered_template)\n        group_elements = soup.select(\"p.group\")\n\n        self.assertEqual(len(group_elements), len(groups))\n\n        for group_index, tags in enumerate(groups, start=0):\n            group_element = group_elements[group_index]\n            link_elements = group_element.select(\"a\")\n\n            self.assertEqual(len(link_elements), len(tags), tags)\n\n            for tag_index, tag in enumerate(tags, start=0):\n                link_element = link_elements[tag_index]\n                self.assertEqual(link_element.text.strip(), tag)\n\n                if tag_index == 0:\n                    if highlight_first_char:\n                        self.assertIn(\n                            f'<span class=\"highlight-char\">{tag[0]}</span>',\n                            str(link_element),\n                        )\n                    else:\n                        self.assertNotIn(\n                            f'<span class=\"highlight-char\">{tag[0]}</span>',\n                            str(link_element),\n                        )\n\n    def assertNumSelectedTags(self, rendered_template: str, count: int):\n        soup = self.make_soup(rendered_template)\n        link_elements = soup.select(\"p.selected-tags a\")\n        self.assertEqual(len(link_elements), count)\n\n    def test_cjk_using_single_group(self):\n        \"\"\"\n        Ideographic characters will be using the same group\n        While other japanese and korean characters will have separate groups.\n        \"\"\"\n        tags = [\n            self.setup_tag(name=\"Aardvark\"),\n            self.setup_tag(name=\"Armadillo\"),\n            self.setup_tag(name=\"あひる\"),\n            self.setup_tag(name=\"あきらか\"),\n            self.setup_tag(name=\"アヒル\"),\n            self.setup_tag(name=\"アキラカ\"),\n            self.setup_tag(name=\"ひる\"),\n            self.setup_tag(name=\"アヒル\"),\n            self.setup_tag(name=\"오리\"),\n            self.setup_tag(name=\"물\"),\n            self.setup_tag(name=\"家鴨\"),\n            self.setup_tag(name=\"感じ\"),\n        ]\n        self.setup_bookmark(tags=tags)\n        rendered_template = self.render_template()\n\n        self.assertTagGroups(\n            rendered_template,\n            [\n                [\n                    \"Aardvark\",\n                    \"Armadillo\",\n                ],\n                [\n                    \"あきらか\",\n                    \"あひる\",\n                ],\n                [\n                    \"ひる\",\n                ],\n                [\n                    \"アキラカ\",\n                    \"アヒル\",\n                ],\n                [\n                    \"물\",\n                ],\n                [\n                    \"오리\",\n                ],\n                [\n                    \"家鴨\",\n                    \"感じ\",\n                ],\n            ],\n        )\n\n    def test_group_alphabetically(self):\n        tags = [\n            self.setup_tag(name=\"Cockatoo\"),\n            self.setup_tag(name=\"Badger\"),\n            self.setup_tag(name=\"Buffalo\"),\n            self.setup_tag(name=\"Chihuahua\"),\n            self.setup_tag(name=\"Alpaca\"),\n            self.setup_tag(name=\"Coyote\"),\n            self.setup_tag(name=\"Aardvark\"),\n            self.setup_tag(name=\"Bumblebee\"),\n            self.setup_tag(name=\"Armadillo\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        rendered_template = self.render_template()\n\n        self.assertTagGroups(\n            rendered_template,\n            [\n                [\n                    \"Aardvark\",\n                    \"Alpaca\",\n                    \"Armadillo\",\n                ],\n                [\n                    \"Badger\",\n                    \"Buffalo\",\n                    \"Bumblebee\",\n                ],\n                [\n                    \"Chihuahua\",\n                    \"Cockatoo\",\n                    \"Coyote\",\n                ],\n            ],\n        )\n\n    def test_group_when_grouping_disabled(self):\n        profile = self.get_or_create_test_user().profile\n        profile.tag_grouping = UserProfile.TAG_GROUPING_DISABLED\n        profile.save()\n\n        tags = [\n            self.setup_tag(name=\"Cockatoo\"),\n            self.setup_tag(name=\"Badger\"),\n            self.setup_tag(name=\"Buffalo\"),\n            self.setup_tag(name=\"Chihuahua\"),\n            self.setup_tag(name=\"Alpaca\"),\n            self.setup_tag(name=\"Coyote\"),\n            self.setup_tag(name=\"Aardvark\"),\n            self.setup_tag(name=\"Bumblebee\"),\n            self.setup_tag(name=\"Armadillo\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        rendered_template = self.render_template()\n\n        self.assertTagGroups(\n            rendered_template,\n            [\n                [\n                    \"Aardvark\",\n                    \"Alpaca\",\n                    \"Armadillo\",\n                    \"Badger\",\n                    \"Buffalo\",\n                    \"Bumblebee\",\n                    \"Chihuahua\",\n                    \"Cockatoo\",\n                    \"Coyote\",\n                ],\n            ],\n            False,\n        )\n\n    def test_no_duplicate_tag_names(self):\n        tags = [\n            self.setup_tag(name=\"shared\", user=self.setup_user(enable_sharing=True)),\n            self.setup_tag(name=\"shared\", user=self.setup_user(enable_sharing=True)),\n            self.setup_tag(name=\"shared\", user=self.setup_user(enable_sharing=True)),\n        ]\n        for tag in tags:\n            self.setup_bookmark(tags=[tag], user=tag.owner, shared=True)\n\n        rendered_template = self.render_template(\n            context_type=contexts.SharedTagCloudContext\n        )\n\n        self.assertTagGroups(\n            rendered_template,\n            [\n                [\n                    \"shared\",\n                ],\n            ],\n        )\n\n    def test_tag_url_respects_search_options(self):\n        tag = self.setup_tag(name=\"tag1\")\n        self.setup_bookmark(tags=[tag], title=\"term1\")\n\n        rendered_template = self.render_template(url=\"/test?q=term1&sort=title_asc\")\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=term1+%23tag1&sort=title_asc\" class=\"mr-2\" data-is-tag-item>\n              <span class=\"highlight-char\">t</span><span>ag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_tag_url_removes_page_number_and_details_id(self):\n        tag = self.setup_tag(name=\"tag1\")\n        self.setup_bookmark(tags=[tag], title=\"term1\")\n\n        rendered_template = self.render_template(\n            url=\"/test?q=term1&sort=title_asc&page=2&details=5\"\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=term1+%23tag1&sort=title_asc\" class=\"mr-2\" data-is-tag-item>\n              <span class=\"highlight-char\">t</span><span>ag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_tag_url_wraps_or_expression_in_parenthesis(self):\n        tag = self.setup_tag(name=\"tag1\")\n        self.setup_bookmark(tags=[tag], title=\"term1\")\n\n        rendered_template = self.render_template(url=\"/test?q=term1 or term2\")\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%28term1+or+term2%29+%23tag1\" class=\"mr-2\" data-is-tag-item>\n              <span class=\"highlight-char\">t</span><span>ag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tags(self):\n        tags = [\n            self.setup_tag(name=\"tag1\"),\n            self.setup_tag(name=\"tag2\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        rendered_template = self.render_template(url=\"/test?q=%23tag1 %23tag2\")\n\n        self.assertNumSelectedTags(rendered_template, 2)\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag2\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag1\"\n               class=\"text-bold mr-2\">\n                <span>-tag2</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tags_complex_queries(self):\n        tags = [\n            self.setup_tag(name=\"tag1\"),\n            self.setup_tag(name=\"tag2\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        rendered_template = self.render_template(url=\"/test?q=%23tag1 or not %23tag2\")\n\n        self.assertNumSelectedTags(rendered_template, 2)\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=not+%23tag2\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag1\"\n               class=\"text-bold mr-2\">\n                <span>-tag2</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n        rendered_template = self.render_template(\n            url=\"/test?q=%23tag1 and not (%23tag2 or term)\"\n        )\n\n        self.assertNumSelectedTags(rendered_template, 2)\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=not+%28%23tag2+or+term%29\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag1+not+term\"\n               class=\"text-bold mr-2\">\n                <span>-tag2</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tags_with_lax_tag_search(self):\n        profile = self.get_or_create_test_user().profile\n        profile.tag_search = UserProfile.TAG_SEARCH_LAX\n        profile.save()\n\n        tags = [\n            self.setup_tag(name=\"tag1\"),\n            self.setup_tag(name=\"tag2\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        # Filter by tag name without hash\n        rendered_template = self.render_template(url=\"/test?q=tag1 %23tag2\")\n\n        self.assertNumSelectedTags(rendered_template, 2)\n\n        # Tag name should still be removed from query string\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag2\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=tag1\"\n               class=\"text-bold mr-2\">\n                <span>-tag2</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tags_ignore_casing_when_removing_query_part(self):\n        tags = [\n            self.setup_tag(name=\"TEST\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        rendered_template = self.render_template(url=\"/test?q=%23test\")\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=\"\n               class=\"text-bold mr-2\">\n                <span>-TEST</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_no_duplicate_selected_tags(self):\n        tags = [\n            self.setup_tag(name=\"shared\", user=self.setup_user(enable_sharing=True)),\n            self.setup_tag(name=\"shared\", user=self.setup_user(enable_sharing=True)),\n            self.setup_tag(name=\"shared\", user=self.setup_user(enable_sharing=True)),\n        ]\n        for tag in tags:\n            self.setup_bookmark(tags=[tag], shared=True, user=tag.owner)\n\n        rendered_template = self.render_template(\n            context_type=contexts.SharedTagCloudContext, url=\"/test?q=%23shared\"\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=\"\n               class=\"text-bold mr-2\">\n                <span>-shared</span>\n            </a>\n        \"\"\",\n            rendered_template,\n            count=1,\n        )\n\n    def test_selected_tag_url_keeps_other_query_terms(self):\n        tag = self.setup_tag(name=\"tag1\")\n        self.setup_bookmark(tags=[tag], title=\"term1\", description=\"term2\")\n\n        rendered_template = self.render_template(url=\"/test?q=term1 %23tag1 term2\")\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=term1+term2\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tag_url_respects_search_options(self):\n        tag = self.setup_tag(name=\"tag1\")\n        self.setup_bookmark(tags=[tag], title=\"term1\", description=\"term2\")\n\n        rendered_template = self.render_template(\n            url=\"/test?q=term1 %23tag1 term2&sort=title_asc\"\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=term1+term2&sort=title_asc\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tag_url_removes_page_number_and_details_id(self):\n        tag = self.setup_tag(name=\"tag1\")\n        self.setup_bookmark(tags=[tag], title=\"term1\", description=\"term2\")\n\n        rendered_template = self.render_template(\n            url=\"/test?q=term1 %23tag1 term2&sort=title_asc&page=2&details=5\"\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=term1+term2&sort=title_asc\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_selected_tags_are_excluded_from_groups(self):\n        tags = [\n            self.setup_tag(name=\"tag1\"),\n            self.setup_tag(name=\"tag2\"),\n            self.setup_tag(name=\"tag3\"),\n            self.setup_tag(name=\"tag4\"),\n            self.setup_tag(name=\"tag5\"),\n        ]\n        self.setup_bookmark(tags=tags)\n\n        rendered_template = self.render_template(url=\"/test?q=%23tag1 %23tag2\")\n\n        self.assertTagGroups(rendered_template, [[\"tag3\", \"tag4\", \"tag5\"]])\n\n        rendered_template = self.render_template(\n            url=\"/test?q=%23tag1 or (%23tag2 or not term)\"\n        )\n\n        self.assertTagGroups(rendered_template, [[\"tag3\", \"tag4\", \"tag5\"]])\n\n    def test_with_anonymous_user(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.enable_public_sharing = True\n        profile.save()\n\n        tags = [\n            self.setup_tag(name=\"tag1\"),\n            self.setup_tag(name=\"tag2\"),\n            self.setup_tag(name=\"tag3\"),\n            self.setup_tag(name=\"tag4\"),\n            self.setup_tag(name=\"tag5\"),\n        ]\n        self.setup_bookmark(tags=tags, shared=True)\n\n        rendered_template = self.render_template(\n            context_type=contexts.SharedTagCloudContext,\n            url=\"/test?q=%23tag1 %23tag2\",\n            user=AnonymousUser(),\n        )\n\n        self.assertTagGroups(rendered_template, [[\"tag3\", \"tag4\", \"tag5\"]])\n        self.assertNumSelectedTags(rendered_template, 2)\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag2\"\n               class=\"text-bold mr-2\">\n                <span>-tag1</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <a href=\"?q=%23tag1\"\n               class=\"text-bold mr-2\">\n                <span>-tag2</span>\n            </a>\n        \"\"\",\n            rendered_template,\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_tags_edit_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.user = self.get_or_create_test_user()\n        self.client.force_login(self.user)\n\n    def test_update_tag(self):\n        tag = self.setup_tag(name=\"old_name\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.edit\", args=[tag.id]), {\"name\": \"new_name\"}\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n\n        tag.refresh_from_db()\n        self.assertEqual(tag.name, \"new_name\")\n\n    def test_allow_case_changes(self):\n        tag = self.setup_tag(name=\"tag\")\n\n        self.client.post(reverse(\"linkding:tags.edit\", args=[tag.id]), {\"name\": \"TAG\"})\n\n        tag.refresh_from_db()\n        self.assertEqual(tag.name, \"TAG\")\n\n    def test_can_only_edit_own_tags(self):\n        other_user = self.setup_user()\n        tag = self.setup_tag(user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:tags.edit\", args=[tag.id]), {\"name\": \"new_name\"}\n        )\n\n        self.assertEqual(response.status_code, 404)\n        tag.refresh_from_db()\n        self.assertNotEqual(tag.name, \"new_name\")\n\n    def test_show_error_for_empty_name(self):\n        tag = self.setup_tag(name=\"tag1\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.edit\", args=[tag.id]), {\"name\": \"\"}\n        )\n\n        self.assertContains(response, \"This field is required\")\n        tag.refresh_from_db()\n        self.assertEqual(tag.name, \"tag1\")\n\n    def test_show_error_for_duplicate_name(self):\n        tag1 = self.setup_tag(name=\"tag1\")\n        self.setup_tag(name=\"tag2\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.edit\", args=[tag1.id]), {\"name\": \"tag2\"}\n        )\n\n        self.assertContains(response, \"Tag &quot;tag2&quot; already exists\")\n        tag1.refresh_from_db()\n        self.assertEqual(tag1.name, \"tag1\")\n\n    def test_show_error_for_duplicate_name_different_casing(self):\n        tag1 = self.setup_tag(name=\"tag1\")\n        self.setup_tag(name=\"tag2\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.edit\", args=[tag1.id]), {\"name\": \"TAG2\"}\n        )\n\n        self.assertContains(response, \"Tag &quot;TAG2&quot; already exists\")\n        tag1.refresh_from_db()\n        self.assertEqual(tag1.name, \"tag1\")\n\n    def test_no_error_for_duplicate_name_different_user(self):\n        other_user = self.setup_user()\n        self.setup_tag(name=\"tag1\", user=other_user)\n\n        tag2 = self.setup_tag(name=\"tag2\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.edit\", args=[tag2.id]), {\"name\": \"tag1\"}\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n        tag2.refresh_from_db()\n        self.assertEqual(tag2.name, \"tag1\")\n\n    def test_update_tag_preserves_query_parameters(self):\n        tag = self.setup_tag(name=\"old_name\")\n\n        url = (\n            reverse(\"linkding:tags.edit\", args=[tag.id])\n            + \"?search=search&unused=true&page=2&sort=name-desc\"\n        )\n        response = self.client.post(url, {\"name\": \"new_name\"})\n\n        expected_redirect = (\n            reverse(\"linkding:tags.index\")\n            + \"?search=search&unused=true&page=2&sort=name-desc\"\n        )\n        self.assertRedirects(response, expected_redirect)\n"
  },
  {
    "path": "bookmarks/tests/test_tags_index_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Tag\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        self.user = self.get_or_create_test_user()\n        self.client.force_login(self.user)\n\n    def get_rows(self, response):\n        html = response.content.decode()\n        soup = self.make_soup(html)\n        return soup.select(\".crud-table tbody tr\")\n\n    def find_row(self, rows, tag):\n        for row in rows:\n            if tag.name in row.get_text():\n                return row\n        return None\n\n    def assertRows(self, response, tags):\n        rows = self.get_rows(response)\n        self.assertEqual(len(rows), len(tags))\n        for tag in tags:\n            row = self.find_row(rows, tag)\n            self.assertIsNotNone(row, f\"Tag '{tag.name}' not found in table\")\n\n    def assertOrderedRows(self, response, tags):\n        rows = self.get_rows(response)\n        self.assertEqual(len(rows), len(tags))\n        for index, tag in enumerate(tags):\n            row = rows[index]\n            self.assertIn(\n                tag.name,\n                row.get_text(),\n                f\"Tag '{tag.name}' not found at index {index}\",\n            )\n\n    def test_list_tags(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        tag3 = self.setup_tag()\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertRows(response, [tag1, tag2, tag3])\n\n    def test_show_user_owned_tags(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        tag3 = self.setup_tag()\n\n        other_user = self.setup_user()\n        self.setup_tag(user=other_user)\n        self.setup_tag(user=other_user)\n        self.setup_tag(user=other_user)\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n\n        self.assertRows(response, [tag1, tag2, tag3])\n\n    def test_search_tags(self):\n        tag1 = self.setup_tag(name=\"programming\")\n        self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"django\")\n        self.setup_tag(name=\"design\")\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?search=prog\")\n\n        self.assertRows(response, [tag1])\n\n    def test_filter_unused_tags(self):\n        tag1 = self.setup_tag()\n        tag2 = self.setup_tag()\n        tag3 = self.setup_tag()\n\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark(tags=[tag3])\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?unused=true\")\n\n        self.assertRows(response, [tag2])\n\n    def test_rows_have_links_to_filtered_bookmarks(self):\n        tag1 = self.setup_tag(name=\"python\")\n        tag2 = self.setup_tag(name=\"django-framework\")\n\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark(tags=[tag1, tag2])\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n\n        rows = self.get_rows(response)\n\n        tag1_row = self.find_row(rows, tag1)\n        view_link = tag1_row.find(\"a\", string=lambda s: s and s.strip() == \"2\")\n        expected_url = reverse(\"linkding:bookmarks.index\") + \"?q=%23python\"\n        self.assertEqual(view_link[\"href\"], expected_url)\n\n        tag2_row = self.find_row(rows, tag2)\n        view_link = tag2_row.find(\"a\", string=lambda s: s and s.strip() == \"1\")\n        expected_url = reverse(\"linkding:bookmarks.index\") + \"?q=%23django-framework\"\n        self.assertEqual(view_link[\"href\"], expected_url)\n\n    def test_shows_tag_total(self):\n        tag1 = self.setup_tag(name=\"python\")\n        tag2 = self.setup_tag(name=\"javascript\")\n        tag3 = self.setup_tag(name=\"design\")\n        self.setup_tag(name=\"unused-tag\")\n\n        self.setup_bookmark(tags=[tag1])\n        self.setup_bookmark(tags=[tag2])\n        self.setup_bookmark(tags=[tag3])\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n        self.assertContains(response, \"4 tags total\")\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?search=python\")\n        self.assertContains(response, \"Showing 1 of 4 tags\")\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?unused=true\")\n        self.assertContains(response, \"Showing 1 of 4 tags\")\n\n        response = self.client.get(\n            reverse(\"linkding:tags.index\") + \"?search=nonexistent\"\n        )\n        self.assertContains(response, \"Showing 0 of 4 tags\")\n\n    def test_pagination(self):\n        tags = []\n        for _i in range(75):\n            tags.append(self.setup_tag())\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n        rows = self.get_rows(response)\n        self.assertEqual(len(rows), 50)\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?page=2\")\n        rows = self.get_rows(response)\n        self.assertEqual(len(rows), 25)\n\n    def test_delete_action(self):\n        tag = self.setup_tag(name=\"tag_to_delete\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.index\"), {\"delete_tag\": tag.id}\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n        self.assertFalse(Tag.objects.filter(id=tag.id).exists())\n\n    def test_tag_delete_action_preserves_query_parameters(self):\n        tag = self.setup_tag(name=\"search_tag\")\n\n        url = (\n            reverse(\"linkding:tags.index\")\n            + \"?search=search&unused=true&page=2&sort=name-desc\"\n        )\n        response = self.client.post(url, {\"delete_tag\": tag.id})\n\n        self.assertRedirects(response, url)\n\n    def test_tag_delete_action_only_deletes_own_tags(self):\n        other_user = self.setup_user()\n        other_tag = self.setup_tag(user=other_user, name=\"other_user_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.index\"), {\"delete_tag\": other_tag.id}, follow=True\n        )\n\n        self.assertEqual(response.status_code, 404)\n\n    def test_sort_by_name_ascending(self):\n        tag_c = self.setup_tag(name=\"c_tag\")\n        tag_a = self.setup_tag(name=\"a_tag\")\n        tag_b = self.setup_tag(name=\"b_tag\")\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?sort=name-asc\")\n\n        self.assertOrderedRows(response, [tag_a, tag_b, tag_c])\n\n    def test_sort_by_name_descending(self):\n        tag_c = self.setup_tag(name=\"c_tag\")\n        tag_a = self.setup_tag(name=\"a_tag\")\n        tag_b = self.setup_tag(name=\"b_tag\")\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?sort=name-desc\")\n\n        self.assertOrderedRows(response, [tag_c, tag_b, tag_a])\n\n    def test_sort_by_bookmark_count_ascending(self):\n        tag_few = self.setup_tag(name=\"few_bookmarks\")\n        tag_many = self.setup_tag(name=\"many_bookmarks\")\n        tag_none = self.setup_tag(name=\"no_bookmarks\")\n\n        self.setup_bookmark(tags=[tag_few])\n        self.setup_bookmark(tags=[tag_many])\n        self.setup_bookmark(tags=[tag_many])\n        self.setup_bookmark(tags=[tag_many])\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?sort=count-asc\")\n\n        self.assertOrderedRows(response, [tag_none, tag_few, tag_many])\n\n    def test_sort_by_bookmark_count_descending(self):\n        tag_few = self.setup_tag(name=\"few_bookmarks\")\n        tag_many = self.setup_tag(name=\"many_bookmarks\")\n        tag_none = self.setup_tag(name=\"no_bookmarks\")\n\n        self.setup_bookmark(tags=[tag_few])\n        self.setup_bookmark(tags=[tag_many])\n        self.setup_bookmark(tags=[tag_many])\n        self.setup_bookmark(tags=[tag_many])\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?sort=count-desc\")\n\n        self.assertOrderedRows(response, [tag_many, tag_few, tag_none])\n\n    def test_default_sort_is_name_ascending(self):\n        tag_c = self.setup_tag(name=\"c_tag\")\n        tag_a = self.setup_tag(name=\"a_tag\")\n        tag_b = self.setup_tag(name=\"b_tag\")\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n\n        self.assertOrderedRows(response, [tag_a, tag_b, tag_c])\n\n    def test_sort_select_has_correct_options_and_selection(self):\n        self.setup_tag()\n\n        response = self.client.get(reverse(\"linkding:tags.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <select id=\"sort\" name=\"sort\" class=\"form-select\" data-submit-on-change>\n              <option value=\"name-asc\" selected>Name A-Z</option>\n              <option value=\"name-desc\">Name Z-A</option>\n              <option value=\"count-asc\">Fewest bookmarks</option>\n              <option value=\"count-desc\">Most bookmarks</option>\n            </select>        \n        \"\"\",\n            html,\n        )\n\n        response = self.client.get(reverse(\"linkding:tags.index\") + \"?sort=name-desc\")\n        html = response.content.decode()\n\n        self.assertInHTML(\n            \"\"\"\n            <select id=\"sort\" name=\"sort\" class=\"form-select\" data-submit-on-change>\n              <option value=\"name-asc\">Name A-Z</option>\n              <option value=\"name-desc\" selected>Name Z-A</option>\n              <option value=\"count-asc\">Fewest bookmarks</option>\n              <option value=\"count-desc\">Most bookmarks</option>\n            </select>        \n        \"\"\",\n            html,\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_tags_merge_view.py",
    "content": "from bs4 import TemplateString\nfrom bs4.element import CData, NavigableString\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Bookmark, Tag\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin\n\n\nclass TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        self.user = self.get_or_create_test_user()\n        self.client.force_login(self.user)\n\n    def get_text(self, element):\n        # Invalid form responses are wrapped in <template> tags, which BeautifulSoup\n        # treats as TemplateString objects. Include those when extracting text.\n        return element.get_text(types=(NavigableString, CData, TemplateString))\n\n    def get_form_group(self, response, input_name):\n        soup = self.make_soup(response.content.decode())\n        input_element = soup.find(\"input\", {\"name\": input_name})\n        if input_element:\n            return input_element.find_parent(\"div\", class_=\"form-group\")\n        autocomplete_element = soup.find(\n            \"ld-tag-autocomplete\", {\"input-name\": input_name}\n        )\n        if autocomplete_element:\n            return autocomplete_element.find_parent(\"div\", class_=\"form-group\")\n        return None\n\n    def get_autocomplete(self, response, input_name):\n        soup = self.make_soup(response.content.decode())\n        return soup.find(\"ld-tag-autocomplete\", {\"input-name\": input_name})\n\n    def test_merge_tags(self):\n        target_tag = self.setup_tag(name=\"target_tag\")\n        merge_tag1 = self.setup_tag(name=\"merge_tag1\")\n        merge_tag2 = self.setup_tag(name=\"merge_tag2\")\n\n        bookmark1 = self.setup_bookmark(tags=[merge_tag1])\n        bookmark2 = self.setup_bookmark(tags=[merge_tag2])\n        bookmark3 = self.setup_bookmark(tags=[target_tag])\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"merge_tag1 merge_tag2\"},\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n\n        self.assertEqual(Tag.objects.count(), 1)\n        self.assertFalse(Tag.objects.filter(id=merge_tag1.id).exists())\n        self.assertFalse(Tag.objects.filter(id=merge_tag2.id).exists())\n\n        self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])\n\n    def test_merge_tags_complex(self):\n        target_tag = self.setup_tag(name=\"target_tag\")\n        merge_tag1 = self.setup_tag(name=\"merge_tag1\")\n        merge_tag2 = self.setup_tag(name=\"merge_tag2\")\n        other_tag = self.setup_tag(name=\"other_tag\")\n\n        bookmark1 = self.setup_bookmark(tags=[merge_tag1])\n        bookmark2 = self.setup_bookmark(tags=[merge_tag2])\n        bookmark3 = self.setup_bookmark(tags=[target_tag])\n        bookmark4 = self.setup_bookmark(\n            tags=[merge_tag1, merge_tag2]\n        )  # both merge tags\n        bookmark5 = self.setup_bookmark(\n            tags=[merge_tag2, target_tag]\n        )  # already has target tag\n        bookmark6 = self.setup_bookmark(\n            tags=[merge_tag1, merge_tag2, target_tag]\n        )  # both merge tags and target\n        bookmark7 = self.setup_bookmark(tags=[other_tag])  # unrelated tag\n        bookmark8 = self.setup_bookmark(\n            tags=[other_tag, merge_tag1]\n        )  # merge and unrelated tag\n        bookmark9 = self.setup_bookmark(\n            tags=[other_tag, target_tag]\n        )  # merge and target tag\n        bookmark10 = self.setup_bookmark(tags=[])  # no tags\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"merge_tag1 merge_tag2\"},\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n\n        self.assertEqual(Bookmark.objects.count(), 10)\n        self.assertEqual(Tag.objects.count(), 2)\n        self.assertEqual(Bookmark.tags.through.objects.count(), 11)\n\n        self.assertCountEqual(list(Tag.objects.all()), [target_tag, other_tag])\n\n        self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark4.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark5.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark6.tags.all()), [target_tag])\n        self.assertCountEqual(list(bookmark7.tags.all()), [other_tag])\n        self.assertCountEqual(list(bookmark8.tags.all()), [other_tag, target_tag])\n        self.assertCountEqual(list(bookmark9.tags.all()), [other_tag, target_tag])\n        self.assertCountEqual(list(bookmark10.tags.all()), [])\n\n    def test_can_only_merge_own_tags(self):\n        other_user = self.setup_user()\n        self.setup_tag(name=\"target_tag\", user=other_user)\n        self.setup_tag(name=\"merge_tag\", user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"merge_tag\"},\n        )\n\n        target_tag_group = self.get_form_group(response, \"target_tag\")\n        self.assertIn(\n            'Tag \"target_tag\" does not exist', self.get_text(target_tag_group)\n        )\n\n        merge_tags_group = self.get_form_group(response, \"merge_tags\")\n        self.assertIn('Tag \"merge_tag\" does not exist', self.get_text(merge_tags_group))\n\n    def test_validate_missing_target_tag(self):\n        merge_tag = self.setup_tag(name=\"merge_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"\", \"merge_tags\": \"merge_tag\"},\n        )\n\n        target_tag_group = self.get_form_group(response, \"target_tag\")\n        self.assertIn(\"This field is required\", self.get_text(target_tag_group))\n        self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())\n\n        autocomplete = self.get_autocomplete(response, \"target_tag\")\n        self.assertIn(\"is-error\", autocomplete.get(\"input-class\", \"\"))\n\n    def test_validate_missing_merge_tags(self):\n        self.setup_tag(name=\"target_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"\"},\n        )\n\n        merge_tags_group = self.get_form_group(response, \"merge_tags\")\n        self.assertIn(\"This field is required\", self.get_text(merge_tags_group))\n\n        autocomplete = self.get_autocomplete(response, \"merge_tags\")\n        self.assertIn(\"is-error\", autocomplete.get(\"input-class\", \"\"))\n\n    def test_validate_nonexistent_target_tag(self):\n        self.setup_tag(name=\"merge_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"nonexistent_tag\", \"merge_tags\": \"merge_tag\"},\n        )\n\n        target_tag_group = self.get_form_group(response, \"target_tag\")\n        self.assertIn(\n            'Tag \"nonexistent_tag\" does not exist', self.get_text(target_tag_group)\n        )\n\n    def test_validate_nonexistent_merge_tag(self):\n        self.setup_tag(name=\"target_tag\")\n        self.setup_tag(name=\"merge_tag1\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"merge_tag1 nonexistent_tag\"},\n        )\n\n        merge_tags_group = self.get_form_group(response, \"merge_tags\")\n        self.assertIn(\n            'Tag \"nonexistent_tag\" does not exist', self.get_text(merge_tags_group)\n        )\n\n    def test_validate_multiple_target_tags(self):\n        self.setup_tag(name=\"target_tag1\")\n        self.setup_tag(name=\"target_tag2\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag1 target_tag2\", \"merge_tags\": \"some_tag\"},\n        )\n\n        target_tag_group = self.get_form_group(response, \"target_tag\")\n        self.assertIn(\n            \"Please enter only one tag name for the target tag\",\n            self.get_text(target_tag_group),\n        )\n\n    def test_validate_target_tag_in_merge_list(self):\n        self.setup_tag(name=\"target_tag\")\n        self.setup_tag(name=\"merge_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"target_tag merge_tag\"},\n        )\n\n        merge_tags_group = self.get_form_group(response, \"merge_tags\")\n        self.assertIn(\n            \"The target tag cannot be selected for merging\",\n            self.get_text(merge_tags_group),\n        )\n\n    def test_merge_shows_success_message(self):\n        self.setup_tag(name=\"target_tag\")\n        self.setup_tag(name=\"merge_tag1\")\n        self.setup_tag(name=\"merge_tag2\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.merge\"),\n            {\"target_tag\": \"target_tag\", \"merge_tags\": \"merge_tag1 merge_tag2\"},\n            follow=True,\n        )\n\n        self.assertInHTML(\n            \"\"\"\n                <div class=\"toast toast-success\" role=\"alert\">\n                    Successfully merged 2 tags (merge_tag1, merge_tag2) into \"target_tag\".\n                </div>\n            \"\"\",\n            response.content.decode(),\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_tags_model.py",
    "content": "from django.test import TestCase\n\nfrom bookmarks.models import parse_tag_string\n\n\nclass TagTestCase(TestCase):\n    def test_parse_tag_string_returns_list_of_tag_names(self):\n        self.assertCountEqual(\n            parse_tag_string(\"book, movie, album\"), [\"book\", \"movie\", \"album\"]\n        )\n\n    def test_parse_tag_string_respects_separator(self):\n        self.assertCountEqual(\n            parse_tag_string(\"book movie album\", \" \"), [\"book\", \"movie\", \"album\"]\n        )\n\n    def test_parse_tag_string_orders_tag_names_alphabetically(self):\n        self.assertListEqual(\n            parse_tag_string(\"book,movie,album\"), [\"album\", \"book\", \"movie\"]\n        )\n        self.assertListEqual(\n            parse_tag_string(\"Book,movie,album\"), [\"album\", \"Book\", \"movie\"]\n        )\n\n    def test_parse_tag_string_handles_whitespace(self):\n        self.assertCountEqual(\n            parse_tag_string(\"\\t  book, movie \\t, album, \\n\\r\"),\n            [\"album\", \"book\", \"movie\"],\n        )\n\n    def test_parse_tag_string_handles_invalid_input(self):\n        self.assertListEqual(parse_tag_string(None), [])\n        self.assertListEqual(parse_tag_string(\"\"), [])\n\n    def test_parse_tag_string_deduplicates_tag_names(self):\n        self.assertEqual(len(parse_tag_string(\"book,book,Book,BOOK\")), 1)\n\n    def test_parse_tag_string_handles_duplicate_separators(self):\n        self.assertCountEqual(\n            parse_tag_string(\"book,,movie,,,album\"), [\"album\", \"book\", \"movie\"]\n        )\n\n    def test_parse_tag_string_handles_duplicate_separators_with_spaces(self):\n        self.assertCountEqual(\n            parse_tag_string(\"book, ,movie, , ,album\"), [\"album\", \"book\", \"movie\"]\n        )\n\n    def test_parse_tag_string_replaces_whitespace_within_names(self):\n        self.assertCountEqual(\n            parse_tag_string(\"travel guide, book recommendations\"),\n            [\"travel-guide\", \"book-recommendations\"],\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_tags_new_view.py",
    "content": "from django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Tag\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.user = self.get_or_create_test_user()\n        self.client.force_login(self.user)\n\n    def test_create_tag(self):\n        response = self.client.post(reverse(\"linkding:tags.new\"), {\"name\": \"new_tag\"})\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n        self.assertEqual(Tag.objects.count(), 1)\n        self.assertTrue(Tag.objects.filter(name=\"new_tag\", owner=self.user).exists())\n\n    def test_show_error_for_empty_name(self):\n        response = self.client.post(reverse(\"linkding:tags.new\"), {\"name\": \"\"})\n\n        self.assertContains(response, \"This field is required\")\n        self.assertEqual(Tag.objects.count(), 0)\n\n    def test_show_error_for_duplicate_name(self):\n        self.setup_tag(name=\"existing_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.new\"), {\"name\": \"existing_tag\"}\n        )\n\n        self.assertContains(response, \"Tag &quot;existing_tag&quot; already exists\")\n        self.assertEqual(Tag.objects.count(), 1)\n\n    def test_show_error_for_duplicate_name_different_casing(self):\n        self.setup_tag(name=\"existing_tag\")\n\n        response = self.client.post(\n            reverse(\"linkding:tags.new\"), {\"name\": \"existing_TAG\"}\n        )\n\n        self.assertContains(response, \"Tag &quot;existing_TAG&quot; already exists\")\n        self.assertEqual(Tag.objects.count(), 1)\n\n    def test_no_error_for_duplicate_name_different_user(self):\n        other_user = self.setup_user()\n        self.setup_tag(name=\"existing_tag\", user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:tags.new\"), {\"name\": \"existing_tag\"}\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:tags.index\"))\n        self.assertEqual(Tag.objects.count(), 2)\n        self.assertEqual(\n            Tag.objects.filter(name=\"existing_tag\", owner=self.user).count(), 1\n        )\n        self.assertEqual(\n            Tag.objects.filter(name=\"existing_tag\", owner=other_user).count(), 1\n        )\n\n    def test_create_shows_success_message(self):\n        response = self.client.post(\n            reverse(\"linkding:tags.new\"), {\"name\": \"new_tag\"}, follow=True\n        )\n\n        self.assertInHTML(\n            \"\"\"\n            <div class=\"toast toast-success\" role=\"alert\">\n                Tag \"new_tag\" created successfully.\n            </div>\n        \"\"\",\n            response.content.decode(),\n        )\n"
  },
  {
    "path": "bookmarks/tests/test_tags_service.py",
    "content": "import datetime\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom bookmarks.models import Tag\nfrom bookmarks.services.tags import get_or_create_tag, get_or_create_tags\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\n\nclass TagServiceTestCase(TestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.get_or_create_test_user()\n\n    def test_get_or_create_tag_should_create_new_tag(self):\n        get_or_create_tag(\"Book\", self.user)\n\n        tags = Tag.objects.all()\n\n        self.assertEqual(len(tags), 1)\n        self.assertEqual(tags[0].name, \"Book\")\n        self.assertEqual(tags[0].owner, self.user)\n        self.assertTrue(\n            abs(tags[0].date_added - timezone.now()) < datetime.timedelta(seconds=10)\n        )\n\n    def test_get_or_create_tag_should_return_existing_tag(self):\n        first_tag = get_or_create_tag(\"Book\", self.user)\n        second_tag = get_or_create_tag(\"Book\", self.user)\n\n        tags = Tag.objects.all()\n\n        self.assertEqual(len(tags), 1)\n        self.assertEqual(first_tag.id, second_tag.id)\n\n    def test_get_or_create_tag_should_ignore_casing_when_looking_for_existing_tag(self):\n        first_tag = get_or_create_tag(\"Book\", self.user)\n        second_tag = get_or_create_tag(\"book\", self.user)\n\n        tags = Tag.objects.all()\n\n        self.assertEqual(len(tags), 1)\n        self.assertEqual(first_tag.id, second_tag.id)\n\n    def test_get_or_create_tag_should_handle_legacy_dbs_with_existing_duplicates(self):\n        first_tag = Tag.objects.create(\n            name=\"book\", date_added=timezone.now(), owner=self.user\n        )\n        Tag.objects.create(name=\"Book\", date_added=timezone.now(), owner=self.user)\n        retrieved_tag = get_or_create_tag(\"Book\", self.user)\n\n        self.assertEqual(first_tag.id, retrieved_tag.id)\n\n    def test_get_or_create_tags_should_return_tags(self):\n        books_tag = get_or_create_tag(\"Book\", self.user)\n        movies_tag = get_or_create_tag(\"Movie\", self.user)\n\n        tags = get_or_create_tags([\"book\", \"movie\"], self.user)\n\n        self.assertEqual(len(tags), 2)\n        self.assertListEqual(tags, [books_tag, movies_tag])\n\n    def test_get_or_create_tags_should_deduplicate_tags(self):\n        books_tag = get_or_create_tag(\"Book\", self.user)\n\n        tags = get_or_create_tags([\"book\", \"Book\", \"BOOK\"], self.user)\n\n        self.assertEqual(len(tags), 1)\n        self.assertListEqual(tags, [books_tag])\n"
  },
  {
    "path": "bookmarks/tests/test_toasts_view.py",
    "content": "from django.contrib.auth.models import User\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom bookmarks.models import Toast\nfrom bookmarks.tests.helpers import (\n    BookmarkFactoryMixin,\n    HtmlTestMixin,\n    disable_logging,\n    random_sentence,\n)\n\n\nclass ToastsViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):\n    def setUp(self) -> None:\n        user = self.get_or_create_test_user()\n        self.client.force_login(user)\n\n    def create_toast(\n        self, user: User = None, message: str = None, acknowledged: bool = False\n    ):\n        if not user:\n            user = self.user\n        if not message:\n            message = random_sentence()\n\n        toast = Toast(\n            owner=user, key=\"test\", message=message, acknowledged=acknowledged\n        )\n        toast.save()\n        return toast\n\n    def test_should_render_unacknowledged_toasts(self):\n        self.create_toast()\n        self.create_toast()\n        self.create_toast(acknowledged=True)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        # Should render toasts container\n        self.assertContains(response, '<div class=\"message-list\">')\n        # Should render two toasts\n        self.assertContains(response, '<div class=\"toast d-flex\">', count=2)\n\n    def test_should_not_render_acknowledged_toasts(self):\n        self.create_toast(acknowledged=True)\n        self.create_toast(acknowledged=True)\n        self.create_toast(acknowledged=True)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        # Should not render toasts container\n        self.assertContains(response, '<div class=\"message-list\">', count=0)\n        # Should not render toasts\n        self.assertContains(response, '<div class=\"toast\">', count=0)\n\n    def test_should_not_render_toasts_of_other_users(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n\n        self.create_toast(user=other_user)\n        self.create_toast(user=other_user)\n        self.create_toast(user=other_user)\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n\n        # Should not render toasts container\n        self.assertContains(response, '<div class=\"message-list\">', count=0)\n        # Should not render toasts\n        self.assertContains(response, '<div class=\"toast\">', count=0)\n\n    def test_form_tag(self):\n        self.create_toast()\n        expected_action = f\"{reverse('linkding:toasts.acknowledge')}?return_url={reverse('linkding:bookmarks.index')}\"\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        soup = self.make_soup(response.content.decode())\n        form = soup.find(\"form\", attrs={\"action\": expected_action, \"method\": \"post\"})\n\n        self.assertIsNotNone(form)\n\n    def test_toast_content(self):\n        toast = self.create_toast()\n        expected_toast = f\"\"\"\n            <div class=\"toast d-flex\">\n                {toast.message}\n                <button type=\"submit\" name=\"toast\" value=\"{toast.id}\" class=\"btn btn-clear\"></button>\n            </div>        \n        \"\"\"\n\n        response = self.client.get(reverse(\"linkding:bookmarks.index\"))\n        html = response.content.decode()\n\n        self.assertInHTML(expected_toast, html)\n\n    def test_acknowledge_toast(self):\n        toast = self.create_toast()\n\n        self.client.post(\n            reverse(\"linkding:toasts.acknowledge\"),\n            {\n                \"toast\": [toast.id],\n            },\n        )\n\n        toast.refresh_from_db()\n        self.assertTrue(toast.acknowledged)\n\n    def test_acknowledge_toast_should_redirect_to_return_url(self):\n        toast = self.create_toast()\n        return_url = reverse(\"linkding:settings.general\")\n        acknowledge_url = reverse(\"linkding:toasts.acknowledge\")\n        acknowledge_url = acknowledge_url + \"?return_url=\" + return_url\n\n        response = self.client.post(\n            acknowledge_url,\n            {\n                \"toast\": [toast.id],\n            },\n        )\n\n        self.assertRedirects(response, return_url)\n\n    def test_acknowledge_toast_should_redirect_to_index_by_default(self):\n        toast = self.create_toast()\n\n        response = self.client.post(\n            reverse(\"linkding:toasts.acknowledge\"),\n            {\n                \"toast\": [toast.id],\n            },\n        )\n\n        self.assertRedirects(response, reverse(\"linkding:bookmarks.index\"))\n\n    @disable_logging\n    def test_acknowledge_toast_should_not_acknowledge_other_users_toast(self):\n        other_user = User.objects.create_user(\n            \"otheruser\", \"otheruser@example.com\", \"password123\"\n        )\n        toast = self.create_toast(user=other_user)\n\n        response = self.client.post(\n            reverse(\"linkding:toasts.acknowledge\"),\n            {\n                \"toast\": [toast.id],\n            },\n        )\n        self.assertEqual(response.status_code, 404)\n"
  },
  {
    "path": "bookmarks/tests/test_user_profile_model.py",
    "content": "from django.contrib.auth.models import User\nfrom django.test import TestCase\n\nfrom bookmarks.models import UserProfile\n\n\nclass UserProfileTestCase(TestCase):\n    def test_create_user_should_init_profile(self):\n        user = User.objects.create_user(\"testuser\", \"test@example.com\", \"password123\")\n        profile = UserProfile.objects.all().filter(user_id=user.id).first()\n        self.assertIsNotNone(profile)\n\n    def test_bookmark_sharing_is_disabled_by_default(self):\n        user = User.objects.create_user(\"testuser\", \"test@example.com\", \"password123\")\n        profile = UserProfile.objects.all().filter(user_id=user.id).first()\n        self.assertFalse(profile.enable_sharing)\n"
  },
  {
    "path": "bookmarks/tests/test_user_select_tag.py",
    "content": "from django.template import RequestContext, Template\nfrom django.test import RequestFactory, TestCase\n\nfrom bookmarks.models import BookmarkSearch, User\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\nfrom bookmarks.views import contexts\n\n\nclass UserSelectTagTest(TestCase, BookmarkFactoryMixin):\n    def render_template(self, url: str):\n        rf = RequestFactory()\n        request = rf.get(url)\n        request.user = self.get_or_create_test_user()\n        request.user_profile = self.get_or_create_test_user().profile\n        search = BookmarkSearch.from_request(request, request.GET)\n        user_list = contexts.UserListContext(request, search)\n        context = RequestContext(\n            request,\n            {\n                \"request\": request,\n                \"user_list\": user_list,\n            },\n        )\n        template_to_render = Template(\"{% include 'bookmarks/user_section.html' %}\")\n        return template_to_render.render(context)\n\n    def assertUserOption(self, html: str, user: User, selected: bool = False):\n        self.assertInHTML(\n            f\"\"\"\n          <option value=\"{user.username}\" {\"selected\" if selected else \"\"}>\n            {user.username}\n          </option>        \n        \"\"\",\n            html,\n        )\n\n    def assertHiddenInput(self, html: str, name: str, value: str = None):\n        needle = f'<input type=\"hidden\" name=\"{name}\"'\n        if value is not None:\n            needle += f' value=\"{value}\"'\n\n        self.assertIn(needle, html)\n\n    def assertNoHiddenInput(self, html: str, name: str):\n        needle = f'<input type=\"hidden\" name=\"{name}\"'\n\n        self.assertNotIn(needle, html)\n\n    def test_empty_option(self):\n        user1 = self.setup_user(name=\"user1\", enable_sharing=True)\n        self.setup_bookmark(user=user1, shared=True)\n\n        rendered_template = self.render_template(\"/test\")\n\n        self.assertInHTML(\n            \"\"\"\n          <option value=\"\" selected=\"\">Everyone</option>\n        \"\"\",\n            rendered_template,\n        )\n\n    def test_render_user_options(self):\n        user1 = self.setup_user(name=\"user1\", enable_sharing=True)\n        user2 = self.setup_user(name=\"user2\", enable_sharing=True)\n        user3 = self.setup_user(name=\"user3\", enable_sharing=True)\n\n        self.setup_bookmark(user=user1, shared=True)\n        self.setup_bookmark(user=user2, shared=True)\n        self.setup_bookmark(user=user3, shared=True)\n\n        rendered_template = self.render_template(\"/test\")\n\n        self.assertUserOption(rendered_template, user1)\n        self.assertUserOption(rendered_template, user2)\n        self.assertUserOption(rendered_template, user3)\n\n    def test_preselect_user_option(self):\n        user1 = self.setup_user(name=\"user1\", enable_sharing=True)\n        user2 = self.setup_user(name=\"user2\", enable_sharing=True)\n        user3 = self.setup_user(name=\"user3\", enable_sharing=True)\n\n        self.setup_bookmark(user=user1, shared=True)\n        self.setup_bookmark(user=user2, shared=True)\n        self.setup_bookmark(user=user3, shared=True)\n\n        rendered_template = self.render_template(\"/test?user=user1\")\n\n        self.assertUserOption(rendered_template, user1, True)\n\n    def test_hidden_inputs(self):\n        # Without params\n        url = \"/test\"\n        rendered_template = self.render_template(url)\n\n        self.assertNoHiddenInput(rendered_template, \"user\")\n        self.assertNoHiddenInput(rendered_template, \"q\")\n        self.assertNoHiddenInput(rendered_template, \"sort\")\n        self.assertNoHiddenInput(rendered_template, \"shared\")\n        self.assertNoHiddenInput(rendered_template, \"unread\")\n\n        # With params\n        url = \"/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes\"\n        rendered_template = self.render_template(url)\n\n        self.assertNoHiddenInput(rendered_template, \"user\")\n        self.assertHiddenInput(rendered_template, \"q\", \"foo\")\n        self.assertHiddenInput(rendered_template, \"sort\", \"title_asc\")\n        self.assertHiddenInput(rendered_template, \"shared\", \"yes\")\n        self.assertHiddenInput(rendered_template, \"unread\", \"yes\")\n"
  },
  {
    "path": "bookmarks/tests/test_utils.py",
    "content": "from unittest.mock import patch\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom bookmarks.utils import (\n    humanize_absolute_date,\n    humanize_relative_date,\n    normalize_url,\n    parse_timestamp,\n)\n\n\nclass UtilsTestCase(TestCase):\n    def test_humanize_absolute_date(self):\n        test_cases = [\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2023, 1, 1),\n                \"01/01/2021\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 2, 1),\n                \"01/01/2021\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 8),\n                \"01/01/2021\",\n            ),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), \"Friday\"),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 7, 23, 59),\n                \"Friday\",\n            ),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), \"Friday\"),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), \"Yesterday\"),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 2, 23, 59),\n                \"Yesterday\",\n            ),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), \"Today\"),\n        ]\n\n        for test_case in test_cases:\n            result = humanize_absolute_date(test_case[0], test_case[1])\n            self.assertEqual(test_case[2], result)\n\n    def test_humanize_absolute_date_should_use_current_date_as_default(self):\n        with patch.object(timezone, \"now\", return_value=timezone.datetime(2021, 1, 1)):\n            self.assertEqual(\n                humanize_absolute_date(timezone.datetime(2021, 1, 1)), \"Today\"\n            )\n\n        # Regression: Test that subsequent calls use current date instead of cached date (#107)\n        with patch.object(timezone, \"now\", return_value=timezone.datetime(2021, 1, 13)):\n            self.assertEqual(\n                humanize_absolute_date(timezone.datetime(2021, 1, 13)), \"Today\"\n            )\n\n    def test_humanize_relative_date(self):\n        test_cases = [\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2022, 1, 1),\n                \"1 year ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2022, 12, 31),\n                \"1 year ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2023, 1, 1),\n                \"2 years ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2023, 12, 31),\n                \"2 years ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 12, 31),\n                \"11 months ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 2, 1),\n                \"1 month ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 31),\n                \"4 weeks ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 14),\n                \"1 week ago\",\n            ),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 8),\n                \"1 week ago\",\n            ),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), \"Friday\"),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 7, 23, 59),\n                \"Friday\",\n            ),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), \"Friday\"),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), \"Yesterday\"),\n            (\n                timezone.datetime(2021, 1, 1),\n                timezone.datetime(2021, 1, 2, 23, 59),\n                \"Yesterday\",\n            ),\n            (timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), \"Today\"),\n        ]\n\n        for test_case in test_cases:\n            result = humanize_relative_date(test_case[0], test_case[1])\n            self.assertEqual(test_case[2], result)\n\n    def test_humanize_relative_date_should_use_current_date_as_default(self):\n        with patch.object(timezone, \"now\", return_value=timezone.datetime(2021, 1, 1)):\n            self.assertEqual(\n                humanize_relative_date(timezone.datetime(2021, 1, 1)), \"Today\"\n            )\n\n        # Regression: Test that subsequent calls use current date instead of cached date (#107)\n        with patch.object(timezone, \"now\", return_value=timezone.datetime(2021, 1, 13)):\n            self.assertEqual(\n                humanize_relative_date(timezone.datetime(2021, 1, 13)), \"Today\"\n            )\n\n    def verify_timestamp(self, date, factor=1):\n        timestamp_string = str(int(date.timestamp() * factor))\n        parsed_date = parse_timestamp(timestamp_string)\n        self.assertEqual(date, parsed_date)\n\n    def test_parse_timestamp_fails_for_invalid_timestamps(self):\n        with self.assertRaises(ValueError):\n            parse_timestamp(\"invalid\")\n\n    def test_parse_timestamp_parses_millisecond_timestamps(self):\n        now = timezone.now().replace(microsecond=0)\n        self.verify_timestamp(now)\n\n    def test_parse_timestamp_parses_microsecond_timestamps(self):\n        now = timezone.now().replace(microsecond=0)\n        self.verify_timestamp(now, 1000)\n\n    def test_parse_timestamp_parses_nanosecond_timestamps(self):\n        now = timezone.now().replace(microsecond=0)\n        self.verify_timestamp(now, 1000000)\n\n    def test_parse_timestamp_fails_for_out_of_range_timestamp(self):\n        now = timezone.now().replace(microsecond=0)\n\n        with self.assertRaises(ValueError):\n            self.verify_timestamp(now, 1000000000)\n\n    def test_normalize_url_trailing_slash_handling(self):\n        test_cases = [\n            (\"https://example.com/\", \"https://example.com\"),\n            (\n                \"https://example.com/path/\",\n                \"https://example.com/path\",\n            ),\n            (\"https://example.com/path/to/page/\", \"https://example.com/path/to/page\"),\n            (\n                \"https://example.com/path\",\n                \"https://example.com/path\",\n            ),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_query_parameters(self):\n        test_cases = [\n            (\"https://example.com?z=1&a=2\", \"https://example.com?a=2&z=1\"),\n            (\"https://example.com?c=3&b=2&a=1\", \"https://example.com?a=1&b=2&c=3\"),\n            (\"https://example.com?param=value\", \"https://example.com?param=value\"),\n            (\"https://example.com?\", \"https://example.com\"),\n            (\n                \"https://example.com?empty=&filled=value\",\n                \"https://example.com?empty=&filled=value\",\n            ),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_case_sensitivity(self):\n        test_cases = [\n            (\n                \"https://EXAMPLE.com/Path/To/Page\",\n                \"https://example.com/Path/To/Page\",\n            ),\n            (\"https://EXAMPLE.COM/API/v1/Users\", \"https://example.com/API/v1/Users\"),\n            (\n                \"HTTPS://EXAMPLE.COM/path\",\n                \"https://example.com/path\",\n            ),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_special_characters_and_encoding(self):\n        test_cases = [\n            (\n                \"https://example.com/path%20with%20spaces\",\n                \"https://example.com/path%20with%20spaces\",\n            ),\n            (\"https://example.com/caf%C3%A9\", \"https://example.com/caf%C3%A9\"),\n            (\n                \"https://example.com/path?q=hello%20world\",\n                \"https://example.com/path?q=hello%20world\",\n            ),\n            (\"https://example.com/pàth\", \"https://example.com/pàth\"),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_various_protocols(self):\n        test_cases = [\n            (\"FTP://example.com\", \"ftp://example.com\"),\n            (\"HTTP://EXAMPLE.COM\", \"http://example.com\"),\n            (\"https://example.com\", \"https://example.com\"),\n            (\"file:///path/to/file\", \"file:///path/to/file\"),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_port_handling(self):\n        test_cases = [\n            (\"https://example.com:8080\", \"https://example.com:8080\"),\n            (\"https://EXAMPLE.COM:8080\", \"https://example.com:8080\"),\n            (\"http://example.com:80\", \"http://example.com:80\"),\n            (\"https://example.com:443\", \"https://example.com:443\"),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_authentication_handling(self):\n        test_cases = [\n            (\"https://user:pass@EXAMPLE.COM\", \"https://user:pass@example.com\"),\n            (\"https://user@EXAMPLE.COM\", \"https://user@example.com\"),\n            (\"ftp://admin:secret@EXAMPLE.COM\", \"ftp://admin:secret@example.com\"),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_fragment_handling(self):\n        test_cases = [\n            (\"https://example.com#\", \"https://example.com\"),\n            (\"https://example.com#section\", \"https://example.com#section\"),\n            (\"https://EXAMPLE.COM/path#Section\", \"https://example.com/path#Section\"),\n            (\"https://EXAMPLE.COM/path/#Section\", \"https://example.com/path#Section\"),\n            (\"https://example.com?a=1#fragment\", \"https://example.com?a=1#fragment\"),\n            (\n                \"https://example.com?z=2&a=1#fragment\",\n                \"https://example.com?a=1&z=2#fragment\",\n            ),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_edge_cases(self):\n        test_cases = [\n            (\"\", \"\"),\n            (\"   \", \"\"),\n            (\"   https://example.com   \", \"https://example.com\"),\n            (\"not-a-url\", \"not-a-url\"),\n            (\"://invalid\", \"://invalid\"),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n\n    def test_normalize_url_internationalized_domain_names(self):\n        test_cases = [\n            (\n                \"https://xn--fsq.xn--0zwm56d\",\n                \"https://xn--fsq.xn--0zwm56d\",\n            ),\n            (\"https://测试.中国\", \"https://测试.中国\"),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected.lower() if expected else expected, result)\n\n    def test_normalize_url_complex_query_parameters(self):\n        test_cases = [\n            (\n                \"https://example.com?z=1&a=2&z=3&b=4\",\n                \"https://example.com?a=2&b=4&z=1&z=3\",  # Multiple values for same key\n            ),\n            (\n                \"https://example.com?param=value1&param=value2\",\n                \"https://example.com?param=value1&param=value2\",\n            ),\n            (\n                \"https://example.com?special=%21%40%23%24%25\",\n                \"https://example.com?special=%21%40%23%24%25\",\n            ),\n        ]\n\n        for original, expected in test_cases:\n            with self.subTest(url=original):\n                result = normalize_url(original)\n                self.assertEqual(expected, result)\n"
  },
  {
    "path": "bookmarks/tests/test_website_loader.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\n\nfrom bookmarks.services import website_loader\n\n\nclass MockStreamingResponse:\n    def __init__(self, num_chunks, chunk_size, insert_head_after_chunk=None):\n        self.chunks = []\n        for index in range(num_chunks):\n            chunk = \"\".zfill(chunk_size)\n            self.chunks.append(chunk.encode(\"utf-8\"))\n\n            if index == insert_head_after_chunk:\n                self.chunks.append(b\"</head>\")\n\n    def iter_content(self, **kwargs):\n        return self.chunks\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n\nclass WebsiteLoaderTestCase(TestCase):\n    def setUp(self):\n        # clear cached metadata before test run\n        website_loader._load_website_metadata_cached.cache_clear()\n\n    def render_html_document(\n        self, title, description=\"\", og_description=\"\", og_image=\"\"\n    ):\n        meta_description = (\n            f'<meta name=\"description\" content=\"{description}\">' if description else \"\"\n        )\n        meta_og_description = (\n            f'<meta property=\"og:description\" content=\"{og_description}\">'\n            if og_description\n            else \"\"\n        )\n        meta_og_image = (\n            f'<meta property=\"og:image\" content=\"{og_image}\">' if og_image else \"\"\n        )\n        return f\"\"\"\n        <!DOCTYPE html>\n        <html lang=\"en\">\n        <head>\n            <meta charset=\"UTF-8\">\n            <title>{title}</title>\n            {meta_description}\n            {meta_og_description}\n            {meta_og_image}\n        </head>\n        <body></body>\n        </html>\n        \"\"\"\n\n    def test_load_page_returns_content(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = MockStreamingResponse(\n                num_chunks=10, chunk_size=1024\n            )\n            content = website_loader.load_page(\"https://example.com\")\n\n            expected_content_size = 10 * 1024\n            self.assertEqual(expected_content_size, len(content))\n\n    def test_load_page_limits_large_documents(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = MockStreamingResponse(\n                num_chunks=10, chunk_size=1024 * 1000\n            )\n            content = website_loader.load_page(\"https://example.com\")\n\n            # Should have read six chunks, after which content exceeds the max of 5MB\n            expected_content_size = 6 * 1024 * 1000\n            self.assertEqual(expected_content_size, len(content))\n\n    def test_load_page_stops_reading_at_end_of_head(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_get.return_value = MockStreamingResponse(\n                num_chunks=10, chunk_size=1024 * 1000, insert_head_after_chunk=0\n            )\n            content = website_loader.load_page(\"https://example.com\")\n\n            # Should have read first chunk, and second chunk containing closing head tag\n            expected_content_size = 1 * 1024 * 1000 + len(\"</head>\")\n            self.assertEqual(expected_content_size, len(content))\n\n    def test_load_page_removes_bytes_after_end_of_head(self):\n        with mock.patch(\"requests.get\") as mock_get:\n            mock_response = MockStreamingResponse(num_chunks=1, chunk_size=0)\n            mock_response.chunks[0] = \"<head>人</head>\".encode()\n            # add a single byte that can't be decoded to utf-8\n            mock_response.chunks[0] += 0xFF.to_bytes(1, \"big\")\n            mock_get.return_value = mock_response\n            content = website_loader.load_page(\"https://example.com\")\n\n            # verify that byte after head was removed, content parsed as utf-8\n            self.assertEqual(content, \"<head>人</head>\")\n\n    def test_load_website_metadata(self):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"test title\", \"test description\"\n            )\n            metadata = website_loader.load_website_metadata(\"https://example.com\")\n            self.assertEqual(\"test title\", metadata.title)\n            self.assertEqual(\"test description\", metadata.description)\n            self.assertIsNone(metadata.preview_image)\n\n    def test_load_website_metadata_trims_title_and_description(self):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"  test title  \", \"  test description  \"\n            )\n            metadata = website_loader.load_website_metadata(\"https://example.com\")\n            self.assertEqual(\"test title\", metadata.title)\n            self.assertEqual(\"test description\", metadata.description)\n\n    def test_load_website_metadata_using_og_description(self):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"test title\", \"\", og_description=\"test og description\"\n            )\n            metadata = website_loader.load_website_metadata(\"https://example.com\")\n            self.assertEqual(\"test title\", metadata.title)\n            self.assertEqual(\"test og description\", metadata.description)\n\n    def test_load_website_metadata_using_og_image(self):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"test title\", og_image=\"http://example.com/image.jpg\"\n            )\n            metadata = website_loader.load_website_metadata(\"https://example.com\")\n            self.assertEqual(\"http://example.com/image.jpg\", metadata.preview_image)\n\n    def test_load_website_metadata_gets_absolute_og_image_path_when_path_starts_with_dots(\n        self,\n    ):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"test title\", og_image=\"../image.jpg\"\n            )\n            metadata = website_loader.load_website_metadata(\n                \"https://example.com/a/b/page.html\"\n            )\n            self.assertEqual(\"https://example.com/a/image.jpg\", metadata.preview_image)\n\n    def test_load_website_metadata_gets_absolute_og_image_path_when_path_starts_with_slash(\n        self,\n    ):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"test title\", og_image=\"/image.jpg\"\n            )\n            metadata = website_loader.load_website_metadata(\n                \"https://example.com/a/b/page.html\"\n            )\n            self.assertEqual(\"https://example.com/image.jpg\", metadata.preview_image)\n\n    def test_load_website_metadata_prefers_description_over_og_description(self):\n        with mock.patch(\n            \"bookmarks.services.website_loader.load_page\"\n        ) as mock_load_page:\n            mock_load_page.return_value = self.render_html_document(\n                \"test title\", \"test description\", og_description=\"test og description\"\n            )\n            metadata = website_loader.load_website_metadata(\"https://example.com\")\n            self.assertEqual(\"test title\", metadata.title)\n            self.assertEqual(\"test description\", metadata.description)\n\n    def test_website_metadata_ignore_cache(self):\n        expected_html = '<html><head><title>Test Title</title><meta name=\"description\" content=\"Test Description\"><meta property=\"og:image\" content=\"/images/test.jpg\"></head></html>'\n\n        with mock.patch.object(\n            website_loader, \"load_page\", return_value=expected_html\n        ) as mock_load_page:\n            website_loader.load_website_metadata(\"https://example.com\")\n            mock_load_page.assert_called_once()\n\n            website_loader.load_website_metadata(\"https://example.com\")\n            mock_load_page.assert_called_once()\n\n            website_loader.load_website_metadata(\n                \"https://example.com\", ignore_cache=True\n            )\n            self.assertEqual(mock_load_page.call_count, 2)\n\n\nclass ContentTypeDetectionTestCase(TestCase):\n    def test_detect_content_type_returns_content_type_from_head_request(self):\n        with mock.patch(\"requests.head\") as mock_head:\n            mock_response = mock.Mock()\n            mock_response.status_code = 200\n            mock_response.headers = {\"Content-Type\": \"application/pdf\"}\n            mock_head.return_value = mock_response\n\n            result = website_loader.detect_content_type(\"https://example.com/doc.pdf\")\n\n            self.assertEqual(result, \"application/pdf\")\n            mock_head.assert_called_once()\n\n    def test_detect_content_type_strips_charset(self):\n        with mock.patch(\"requests.head\") as mock_head:\n            mock_response = mock.Mock()\n            mock_response.status_code = 200\n            mock_response.headers = {\"Content-Type\": \"text/html; charset=utf-8\"}\n            mock_head.return_value = mock_response\n\n            result = website_loader.detect_content_type(\"https://example.com\")\n\n            self.assertEqual(result, \"text/html\")\n\n    def test_detect_content_type_returns_lowercase(self):\n        with mock.patch(\"requests.head\") as mock_head:\n            mock_response = mock.Mock()\n            mock_response.status_code = 200\n            mock_response.headers = {\"Content-Type\": \"Application/PDF\"}\n            mock_head.return_value = mock_response\n\n            result = website_loader.detect_content_type(\"https://example.com/doc.pdf\")\n\n            self.assertEqual(result, \"application/pdf\")\n\n    def test_detect_content_type_falls_back_to_get_when_head_fails(self):\n        with (\n            mock.patch(\"requests.head\") as mock_head,\n            mock.patch(\"requests.get\") as mock_get,\n        ):\n            import requests\n\n            mock_head.side_effect = requests.RequestException(\"HEAD failed\")\n\n            mock_response = mock.Mock()\n            mock_response.status_code = 200\n            mock_response.headers = {\"Content-Type\": \"application/pdf\"}\n            mock_response.__enter__ = mock.Mock(return_value=mock_response)\n            mock_response.__exit__ = mock.Mock(return_value=False)\n            mock_get.return_value = mock_response\n\n            result = website_loader.detect_content_type(\"https://example.com/doc.pdf\")\n\n            self.assertEqual(result, \"application/pdf\")\n            mock_head.assert_called_once()\n            mock_get.assert_called_once()\n\n    def test_detect_content_type_returns_none_when_both_head_and_get_fail(self):\n        with (\n            mock.patch(\"requests.head\") as mock_head,\n            mock.patch(\"requests.get\") as mock_get,\n        ):\n            import requests\n\n            mock_head.side_effect = requests.RequestException(\"HEAD failed\")\n            mock_get.side_effect = requests.RequestException(\"GET failed\")\n\n            result = website_loader.detect_content_type(\"https://example.com/doc.pdf\")\n\n            self.assertIsNone(result)\n\n    def test_detect_content_type_returns_none_for_non_200_status(self):\n        with (\n            mock.patch(\"requests.head\") as mock_head,\n            mock.patch(\"requests.get\") as mock_get,\n        ):\n            mock_head_response = mock.Mock()\n            mock_head_response.status_code = 404\n            mock_head.return_value = mock_head_response\n\n            mock_get_response = mock.Mock()\n            mock_get_response.status_code = 404\n            mock_get_response.__enter__ = mock.Mock(return_value=mock_get_response)\n            mock_get_response.__exit__ = mock.Mock(return_value=False)\n            mock_get.return_value = mock_get_response\n\n            result = website_loader.detect_content_type(\"https://example.com/doc.pdf\")\n\n            self.assertIsNone(result)\n\n    def test_is_pdf_content_type(self):\n        self.assertTrue(website_loader.is_pdf_content_type(\"application/pdf\"))\n        self.assertTrue(website_loader.is_pdf_content_type(\"application/x-pdf\"))\n        self.assertFalse(website_loader.is_pdf_content_type(\"text/html\"))\n        self.assertFalse(website_loader.is_pdf_content_type(None))\n        self.assertFalse(website_loader.is_pdf_content_type(\"\"))\n"
  },
  {
    "path": "bookmarks/tests_e2e/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass A11yNavigationFocusTest(LinkdingE2ETestCase):\n    def test_initial_page_load_focus(self):\n        # First page load should keep focus on the body\n        page = self.open(reverse(\"linkding:bookmarks.index\"))\n        focused_tag = page.evaluate(\"document.activeElement?.tagName\")\n        self.assertEqual(\"BODY\", focused_tag)\n\n        page.goto(self.live_server_url + reverse(\"linkding:bookmarks.archived\"))\n        focused_tag = page.evaluate(\"document.activeElement?.tagName\")\n        self.assertEqual(\"BODY\", focused_tag)\n\n        page.goto(self.live_server_url + reverse(\"linkding:settings.general\"))\n        focused_tag = page.evaluate(\"document.activeElement?.tagName\")\n        self.assertEqual(\"BODY\", focused_tag)\n\n        # Bookmark form views should focus the URL input\n        page.goto(self.live_server_url + reverse(\"linkding:bookmarks.new\"))\n        page.wait_for_timeout(timeout=1000)\n        focused_tag = page.evaluate(\n            \"document.activeElement?.tagName + '|' + document.activeElement?.name\"\n        )\n        self.assertEqual(\"INPUT|url\", focused_tag)\n\n    def test_page_navigation_focus(self):\n        bookmark = self.setup_bookmark()\n\n        page = self.open(reverse(\"linkding:bookmarks.index\"))\n\n        # Subsequent navigation should move focus to main content\n        self.reset_focus()\n        self.navigate_menu(\"Bookmarks\", \"Active\")\n        focused = page.locator(\"main:focus\")\n        expect(focused).to_be_visible()\n\n        self.reset_focus()\n        self.navigate_menu(\"Bookmarks\", \"Archived\")\n        focused = page.locator(\"main:focus\")\n        expect(focused).to_be_visible()\n\n        self.reset_focus()\n        self.navigate_menu(\"Settings\", \"General\")\n        focused = page.locator(\"main:focus\")\n        expect(focused).to_be_visible()\n\n        # Bookmark form views should focus the URL input\n        self.reset_focus()\n        self.navigate_menu(\"Add bookmark\")\n        focused = page.locator(\"input[name='url']:focus\")\n        expect(focused).to_be_visible()\n\n        # Opening details modal should move focus to close button\n        self.navigate_menu(\"Bookmarks\", \"Active\")\n        self.open_details_modal(bookmark)\n        focused = page.locator(\".modal button.close:focus\")\n        expect(focused).to_be_visible()\n\n        # Closing modal should move focus back to the bookmark item\n        page.keyboard.press(\"Escape\")\n        focused = self.locate_bookmark(bookmark.title).locator(\"a.view-action:focus\")\n        expect(focused).to_be_visible()\n\n    def reset_focus(self):\n        self.page.evaluate(\"document.activeElement.blur()\")\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py",
    "content": "from django.test import override_settings\nfrom django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.models import Bookmark\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):\n    def test_show_details(self):\n        bookmark = self.setup_bookmark()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        details_modal = self.open_details_modal(bookmark)\n        title = details_modal.locator(\"h2\")\n        expect(title).to_have_text(bookmark.title)\n\n    def test_close_details(self):\n        bookmark = self.setup_bookmark()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        # close with close button\n        details_modal = self.open_details_modal(bookmark)\n        details_modal.locator(\"button.close\").click()\n        expect(details_modal).to_be_hidden()\n\n        # close with backdrop\n        details_modal = self.open_details_modal(bookmark)\n        overlay = details_modal.locator(\".modal-overlay\")\n        overlay.click(position={\"x\": 0, \"y\": 0})\n        expect(details_modal).to_be_hidden()\n\n        # close with escape\n        details_modal = self.open_details_modal(bookmark)\n        self.page.keyboard.press(\"Escape\")\n        expect(details_modal).to_be_hidden()\n\n    def test_toggle_archived(self):\n        bookmark = self.setup_bookmark()\n\n        # archive\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        details_modal = self.open_details_modal(bookmark)\n        details_modal.get_by_text(\"Archived\", exact=False).click()\n        expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()\n        self.assertReloads(0)\n\n        # unarchive\n        url = reverse(\"linkding:bookmarks.archived\")\n        self.page.goto(self.live_server_url + url)\n        self.resetReloads()\n\n        details_modal = self.open_details_modal(bookmark)\n        details_modal.get_by_text(\"Archived\", exact=False).click()\n        expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()\n        self.assertReloads(0)\n\n    def test_toggle_unread(self):\n        bookmark = self.setup_bookmark()\n\n        # mark as unread\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        details_modal = self.open_details_modal(bookmark)\n\n        details_modal.get_by_text(\"Unread\").click()\n        bookmark_item = self.locate_bookmark(bookmark.title)\n        expect(bookmark_item.get_by_text(\"Unread\")).to_be_visible()\n        self.assertReloads(0)\n\n        # mark as read\n        details_modal.get_by_text(\"Unread\").click()\n        bookmark_item = self.locate_bookmark(bookmark.title)\n        expect(bookmark_item.get_by_text(\"Unread\")).not_to_be_visible()\n        self.assertReloads(0)\n\n    def test_toggle_shared(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        bookmark = self.setup_bookmark()\n\n        # share bookmark\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        details_modal = self.open_details_modal(bookmark)\n\n        details_modal.get_by_text(\"Shared\").click()\n        bookmark_item = self.locate_bookmark(bookmark.title)\n        expect(bookmark_item.get_by_text(\"Shared\")).to_be_visible()\n        self.assertReloads(0)\n\n        # unshare bookmark\n        details_modal.get_by_text(\"Shared\").click()\n        bookmark_item = self.locate_bookmark(bookmark.title)\n        expect(bookmark_item.get_by_text(\"Shared\")).not_to_be_visible()\n        self.assertReloads(0)\n\n    def test_edit_return_url(self):\n        bookmark = self.setup_bookmark()\n\n        url = reverse(\"linkding:bookmarks.index\") + f\"?q={bookmark.title}\"\n        self.open(url)\n\n        details_modal = self.open_details_modal(bookmark)\n\n        # Navigate to edit page\n        with self.page.expect_navigation():\n            details_modal.get_by_text(\"Edit\").click()\n\n        # Cancel edit, verify return to details url\n        details_url = url + f\"&details={bookmark.id}\"\n        with self.page.expect_navigation(url=self.live_server_url + details_url):\n            self.page.get_by_text(\"Cancel\").click()\n\n    def test_delete(self):\n        bookmark = self.setup_bookmark()\n\n        url = reverse(\"linkding:bookmarks.index\") + f\"?q={bookmark.title}\"\n        self.open(url)\n\n        details_modal = self.open_details_modal(bookmark)\n\n        # Wait for confirm button to be initialized\n        self.page.wait_for_timeout(1000)\n\n        # Delete bookmark, verify return url\n        with self.page.expect_navigation(url=self.live_server_url + url):\n            details_modal.get_by_text(\"Delete\").click()\n            self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        # verify bookmark is deleted\n        expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()\n\n        self.assertEqual(Bookmark.objects.count(), 0)\n\n    @override_settings(LD_ENABLE_SNAPSHOTS=True)\n    def test_create_snapshot_remove_snapshot(self):\n        bookmark = self.setup_bookmark()\n\n        url = reverse(\"linkding:bookmarks.index\") + f\"?q={bookmark.title}\"\n        self.open(url)\n\n        details_modal = self.open_details_modal(bookmark)\n        asset_list = details_modal.locator(\".assets\")\n\n        # No snapshots initially\n        snapshot = asset_list.get_by_text(\"snapshot\", exact=False)\n        expect(snapshot).not_to_be_visible()\n\n        # Create snapshot\n        details_modal.get_by_text(\"snapshot\", exact=False).click()\n        self.assertReloads(0)\n\n        # Has new snapshots\n        expect(snapshot).to_be_visible()\n\n        # Remove snapshot\n        asset_list.get_by_text(\"Remove\", exact=False).click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\", exact=False).click()\n\n        # Snapshot is removed\n        expect(snapshot).not_to_be_visible()\n        self.assertReloads(0)\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_bookmark_item.py",
    "content": "from unittest import skip\n\nfrom django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass BookmarkItemE2ETestCase(LinkdingE2ETestCase):\n    @skip(\"Fails in CI, needs investigation\")\n    def test_toggle_notes_should_show_hide_notes(self):\n        bookmark = self.setup_bookmark(notes=\"Test notes\")\n\n        page = self.open(reverse(\"linkding:bookmarks.index\"))\n\n        notes = self.locate_bookmark(bookmark.title).locator(\".notes\")\n        expect(notes).to_be_hidden()\n\n        toggle_notes = page.locator(\"li button.toggle-notes\")\n        toggle_notes.click()\n        expect(notes).to_be_visible()\n\n        toggle_notes.click()\n        expect(notes).to_be_hidden()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_bookmark_page_bulk_edit.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.models import Bookmark\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):\n    def setup_test_data(self):\n        self.setup_numbered_bookmarks(50)\n        self.setup_numbered_bookmarks(50, archived=True)\n        self.setup_numbered_bookmarks(50, prefix=\"foo\")\n        self.setup_numbered_bookmarks(50, archived=True, prefix=\"foo\")\n\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=False, title__startswith=\"Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=True, title__startswith=\"Archived Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(is_archived=False, title__startswith=\"foo\").count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(is_archived=True, title__startswith=\"foo\").count(),\n        )\n\n    def test_active_bookmarks_bulk_select_across(self):\n        self.setup_test_data()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        bookmark_list = self.locate_bookmark_list().element_handle()\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n        # Wait until bookmark list is updated (old reference becomes invisible)\n        bookmark_list.wait_for_element_state(\"hidden\", timeout=1000)\n\n        self.assertEqual(\n            0,\n            Bookmark.objects.filter(\n                is_archived=False, title__startswith=\"Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=True, title__startswith=\"Archived Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            0,\n            Bookmark.objects.filter(is_archived=False, title__startswith=\"foo\").count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(is_archived=True, title__startswith=\"foo\").count(),\n        )\n\n    def test_archived_bookmarks_bulk_select_across(self):\n        self.setup_test_data()\n\n        self.open(reverse(\"linkding:bookmarks.archived\"))\n\n        bookmark_list = self.locate_bookmark_list().element_handle()\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n        # Wait until bookmark list is updated (old reference becomes invisible)\n        bookmark_list.wait_for_element_state(\"hidden\", timeout=1000)\n\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=False, title__startswith=\"Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            0,\n            Bookmark.objects.filter(\n                is_archived=True, title__startswith=\"Archived Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(is_archived=False, title__startswith=\"foo\").count(),\n        )\n        self.assertEqual(\n            0,\n            Bookmark.objects.filter(is_archived=True, title__startswith=\"foo\").count(),\n        )\n\n    def test_active_bookmarks_bulk_select_across_respects_query(self):\n        self.setup_test_data()\n\n        self.open(reverse(\"linkding:bookmarks.index\") + \"?q=foo\")\n\n        bookmark_list = self.locate_bookmark_list().element_handle()\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n        # Wait until bookmark list is updated (old reference becomes invisible)\n        bookmark_list.wait_for_element_state(\"hidden\", timeout=1000)\n\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=False, title__startswith=\"Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=True, title__startswith=\"Archived Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            0,\n            Bookmark.objects.filter(is_archived=False, title__startswith=\"foo\").count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(is_archived=True, title__startswith=\"foo\").count(),\n        )\n\n    def test_archived_bookmarks_bulk_select_across_respects_query(self):\n        self.setup_test_data()\n\n        self.open(reverse(\"linkding:bookmarks.archived\") + \"?q=foo\")\n\n        bookmark_list = self.locate_bookmark_list().element_handle()\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n        # Wait until bookmark list is updated (old reference becomes invisible)\n        bookmark_list.wait_for_element_state(\"hidden\", timeout=1000)\n\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=False, title__startswith=\"Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(\n                is_archived=True, title__startswith=\"Archived Bookmark\"\n            ).count(),\n        )\n        self.assertEqual(\n            50,\n            Bookmark.objects.filter(is_archived=False, title__startswith=\"foo\").count(),\n        )\n        self.assertEqual(\n            0,\n            Bookmark.objects.filter(is_archived=True, title__startswith=\"foo\").count(),\n        )\n\n    def test_select_all_toggles_all_checkboxes(self):\n        self.setup_numbered_bookmarks(5)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        page = self.open(url)\n\n        self.locate_bulk_edit_toggle().click()\n\n        checkboxes = page.locator(\"label.bulk-edit-checkbox input\")\n        self.assertEqual(6, checkboxes.count())\n        for i in range(checkboxes.count()):\n            expect(checkboxes.nth(i)).not_to_be_checked()\n\n        self.locate_bulk_edit_select_all().click()\n\n        for i in range(checkboxes.count()):\n            expect(checkboxes.nth(i)).to_be_checked()\n\n        self.locate_bulk_edit_select_all().click()\n\n        for i in range(checkboxes.count()):\n            expect(checkboxes.nth(i)).not_to_be_checked()\n\n    def test_select_all_shows_select_across(self):\n        self.setup_numbered_bookmarks(5)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        self.locate_bulk_edit_toggle().click()\n\n        expect(self.locate_bulk_edit_select_across()).not_to_be_visible()\n\n        self.locate_bulk_edit_select_all().click()\n        expect(self.locate_bulk_edit_select_across()).to_be_visible()\n\n        self.locate_bulk_edit_select_all().click()\n        expect(self.locate_bulk_edit_select_across()).not_to_be_visible()\n\n    def test_select_across_is_unchecked_when_toggling_all(self):\n        self.setup_numbered_bookmarks(5)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        self.locate_bulk_edit_toggle().click()\n\n        # Show select across, check it\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n        expect(self.locate_bulk_edit_select_across()).to_be_checked()\n\n        # Hide select across by toggling select all\n        self.locate_bulk_edit_select_all().click()\n        expect(self.locate_bulk_edit_select_across()).not_to_be_visible()\n\n        # Show select across again, verify it is unchecked\n        self.locate_bulk_edit_select_all().click()\n        expect(self.locate_bulk_edit_select_across()).not_to_be_checked()\n\n    def test_select_across_is_unchecked_when_toggling_bookmark(self):\n        self.setup_numbered_bookmarks(5)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        self.locate_bulk_edit_toggle().click()\n\n        # Show select across, check it\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n        expect(self.locate_bulk_edit_select_across()).to_be_checked()\n\n        # Hide select across by toggling a single bookmark\n        self.locate_bookmark(\"Bookmark 1\").locator(\"label.bulk-edit-checkbox\").click()\n        expect(self.locate_bulk_edit_select_across()).not_to_be_visible()\n\n        # Show select across again, verify it is unchecked\n        self.locate_bookmark(\"Bookmark 1\").locator(\"label.bulk-edit-checkbox\").click()\n        expect(self.locate_bulk_edit_select_across()).not_to_be_checked()\n\n    def test_execute_resets_all_checkboxes(self):\n        self.setup_numbered_bookmarks(100)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        page = self.open(url)\n\n        bookmark_list = self.locate_bookmark_list().element_handle()\n\n        # Select all bookmarks, enable select across\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bulk_edit_select_all().click()\n        self.locate_bulk_edit_select_across().click()\n\n        # Execute bulk action\n        self.select_bulk_action(\"Mark as unread\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        # Wait until bookmark list is updated (old reference becomes invisible)\n        bookmark_list.wait_for_element_state(\"hidden\", timeout=1000)\n\n        # Verify bulk edit checkboxes are reset\n        checkboxes = page.locator(\"label.bulk-edit-checkbox input\")\n        self.assertEqual(31, checkboxes.count())\n        for i in range(checkboxes.count()):\n            expect(checkboxes.nth(i)).not_to_be_checked()\n\n        # Toggle select all and verify select across is reset\n        self.locate_bulk_edit_select_all().click()\n        expect(self.locate_bulk_edit_select_across()).not_to_be_checked()\n\n    def test_update_select_across_bookmark_count(self):\n        self.setup_numbered_bookmarks(100)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        bookmark_list = self.locate_bookmark_list().element_handle()\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bulk_edit_select_all().click()\n\n        expect(\n            self.locate_bulk_edit_bar().get_by_text(\"All 100 bookmarks\")\n        ).to_be_visible()\n\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n        # Wait until bookmark list is updated (old reference becomes invisible)\n        bookmark_list.wait_for_element_state(\"hidden\", timeout=1000)\n\n        expect(self.locate_bulk_edit_select_all()).not_to_be_checked()\n        self.locate_bulk_edit_select_all().click()\n\n        expect(\n            self.locate_bulk_edit_bar().get_by_text(\"All 70 bookmarks\")\n        ).to_be_visible()\n\n    def test_execute_button_is_disabled_when_no_bookmarks_selected(self):\n        self.setup_numbered_bookmarks(5)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        self.locate_bulk_edit_toggle().click()\n\n        execute_button = self.locate_bulk_edit_bar().get_by_text(\"Execute\")\n\n        # Execute button should be disabled by default\n        expect(execute_button).to_be_disabled()\n\n        # Check a single bookmark - execute button should be enabled\n        self.locate_bookmark(\"Bookmark 1\").locator(\"label.bulk-edit-checkbox\").click()\n        expect(execute_button).to_be_enabled()\n\n        # Uncheck the bookmark - execute button should be disabled again\n        self.locate_bookmark(\"Bookmark 1\").locator(\"label.bulk-edit-checkbox\").click()\n        expect(execute_button).to_be_disabled()\n\n        # Check all bookmarks - execute button should be enabled\n        self.locate_bulk_edit_select_all().click()\n        expect(execute_button).to_be_enabled()\n\n        # Uncheck all bookmarks - execute button should be disabled again\n        self.locate_bulk_edit_select_all().click()\n        expect(execute_button).to_be_disabled()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_bookmark_page_partial_updates.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):\n    def setup_fixture(self):\n        profile = self.get_or_create_test_user().profile\n        profile.enable_sharing = True\n        profile.save()\n\n        # create a number of bookmarks with different states / visibility to\n        # verify correct data is loaded on update\n        self.setup_numbered_bookmarks(3, with_tags=True)\n        self.setup_numbered_bookmarks(3, with_tags=True, archived=True)\n        self.setup_numbered_bookmarks(\n            3,\n            shared=True,\n            prefix=\"Joe's Bookmark\",\n            user=self.setup_user(enable_sharing=True),\n        )\n\n    def assertVisibleBookmarks(self, titles: list[str]):\n        bookmark_tags = self.page.locator(\"ul.bookmark-list > li\")\n        expect(bookmark_tags).to_have_count(len(titles))\n\n        for title in titles:\n            matching_tag = bookmark_tags.filter(has_text=title)\n            expect(matching_tag).to_be_visible()\n\n    def assertVisibleTags(self, titles: list[str]):\n        tag_tags = self.page.locator(\".tag-cloud .unselected-tags a\")\n        expect(tag_tags).to_have_count(len(titles))\n\n        for title in titles:\n            matching_tag = tag_tags.filter(has_text=title)\n            expect(matching_tag).to_be_visible()\n\n    def test_partial_update_respects_query(self):\n        self.setup_numbered_bookmarks(5, prefix=\"foo\")\n        self.setup_numbered_bookmarks(5, prefix=\"bar\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?q=foo\"\n        self.open(url)\n\n        self.assertVisibleBookmarks([\"foo 1\", \"foo 2\", \"foo 3\", \"foo 4\", \"foo 5\"])\n\n        self.locate_bookmark(\"foo 2\").get_by_text(\"Archive\").click()\n        self.assertVisibleBookmarks([\"foo 1\", \"foo 3\", \"foo 4\", \"foo 5\"])\n\n    def test_partial_update_respects_sort(self):\n        self.setup_numbered_bookmarks(5, prefix=\"foo\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?sort=title_asc\"\n        page = self.open(url)\n\n        first_item = page.locator(\"ul.bookmark-list > li\").first\n        expect(first_item).to_contain_text(\"foo 1\")\n\n        first_item.get_by_text(\"Archive\").click()\n\n        first_item = page.locator(\"ul.bookmark-list > li\").first\n        expect(first_item).to_contain_text(\"foo 2\")\n\n    def test_partial_update_respects_page(self):\n        # add a suffix, otherwise 'foo 1' also matches 'foo 10'\n        self.setup_numbered_bookmarks(50, prefix=\"foo\", suffix=\"-\")\n\n        url = reverse(\"linkding:bookmarks.index\") + \"?q=foo&page=2\"\n        self.open(url)\n\n        # with descending sort, page two has 'foo 1' to 'foo 20'\n        expected_titles = [f\"foo {i}-\" for i in range(1, 21)]\n        self.assertVisibleBookmarks(expected_titles)\n\n        self.locate_bookmark(\"foo 20-\").get_by_text(\"Archive\").click()\n\n        expected_titles = [f\"foo {i}-\" for i in range(1, 20)]\n        self.assertVisibleBookmarks(expected_titles)\n\n    def test_multiple_partial_updates(self):\n        self.setup_numbered_bookmarks(5)\n\n        url = reverse(\"linkding:bookmarks.index\")\n        self.open(url)\n\n        self.locate_bookmark(\"Bookmark 1\").get_by_text(\"Archive\").click()\n        self.assertVisibleBookmarks(\n            [\"Bookmark 2\", \"Bookmark 3\", \"Bookmark 4\", \"Bookmark 5\"]\n        )\n\n        self.locate_bookmark(\"Bookmark 2\").get_by_text(\"Archive\").click()\n        self.assertVisibleBookmarks([\"Bookmark 3\", \"Bookmark 4\", \"Bookmark 5\"])\n\n        self.locate_bookmark(\"Bookmark 3\").get_by_text(\"Archive\").click()\n        self.assertVisibleBookmarks([\"Bookmark 4\", \"Bookmark 5\"])\n\n        self.assertReloads(0)\n\n    def test_active_bookmarks_partial_update_on_archive(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        self.locate_bookmark(\"Bookmark 2\").get_by_text(\"Archive\").click()\n\n        self.assertVisibleBookmarks([\"Bookmark 1\", \"Bookmark 3\"])\n        self.assertVisibleTags([\"Tag 1\", \"Tag 3\"])\n        self.assertReloads(0)\n\n    def test_active_bookmarks_partial_update_on_delete(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        self.locate_bookmark(\"Bookmark 2\").get_by_text(\"Remove\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks([\"Bookmark 1\", \"Bookmark 3\"])\n        self.assertVisibleTags([\"Tag 1\", \"Tag 3\"])\n        self.assertReloads(0)\n\n    def test_active_bookmarks_partial_update_on_mark_as_read(self):\n        self.setup_fixture()\n        bookmark2 = self.get_numbered_bookmark(\"Bookmark 2\")\n        bookmark2.unread = True\n        bookmark2.save()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        expect(self.locate_bookmark(\"Bookmark 2\")).to_have_class(\"unread\")\n        self.locate_bookmark(\"Bookmark 2\").get_by_text(\"Unread\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        expect(self.locate_bookmark(\"Bookmark 2\")).not_to_have_class(\"unread\")\n        self.assertReloads(0)\n\n    def test_active_bookmarks_partial_update_on_unshare(self):\n        self.setup_fixture()\n        bookmark2 = self.get_numbered_bookmark(\"Bookmark 2\")\n        bookmark2.shared = True\n        bookmark2.save()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        expect(self.locate_bookmark(\"Bookmark 2\")).to_have_class(\"shared\")\n        self.locate_bookmark(\"Bookmark 2\").get_by_text(\"Shared\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        expect(self.locate_bookmark(\"Bookmark 2\")).not_to_have_class(\"shared\")\n        self.assertReloads(0)\n\n    def test_active_bookmarks_partial_update_on_bulk_archive(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bookmark(\"Bookmark 2\").locator(\"label.bulk-edit-checkbox\").click()\n        self.select_bulk_action(\"Archive\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks([\"Bookmark 1\", \"Bookmark 3\"])\n        self.assertVisibleTags([\"Tag 1\", \"Tag 3\"])\n        self.assertReloads(0)\n\n    def test_active_bookmarks_partial_update_on_bulk_delete(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bookmark(\"Bookmark 2\").locator(\"label.bulk-edit-checkbox\").click()\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks([\"Bookmark 1\", \"Bookmark 3\"])\n        self.assertVisibleTags([\"Tag 1\", \"Tag 3\"])\n        self.assertReloads(0)\n\n    def test_archived_bookmarks_partial_update_on_unarchive(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.archived\"))\n\n        self.locate_bookmark(\"Archived Bookmark 2\").get_by_text(\"Unarchive\").click()\n\n        self.assertVisibleBookmarks([\"Archived Bookmark 1\", \"Archived Bookmark 3\"])\n        self.assertVisibleTags([\"Archived Tag 1\", \"Archived Tag 3\"])\n        self.assertReloads(0)\n\n    def test_archived_bookmarks_partial_update_on_delete(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.archived\"))\n\n        self.locate_bookmark(\"Archived Bookmark 2\").get_by_text(\"Remove\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks([\"Archived Bookmark 1\", \"Archived Bookmark 3\"])\n        self.assertVisibleTags([\"Archived Tag 1\", \"Archived Tag 3\"])\n        self.assertReloads(0)\n\n    def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.archived\"))\n\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bookmark(\"Archived Bookmark 2\").locator(\n            \"label.bulk-edit-checkbox\"\n        ).click()\n        self.select_bulk_action(\"Unarchive\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks([\"Archived Bookmark 1\", \"Archived Bookmark 3\"])\n        self.assertVisibleTags([\"Archived Tag 1\", \"Archived Tag 3\"])\n        self.assertReloads(0)\n\n    def test_archived_bookmarks_partial_update_on_bulk_delete(self):\n        self.setup_fixture()\n\n        self.open(reverse(\"linkding:bookmarks.archived\"))\n\n        self.locate_bulk_edit_toggle().click()\n        self.locate_bookmark(\"Archived Bookmark 2\").locator(\n            \"label.bulk-edit-checkbox\"\n        ).click()\n        self.select_bulk_action(\"Delete\")\n        self.locate_bulk_edit_bar().get_by_text(\"Execute\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks([\"Archived Bookmark 1\", \"Archived Bookmark 3\"])\n        self.assertVisibleTags([\"Archived Tag 1\", \"Archived Tag 3\"])\n        self.assertReloads(0)\n\n    def test_shared_bookmarks_partial_update_on_unarchive(self):\n        self.setup_fixture()\n        self.setup_numbered_bookmarks(\n            3, shared=True, prefix=\"My Bookmark\", with_tags=True\n        )\n\n        self.open(reverse(\"linkding:bookmarks.shared\"))\n\n        self.locate_bookmark(\"My Bookmark 2\").get_by_text(\"Archive\").click()\n\n        # Shared bookmarks page also shows archived bookmarks, though it probably shouldn't\n        self.assertVisibleBookmarks(\n            [\n                \"My Bookmark 1\",\n                \"My Bookmark 2\",\n                \"My Bookmark 3\",\n                \"Joe's Bookmark 1\",\n                \"Joe's Bookmark 2\",\n                \"Joe's Bookmark 3\",\n            ]\n        )\n        self.assertVisibleTags([\"Shared Tag 1\", \"Shared Tag 2\", \"Shared Tag 3\"])\n        self.assertReloads(0)\n\n    def test_shared_bookmarks_partial_update_on_delete(self):\n        self.setup_fixture()\n        self.setup_numbered_bookmarks(\n            3, shared=True, prefix=\"My Bookmark\", with_tags=True\n        )\n\n        self.open(reverse(\"linkding:bookmarks.shared\"))\n\n        self.locate_bookmark(\"My Bookmark 2\").get_by_text(\"Remove\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        self.assertVisibleBookmarks(\n            [\n                \"My Bookmark 1\",\n                \"My Bookmark 3\",\n                \"Joe's Bookmark 1\",\n                \"Joe's Bookmark 2\",\n                \"Joe's Bookmark 3\",\n            ]\n        )\n        self.assertVisibleTags([\"Shared Tag 1\", \"Shared Tag 3\"])\n        self.assertReloads(0)\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_bundle_preview.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass BookmarkItemE2ETestCase(LinkdingE2ETestCase):\n    def test_update_preview_on_filter_changes(self):\n        group1 = self.setup_numbered_bookmarks(3, prefix=\"foo\")\n        group2 = self.setup_numbered_bookmarks(3, prefix=\"bar\")\n\n        # shows all bookmarks initially\n        page = self.open(reverse(\"linkding:bundles.new\"))\n\n        expect(\n            page.get_by_text(\"Found 6 bookmarks matching this bundle\")\n        ).to_be_visible()\n        self.assertVisibleBookmarks(group1 + group2)\n\n        # filter by group1\n        search = page.get_by_label(\"Search\")\n        search.fill(\"foo\")\n\n        expect(\n            page.get_by_text(\"Found 3 bookmarks matching this bundle\")\n        ).to_be_visible()\n        self.assertVisibleBookmarks(group1)\n\n        # filter by group2\n        search.fill(\"bar\")\n\n        expect(\n            page.get_by_text(\"Found 3 bookmarks matching this bundle\")\n        ).to_be_visible()\n        self.assertVisibleBookmarks(group2)\n\n        # filter by invalid group\n        search.fill(\"invalid\")\n\n        expect(\n            page.get_by_text(\"No bookmarks match the current bundle\")\n        ).to_be_visible()\n        self.assertVisibleBookmarks([])\n\n    def assertVisibleBookmarks(self, bookmarks):\n        self.assertEqual(len(bookmarks), self.count_bookmarks())\n\n        for bookmark in bookmarks:\n            expect(self.locate_bookmark(bookmark.title)).to_be_visible()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_collapse_side_panel.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):\n    def setUp(self) -> None:\n        super().setUp()\n\n    def assertSidePanelIsVisible(self):\n        expect(self.page.locator(\".bookmarks-page .side-panel\")).to_be_visible()\n        expect(\n            self.page.locator(\".bookmarks-page ld-filter-drawer-trigger\")\n        ).not_to_be_visible()\n\n    def assertSidePanelIsHidden(self):\n        expect(self.page.locator(\".bookmarks-page .side-panel\")).not_to_be_visible()\n        expect(\n            self.page.locator(\".bookmarks-page ld-filter-drawer-trigger\")\n        ).to_be_visible()\n\n    def test_side_panel_should_be_visible_by_default(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n        self.assertSidePanelIsVisible()\n\n        self.page.goto(self.live_server_url + reverse(\"linkding:bookmarks.archived\"))\n        self.assertSidePanelIsVisible()\n\n        self.page.goto(self.live_server_url + reverse(\"linkding:bookmarks.shared\"))\n        self.assertSidePanelIsVisible()\n\n    def test_side_panel_should_be_hidden_when_collapsed(self):\n        user = self.get_or_create_test_user()\n        user.profile.collapse_side_panel = True\n        user.profile.save()\n\n        self.open(reverse(\"linkding:bookmarks.index\"))\n        self.assertSidePanelIsHidden()\n\n        self.page.goto(self.live_server_url + reverse(\"linkding:bookmarks.archived\"))\n        self.assertSidePanelIsHidden()\n\n        self.page.goto(self.live_server_url + reverse(\"linkding:bookmarks.shared\"))\n        self.assertSidePanelIsHidden()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_dropdown.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass DropdownE2ETestCase(LinkdingE2ETestCase):\n    def locate_dropdown(self):\n        # Use bookmarks dropdown as an example instance to test\n        return self.page.locator(\"ld-dropdown\").filter(\n            has=self.page.get_by_role(\"button\", name=\"Bookmarks\")\n        )\n\n    def locate_dropdown_toggle(self):\n        return self.locate_dropdown().locator(\".dropdown-toggle\")\n\n    def locate_dropdown_menu(self):\n        return self.locate_dropdown().locator(\".menu\")\n\n    def test_click_toggle_opens_and_closes_dropdown(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        toggle = self.locate_dropdown_toggle()\n        menu = self.locate_dropdown_menu()\n\n        # Open dropdown\n        toggle.click()\n        expect(menu).to_be_visible()\n\n        # Click toggle again to close\n        toggle.click()\n        expect(menu).not_to_be_visible()\n\n    def test_outside_click_closes_dropdown(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        toggle = self.locate_dropdown_toggle()\n        menu = self.locate_dropdown_menu()\n\n        # Open dropdown\n        toggle.click()\n        expect(menu).to_be_visible()\n\n        # Click outside the dropdown (on the page body)\n        self.page.locator(\"main\").click()\n        expect(menu).not_to_be_visible()\n\n    def test_escape_closes_dropdown_and_restores_focus(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        toggle = self.locate_dropdown_toggle()\n        menu = self.locate_dropdown_menu()\n\n        # Open dropdown\n        toggle.click()\n        expect(menu).to_be_visible()\n\n        # Press Escape\n        self.page.keyboard.press(\"Escape\")\n\n        # Menu should be closed\n        expect(menu).not_to_be_visible()\n\n        # Focus should be back on toggle\n        expect(toggle).to_be_focused()\n\n    def test_focus_out_closes_dropdown(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        toggle = self.locate_dropdown_toggle()\n        menu = self.locate_dropdown_menu()\n\n        # Open dropdown\n        toggle.click()\n        expect(menu).to_be_visible()\n\n        # Shift+Tab to move focus out of the dropdown\n        self.page.keyboard.press(\"Shift+Tab\")\n\n        # Menu should be closed after focus leaves\n        expect(menu).not_to_be_visible()\n\n    def test_aria_expanded_attribute(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        toggle = self.locate_dropdown_toggle()\n        menu = self.locate_dropdown_menu()\n\n        # Initially aria-expanded should be false\n        expect(toggle).to_have_attribute(\"aria-expanded\", \"false\")\n\n        # Open dropdown\n        toggle.click()\n        expect(menu).to_be_visible()\n\n        # aria-expanded should be true\n        expect(toggle).to_have_attribute(\"aria-expanded\", \"true\")\n\n        # Close dropdown\n        toggle.click()\n        expect(menu).not_to_be_visible()\n\n        # aria-expanded should be false again\n        expect(toggle).to_have_attribute(\"aria-expanded\", \"false\")\n\n    def test_can_click_menu_item(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        toggle = self.locate_dropdown_toggle()\n        menu = self.locate_dropdown_menu()\n\n        # Open dropdown\n        toggle.click()\n        expect(menu).to_be_visible()\n\n        # Click on \"Archived\" menu item\n        menu.get_by_text(\"Archived\", exact=True).click()\n\n        # Should navigate to archived page\n        expect(self.page).to_have_url(\n            self.live_server_url + reverse(\"linkding:bookmarks.archived\")\n        )\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_edit_bookmark_form.py",
    "content": "from unittest.mock import patch\n\nfrom django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.services import website_loader\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\nmock_website_metadata = website_loader.WebsiteMetadata(\n    url=\"https://example.com\",\n    title=\"Example Domain\",\n    description=\"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\",\n    preview_image=None,\n)\n\n\nclass BookmarkFormE2ETestCase(LinkdingE2ETestCase):\n    def setUp(self) -> None:\n        super().setUp()\n        self.website_loader_patch = patch.object(\n            website_loader, \"load_website_metadata\", return_value=mock_website_metadata\n        )\n        self.website_loader_patch.start()\n\n    def tearDown(self) -> None:\n        self.website_loader_patch.stop()\n        super().tearDown()\n\n    def test_should_not_check_for_existing_bookmark(self):\n        bookmark = self.setup_bookmark()\n\n        page = self.open(reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]))\n\n        page.wait_for_timeout(timeout=1000)\n        page.get_by_text(\"This URL is already bookmarked.\").wait_for(state=\"hidden\")\n\n    def test_should_not_prefill_title_and_description(self):\n        bookmark = self.setup_bookmark(\n            title=\"Initial title\", description=\"Initial description\"\n        )\n\n        page = self.open(reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]))\n        page.wait_for_timeout(timeout=1000)\n\n        title = page.get_by_label(\"Title\")\n        description = page.get_by_label(\"Description\")\n        expect(title).to_have_value(bookmark.title)\n        expect(description).to_have_value(bookmark.description)\n\n    def test_enter_url_should_not_prefill_title_and_description(self):\n        bookmark = self.setup_bookmark()\n\n        page = self.open(reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]))\n\n        page.get_by_label(\"URL\").fill(\"https://example.com\")\n        page.wait_for_timeout(timeout=1000)\n\n        title = page.get_by_label(\"Title\")\n        description = page.get_by_label(\"Description\")\n        expect(title).to_have_value(bookmark.title)\n        expect(description).to_have_value(bookmark.description)\n\n    def test_refresh_button_should_be_visible_when_editing(self):\n        bookmark = self.setup_bookmark()\n\n        page = self.open(reverse(\"linkding:bookmarks.edit\", args=[bookmark.id]))\n\n        refresh_button = page.get_by_text(\"Refresh from website\")\n        expect(refresh_button).to_be_visible()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_filter_drawer.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass FilterDrawerE2ETestCase(LinkdingE2ETestCase):\n    def test_show_modal_close_modal(self):\n        self.setup_bookmark(tags=[self.setup_tag(name=\"cooking\")])\n        self.setup_bookmark(tags=[self.setup_tag(name=\"hiking\")])\n\n        page = self.open(reverse(\"linkding:bookmarks.index\"))\n\n        # use smaller viewport to make filter button visible\n        page.set_viewport_size({\"width\": 375, \"height\": 812})\n\n        # open drawer\n        drawer_trigger = page.locator(\".main\").get_by_role(\"button\", name=\"Filters\")\n        drawer_trigger.click()\n\n        # verify drawer is visible\n        drawer = page.locator(\"ld-filter-drawer\")\n        expect(drawer).to_be_visible()\n        expect(drawer.locator(\"h2\")).to_have_text(\"Filters\")\n\n        # close with close button\n        drawer.locator(\"button.close\").click()\n        expect(drawer).to_be_hidden()\n\n        # open drawer again\n        drawer_trigger.click()\n\n        # close with backdrop\n        backdrop = drawer.locator(\".modal-overlay\")\n        backdrop.click(position={\"x\": 0, \"y\": 0})\n        expect(drawer).to_be_hidden()\n\n    def test_select_tag(self):\n        self.setup_bookmark(tags=[self.setup_tag(name=\"cooking\")])\n        self.setup_bookmark(tags=[self.setup_tag(name=\"hiking\")])\n\n        page = self.open(reverse(\"linkding:bookmarks.index\"))\n\n        # use smaller viewport to make filter button visible\n        page.set_viewport_size({\"width\": 375, \"height\": 812})\n\n        # open tag cloud modal\n        drawer_trigger = page.locator(\".main\").get_by_role(\"button\", name=\"Filters\")\n        drawer_trigger.click()\n\n        # verify tags are displayed\n        drawer = page.locator(\"ld-filter-drawer\")\n        unselected_tags = drawer.locator(\".unselected-tags\")\n        expect(unselected_tags.get_by_text(\"cooking\")).to_be_visible()\n        expect(unselected_tags.get_by_text(\"hiking\")).to_be_visible()\n\n        # select tag\n        unselected_tags.get_by_text(\"cooking\").click()\n\n        # open drawer again\n        drawer_trigger.click()\n\n        # verify tag is selected, other tag is not visible anymore\n        selected_tags = drawer.locator(\".selected-tags\")\n        expect(selected_tags.get_by_text(\"cooking\")).to_be_visible()\n\n        expect(unselected_tags.get_by_text(\"cooking\")).not_to_be_visible()\n        expect(unselected_tags.get_by_text(\"hiking\")).not_to_be_visible()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_global_shortcuts.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):\n    def test_focus_search(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        self.page.press(\"body\", \"s\")\n\n        expect(\n            self.page.get_by_placeholder(\"Search for words or #tags\")\n        ).to_be_focused()\n\n    def test_add_bookmark(self):\n        self.open(reverse(\"linkding:bookmarks.index\"))\n\n        self.page.press(\"body\", \"n\")\n\n        expect(self.page).to_have_url(\n            self.live_server_url + reverse(\"linkding:bookmarks.new\")\n        )\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_new_bookmark_form.py",
    "content": "from unittest.mock import patch\nfrom urllib.parse import quote\n\nfrom django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.models import Bookmark\nfrom bookmarks.services import website_loader\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\nmock_website_metadata = website_loader.WebsiteMetadata(\n    url=\"https://example.com\",\n    title=\"Example Domain\",\n    description=\"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\",\n    preview_image=None,\n)\n\n\nclass BookmarkFormE2ETestCase(LinkdingE2ETestCase):\n    def setUp(self) -> None:\n        super().setUp()\n        self.website_loader_patch = patch.object(\n            website_loader, \"load_website_metadata\", return_value=mock_website_metadata\n        )\n        self.website_loader_mock = self.website_loader_patch.start()\n\n    def tearDown(self) -> None:\n        self.website_loader_patch.stop()\n        super().tearDown()\n\n    def test_enter_url_prefills_title_and_description(self):\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n        url = page.get_by_label(\"URL\")\n        title = page.get_by_label(\"Title\")\n        description = page.get_by_label(\"Description\")\n\n        url.fill(\"https://example.com\")\n        expect(title).to_have_value(\"Example Domain\")\n        expect(description).to_have_value(\n            \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n        )\n\n    def test_enter_url_does_not_overwrite_modified_title_and_description(self):\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n        url = page.get_by_label(\"URL\")\n        title = page.get_by_label(\"Title\")\n        description = page.get_by_label(\"Description\")\n\n        title.fill(\"Modified title\")\n        description.fill(\"Modified description\")\n        url.fill(\"https://example.com\")\n        page.wait_for_timeout(timeout=1000)\n\n        expect(title).to_have_value(\"Modified title\")\n        expect(description).to_have_value(\"Modified description\")\n\n    def test_with_initial_url_prefills_title_and_description(self):\n        page_url = (\n            reverse(\"linkding:bookmarks.new\") + f\"?url={quote('https://example.com')}\"\n        )\n        page = self.open(page_url)\n        url = page.get_by_label(\"URL\")\n        title = page.get_by_label(\"Title\")\n        description = page.get_by_label(\"Description\")\n\n        page.wait_for_timeout(timeout=1000)\n\n        expect(url).to_have_value(\"https://example.com\")\n        expect(title).to_have_value(\"Example Domain\")\n        expect(description).to_have_value(\n            \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n        )\n\n    def test_with_initial_url_title_description_does_not_overwrite_title_and_description(\n        self,\n    ):\n        page_url = (\n            reverse(\"linkding:bookmarks.new\")\n            + f\"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description\"\n        )\n        page = self.open(page_url)\n        url = page.get_by_label(\"URL\")\n        title = page.get_by_label(\"Title\")\n        description = page.get_by_label(\"Description\")\n\n        page.wait_for_timeout(timeout=1000)\n\n        expect(url).to_have_value(\"https://example.com\")\n        expect(title).to_have_value(\"Initial title\")\n        expect(description).to_have_value(\"Initial description\")\n\n    def test_create_should_check_for_existing_bookmark(self):\n        existing_bookmark = self.setup_bookmark(\n            title=\"Existing title\",\n            description=\"Existing description\",\n            notes=\"Existing notes\",\n            tags=[self.setup_tag(name=\"tag1\"), self.setup_tag(name=\"tag2\")],\n            unread=True,\n        )\n        tag_names = \" \".join(existing_bookmark.tag_names)\n\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n\n        # Enter bookmarked URL\n        page.get_by_label(\"URL\").fill(existing_bookmark.url)\n        # Already bookmarked hint should be visible\n        page.get_by_text(\"This URL is already bookmarked.\").wait_for(timeout=2000)\n        # Form should be pre-filled with data from existing bookmark\n        self.assertEqual(\n            existing_bookmark.title, page.get_by_label(\"Title\").input_value()\n        )\n        self.assertEqual(\n            existing_bookmark.description,\n            page.get_by_label(\"Description\").input_value(),\n        )\n        self.assertEqual(\n            existing_bookmark.notes, page.get_by_label(\"Notes\").input_value()\n        )\n        self.assertEqual(tag_names, page.get_by_label(\"Tags\").input_value())\n        self.assertTrue(tag_names, page.get_by_label(\"Mark as unread\").is_checked())\n\n        # Enter non-bookmarked URL\n        page.get_by_label(\"URL\").fill(\"https://example.com/unknown\")\n        # Already bookmarked hint should be hidden\n        page.get_by_text(\"This URL is already bookmarked.\").wait_for(\n            state=\"hidden\", timeout=2000\n        )\n\n    def test_enter_url_of_existing_bookmark_should_show_notes(self):\n        bookmark = self.setup_bookmark(\n            notes=\"Existing notes\", description=\"Existing description\"\n        )\n\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n\n        details = page.locator(\"details.notes\")\n        expect(details).not_to_have_attribute(\"open\", value=\"\")\n\n        page.get_by_label(\"URL\").fill(bookmark.url)\n        expect(details).to_have_attribute(\"open\", value=\"\")\n\n    def test_create_should_preview_auto_tags(self):\n        profile = self.get_or_create_test_user().profile\n        profile.auto_tagging_rules = \"github.com dev github\"\n        profile.save()\n\n        # Open page with URL that should have auto tags\n        url = (\n            reverse(\"linkding:bookmarks.new\")\n            + \"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding\"\n        )\n        page = self.open(url)\n\n        auto_tags_hint = page.locator(\".form-input-hint.auto-tags\")\n        expect(auto_tags_hint).to_be_visible()\n        expect(auto_tags_hint).to_have_text(\"Auto tags: dev github\")\n\n        # Change to URL without auto tags\n        page.get_by_label(\"URL\").fill(\"https://example.com\")\n\n        expect(auto_tags_hint).to_be_hidden()\n\n    def test_clear_buttons_only_shown_when_fields_have_content(self):\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n\n        title_field = page.get_by_label(\"Title\")\n        title_clear_button = page.locator(\"ld-clear-button[data-for='id_title']\")\n        description_field = page.get_by_label(\"Description\")\n        description_clear_button = page.locator(\n            \"ld-clear-button[data-for='id_description']\"\n        )\n\n        # Initially, clear buttons should be hidden because fields are empty\n        expect(title_clear_button).to_be_hidden()\n        expect(description_clear_button).to_be_hidden()\n\n        # Add content to title field, its clear button should become visible\n        title_field.fill(\"Test title\")\n        expect(title_clear_button).to_be_visible()\n\n        # Add content to description field, its clear button should become visible\n        description_field.fill(\"Test description\")\n        expect(description_clear_button).to_be_visible()\n\n        # Clear title field, its clear button should be hidden again\n        title_field.fill(\"\")\n        expect(title_clear_button).to_be_hidden()\n\n        # Clear description field, its clear button should be hidden again\n        description_field.fill(\"\")\n        expect(description_clear_button).to_be_hidden()\n\n    def test_refresh_button_only_shown_for_existing_bookmarks(self):\n        existing_bookmark = self.setup_bookmark(\n            title=\"Existing title\", description=\"Existing description\"\n        )\n\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n        refresh_button = page.locator(\"#refresh-button\")\n\n        # Initially, refresh button should be hidden\n        expect(refresh_button).to_be_hidden()\n\n        # Enter a URL that is not bookmarked yet\n        page.get_by_label(\"URL\").fill(\"https://example.com/not-bookmarked\")\n        page.wait_for_timeout(timeout=1000)\n\n        expect(refresh_button).to_be_hidden()\n\n        # Enter a URL that is already bookmarked\n        page.get_by_label(\"URL\").fill(existing_bookmark.url)\n\n        expect(refresh_button).to_be_visible()\n\n        # Change back to non-bookmarked URL\n        page.get_by_label(\"URL\").fill(\"https://example.com/another-not-bookmarked\")\n\n        expect(refresh_button).to_be_hidden()\n\n    def test_refresh_from_website_button_updates_title_and_description(self):\n        existing_bookmark = self.setup_bookmark(\n            title=\"Existing title\", description=\"Existing description\"\n        )\n\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n        url_field = page.get_by_label(\"URL\")\n        title_field = page.get_by_label(\"Title\")\n        description_field = page.get_by_label(\"Description\")\n        refresh_button = page.locator(\"#refresh-button\")\n\n        # Enter the URL of the existing bookmark to make refresh button visible\n        url_field.fill(existing_bookmark.url)\n\n        # Wait for metadata to be loaded and for refresh button to be visible\n        expect(refresh_button).to_be_visible()\n        expect(title_field).to_have_value(\"Existing title\")\n        expect(description_field).to_have_value(\"Existing description\")\n\n        # Update the mock to return different metadata when refresh is requested\n        self.website_loader_mock.reset_mock()\n        self.website_loader_mock.return_value = website_loader.WebsiteMetadata(\n            url=\"https://example.com\",\n            title=\"Updated Example Domain\",\n            description=\"This is a refreshed description for the example domain.\",\n            preview_image=None,\n        )\n\n        # Click the refresh button\n        refresh_button.click()\n\n        # Verify that title and description have been updated with new values\n        expect(title_field).to_have_value(\"Updated Example Domain\")\n        expect(description_field).to_have_value(\n            \"This is a refreshed description for the example domain.\"\n        )\n\n        # Verify that the fields are visually marked as modified\n        expect(title_field).to_have_class(\"form-input modified\")\n        expect(description_field).to_have_class(\"form-input modified\")\n\n    def test_refresh_from_website_button_does_not_modify_fields_if_metadata_is_same(\n        self,\n    ):\n        existing_bookmark = self.setup_bookmark(\n            title=\"Existing title\", description=\"Existing description\"\n        )\n\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n        url_field = page.get_by_label(\"URL\")\n        title_field = page.get_by_label(\"Title\")\n        description_field = page.get_by_label(\"Description\")\n        refresh_button = page.locator(\"#refresh-button\")\n\n        # Enter the URL of the existing bookmark to make refresh button visible\n        url_field.fill(existing_bookmark.url)\n\n        # Wait for metadata to be loaded and for refresh button to be visible\n        expect(refresh_button).to_be_visible()\n        expect(title_field).to_have_value(\"Existing title\")\n        expect(description_field).to_have_value(\"Existing description\")\n\n        # Update the mock to return same metadata when refresh is requested\n        self.website_loader_mock.reset_mock()\n        self.website_loader_mock.return_value = website_loader.WebsiteMetadata(\n            url=\"https://example.com\",\n            title=\"Existing title\",\n            description=\"Existing description\",\n            preview_image=None,\n        )\n\n        # Click the refresh button\n        refresh_button.click()\n        page.wait_for_timeout(timeout=1000)\n\n        # Verify that title and description values are still the same\n        expect(title_field).to_have_value(\"Existing title\")\n        expect(description_field).to_have_value(\"Existing description\")\n\n        # Verify that the fields are NOT visually marked as modified\n        expect(title_field).to_have_class(\"form-input\")\n        expect(description_field).to_have_class(\"form-input\")\n\n    def test_ctrl_enter_submits_form_from_description(self):\n        page = self.open(reverse(\"linkding:bookmarks.new\"))\n        url_field = page.get_by_label(\"URL\")\n        description_field = page.get_by_label(\"Description\")\n\n        url_field.fill(\"https://example.com\")\n        description_field.fill(\"Test description\")\n        description_field.focus()\n\n        # Press Ctrl+Enter to submit form\n        description_field.press(\"Control+Enter\")\n\n        # Should navigate away from new bookmark page after successful submission\n        expect(page).not_to_have_url(\n            self.live_server_url + reverse(\"linkding:bookmarks.new\")\n        )\n\n        self.assertEqual(1, Bookmark.objects.count())\n        bookmark = Bookmark.objects.first()\n        self.assertEqual(\"https://example.com\", bookmark.url)\n        self.assertEqual(\"Test description\", bookmark.description)\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_settings_general.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.models import UserProfile\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass SettingsGeneralE2ETestCase(LinkdingE2ETestCase):\n    def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):\n        self.open(reverse(\"linkding:settings.general\"))\n\n        enable_sharing = self.page.get_by_label(\"Enable bookmark sharing\")\n        enable_sharing_label = self.page.get_by_text(\"Enable bookmark sharing\")\n        enable_public_sharing = self.page.get_by_label(\"Enable public bookmark sharing\")\n        enable_public_sharing_label = self.page.get_by_text(\n            \"Enable public bookmark sharing\"\n        )\n        default_mark_shared = self.page.get_by_label(\n            \"Create bookmarks as shared by default\"\n        )\n        default_mark_shared_label = self.page.get_by_text(\n            \"Create bookmarks as shared by default\"\n        )\n\n        # Public sharing and default shared are disabled by default\n        expect(enable_sharing).not_to_be_checked()\n        expect(enable_public_sharing).not_to_be_checked()\n        expect(enable_public_sharing).to_be_disabled()\n        expect(default_mark_shared).not_to_be_checked()\n        expect(default_mark_shared).to_be_disabled()\n\n        # Enable sharing\n        enable_sharing_label.click()\n        expect(enable_sharing).to_be_checked()\n        expect(enable_public_sharing).not_to_be_checked()\n        expect(enable_public_sharing).to_be_enabled()\n        expect(default_mark_shared).not_to_be_checked()\n        expect(default_mark_shared).to_be_enabled()\n\n        # Enable public sharing and default shared\n        enable_public_sharing_label.click()\n        default_mark_shared_label.click()\n        expect(enable_public_sharing).to_be_checked()\n        expect(enable_public_sharing).to_be_enabled()\n        expect(default_mark_shared).to_be_checked()\n        expect(default_mark_shared).to_be_enabled()\n\n        # Disable sharing\n        enable_sharing_label.click()\n        expect(enable_sharing).not_to_be_checked()\n        expect(enable_public_sharing).not_to_be_checked()\n        expect(enable_public_sharing).to_be_disabled()\n        expect(default_mark_shared).not_to_be_checked()\n        expect(default_mark_shared).to_be_disabled()\n\n    def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_description_display = (\n            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE\n        )\n        profile.save()\n\n        self.open(reverse(\"linkding:settings.general\"))\n\n        max_lines = self.page.get_by_label(\"Bookmark description max lines\")\n        expect(max_lines).to_be_hidden()\n\n    def test_should_show_bookmark_description_max_lines_when_display_separate(self):\n        profile = self.get_or_create_test_user().profile\n        profile.bookmark_description_display = (\n            UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE\n        )\n        profile.save()\n\n        self.open(reverse(\"linkding:settings.general\"))\n\n        max_lines = self.page.get_by_label(\"Bookmark description max lines\")\n        expect(max_lines).to_be_visible()\n\n    def test_should_update_bookmark_description_max_lines_when_changing_display(self):\n        self.open(reverse(\"linkding:settings.general\"))\n\n        max_lines = self.page.get_by_label(\"Bookmark description max lines\")\n        expect(max_lines).to_be_hidden()\n\n        display = self.page.get_by_label(\"Bookmark description\", exact=True)\n        display.select_option(\"separate\")\n        expect(max_lines).to_be_visible()\n\n        display.select_option(\"inline\")\n        expect(max_lines).to_be_hidden()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_settings_integrations.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass SettingsIntegrationsE2ETestCase(LinkdingE2ETestCase):\n    def test_create_api_token(self):\n        self.open(reverse(\"linkding:settings.integrations\"))\n\n        # Click create API token button\n        self.page.get_by_text(\"Create API token\").click()\n\n        # Wait for modal to appear\n        modal = self.page.locator(\".modal\")\n        expect(modal).to_be_visible()\n\n        # Enter custom token name\n        token_name_input = modal.locator(\"#token-name\")\n        token_name_input.fill(\"\")\n        token_name_input.fill(\"My Test Token\")\n\n        # Confirm the dialog\n        modal.page.get_by_role(\"button\", name=\"Create Token\").click()\n\n        # Verify the API token key is shown in the input\n        new_token_input = self.page.locator(\"#new-token-key\")\n        expect(new_token_input).to_be_visible()\n        token_value = new_token_input.input_value()\n        self.assertTrue(len(token_value) > 0)\n\n        # Verify the API token is now listed in the table\n        token_table = self.page.locator(\"table.crud-table\")\n        expect(token_table).to_be_visible()\n        expect(token_table.get_by_text(\"My Test Token\")).to_be_visible()\n\n        # Verify the dialog is gone\n        expect(modal).to_be_hidden()\n\n        # Reload the page to verify the API token key is only shown once\n        self.page.reload()\n\n        # Token key input should no longer be visible\n        expect(new_token_input).not_to_be_visible()\n\n        # But the token should still be listed in the table\n        expect(token_table.get_by_text(\"My Test Token\")).to_be_visible()\n\n    def test_delete_api_token(self):\n        self.setup_api_token(name=\"Token To Delete\")\n\n        self.open(reverse(\"linkding:settings.integrations\"))\n\n        token_table = self.page.locator(\"table.crud-table\")\n        expect(token_table.get_by_text(\"Token To Delete\")).to_be_visible()\n\n        # Click delete button for the token\n        token_row = token_table.locator(\"tr\").filter(has_text=\"Token To Delete\")\n        token_row.get_by_role(\"button\", name=\"Delete\").click()\n\n        # Confirm deletion\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        # Verify the token is removed from the table\n        expect(token_table.get_by_text(\"Token To Delete\")).not_to_be_visible()\n"
  },
  {
    "path": "bookmarks/tests_e2e/e2e_test_tag_management.py",
    "content": "from django.urls import reverse\nfrom playwright.sync_api import expect\n\nfrom bookmarks.models import Tag\nfrom bookmarks.tests_e2e.helpers import LinkdingE2ETestCase\n\n\nclass TagManagementE2ETestCase(LinkdingE2ETestCase):\n    def locate_tag_modal(self):\n        modal = self.page.locator(\"ld-modal.tag-edit-modal\")\n        expect(modal).to_be_visible()\n        return modal\n\n    def locate_merge_modal(self):\n        modal = self.page.locator(\"ld-modal\").filter(has_text=\"Merge Tags\")\n        expect(modal).to_be_visible()\n        return modal\n\n    def locate_tag_row(self, name: str):\n        return self.page.locator(\"table.crud-table tbody tr\").filter(has_text=name)\n\n    def verify_success_message(self, text: str):\n        success_message = self.page.locator(\".toast.toast-success\")\n        expect(success_message).to_be_visible()\n        expect(success_message).to_contain_text(text)\n\n    def test_create_tag(self):\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Click the Create Tag button to open the modal\n        self.page.get_by_text(\"Create Tag\").click()\n\n        modal = self.locate_tag_modal()\n\n        # Fill in a tag name\n        name_input = modal.get_by_label(\"Name\")\n        name_input.fill(\"test-tag\")\n\n        # Submit the form\n        modal.get_by_text(\"Save\").click()\n\n        # Verify modal is closed and we're back on the tags page\n        expect(modal).not_to_be_visible()\n\n        # Verify the success message is shown\n        self.verify_success_message('Tag \"test-tag\" created successfully.')\n\n        # Verify the new tag is shown in the list\n        tag_row = self.locate_tag_row(\"test-tag\")\n        expect(tag_row).to_be_visible()\n\n        # Verify the tag was actually created in the database\n        self.assertEqual(\n            Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 1\n        )\n        tag = Tag.objects.get(owner=self.get_or_create_test_user())\n        self.assertEqual(tag.name, \"test-tag\")\n\n    def test_create_tag_validation_error(self):\n        existing_tag = self.setup_tag(name=\"existing-tag\")\n\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Click the Create Tag button to open the modal\n        self.page.get_by_text(\"Create Tag\").click()\n\n        modal = self.locate_tag_modal()\n\n        # Submit with empty value\n        modal.get_by_text(\"Save\").click()\n\n        # Verify the error is shown (field is required)\n        error_hint = modal.get_by_text(\"This field is required\")\n        expect(error_hint).to_be_visible()\n\n        # Fill in the name of an existing tag\n        name_input = modal.get_by_label(\"Name\")\n        name_input.fill(existing_tag.name)\n\n        # Submit the form\n        modal.get_by_text(\"Save\").click()\n\n        # Verify the error is shown (tag already exists)\n        error_hint = modal.get_by_text('Tag \"existing-tag\" already exists')\n        expect(error_hint).to_be_visible()\n\n        # Verify no additional tag was created\n        self.assertEqual(\n            Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 1\n        )\n\n    def test_edit_tag(self):\n        tag = self.setup_tag(name=\"old-name\")\n\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Click the Edit button for the tag\n        tag_row = self.locate_tag_row(tag.name)\n        tag_row.get_by_role(\"link\", name=\"Edit\").click()\n\n        modal = self.locate_tag_modal()\n\n        # Verify the form is pre-filled with the tag name\n        name_input = modal.get_by_label(\"Name\")\n        expect(name_input).to_have_value(tag.name)\n\n        # Change the tag name\n        name_input.fill(\"new-name\")\n\n        # Submit the form\n        modal.get_by_text(\"Save\").click()\n\n        # Verify modal is closed\n        expect(modal).not_to_be_visible()\n\n        # Verify the updated tag is shown in the list\n        expect(self.locate_tag_row(\"new-name\")).to_be_visible()\n        expect(self.locate_tag_row(\"old-name\")).not_to_be_visible()\n\n        # Verify the tag was updated in the database\n        tag.refresh_from_db()\n        self.assertEqual(tag.name, \"new-name\")\n\n    def test_edit_tag_validation_error(self):\n        tag = self.setup_tag(name=\"tag-to-edit\")\n        other_tag = self.setup_tag(name=\"other-tag\")\n\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Click the Edit button for the tag\n        tag_row = self.locate_tag_row(tag.name)\n        tag_row.get_by_role(\"link\", name=\"Edit\").click()\n\n        modal = self.locate_tag_modal()\n\n        # Clear the name and submit\n        name_input = modal.get_by_label(\"Name\")\n        name_input.fill(\"\")\n        modal.get_by_text(\"Save\").click()\n\n        # Verify the error is shown (field is required)\n        error_hint = modal.get_by_text(\"This field is required\")\n        expect(error_hint).to_be_visible()\n\n        # Fill in the name of another existing tag\n        name_input.fill(other_tag.name)\n        modal.get_by_text(\"Save\").click()\n\n        # Verify the error is shown (tag already exists)\n        error_hint = modal.get_by_text('Tag \"other-tag\" already exists')\n        expect(error_hint).to_be_visible()\n\n        # Verify the tag was not modified\n        tag.refresh_from_db()\n        self.assertEqual(tag.name, \"tag-to-edit\")\n\n    def test_edit_tag_preserves_query_and_scroll_position(self):\n        # Create enough tags to have multiple pages (50 per page)\n        for i in range(70):\n            self.setup_tag(name=f\"test-tag-{i:02d}\")\n\n        # Open tags page 2 with search query\n        url = reverse(\"linkding:tags.index\") + \"?search=test&page=2\"\n        self.open(url)\n\n        # Verify we're on page 2\n        expect(self.locate_tag_row(\"test-tag-00\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-50\")).to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-60\")).to_be_visible()\n\n        # Scroll down\n        self.page.evaluate(\"window.scrollTo(0, 300)\")\n        initial_scroll = self.page.evaluate(\"window.scrollY\")\n        self.assertGreater(initial_scroll, 0)\n\n        # Edit tag\n        tag_row = self.locate_tag_row(\"test-tag-55\")\n        tag_row.get_by_role(\"link\", name=\"Edit\").click()\n\n        modal = self.locate_tag_modal()\n\n        name_input = modal.get_by_label(\"Name\")\n        name_input.fill(\"test-tag-55-edited\")\n\n        modal.get_by_text(\"Save\").click()\n\n        expect(modal).not_to_be_visible()\n\n        # Verify query parameters and scroll position are preserved\n        current_url = self.page.url\n        self.assertIn(\"search=test\", current_url)\n        self.assertIn(\"page=2\", current_url)\n\n        expect(self.locate_tag_row(\"test-tag-00\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-50\")).to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-55-edited\")).to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-60\")).to_be_visible()\n\n        final_scroll = self.page.evaluate(\"window.scrollY\")\n        self.assertEqual(initial_scroll, final_scroll)\n\n    def test_delete_tag_preserves_query_and_scroll_position(self):\n        # Create enough tags to have multiple pages (50 per page)\n        for i in range(70):\n            self.setup_tag(name=f\"test-tag-{i:02d}\")\n\n        # Open tags page 2 with search query\n        url = reverse(\"linkding:tags.index\") + \"?search=test&page=2\"\n        self.open(url)\n\n        # Verify we're on page 2\n        expect(self.locate_tag_row(\"test-tag-00\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-50\")).to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-55\")).to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-60\")).to_be_visible()\n\n        # Scroll down\n        self.page.evaluate(\"window.scrollTo(0, 300)\")\n        initial_scroll = self.page.evaluate(\"window.scrollY\")\n        self.assertGreater(initial_scroll, 0)\n\n        # Delete tag\n        tag_row = self.locate_tag_row(\"test-tag-55\")\n        tag_row.get_by_role(\"button\", name=\"Remove\").click()\n        self.locate_confirm_dialog().get_by_text(\"Confirm\").click()\n\n        # Verify query parameters and scroll position are preserved\n        current_url = self.page.url\n        self.assertIn(\"search=test\", current_url)\n        self.assertIn(\"page=2\", current_url)\n\n        expect(self.locate_tag_row(\"test-tag-00\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-50\")).to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-55\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"test-tag-60\")).to_be_visible()\n\n        final_scroll = self.page.evaluate(\"window.scrollY\")\n        self.assertEqual(initial_scroll, final_scroll)\n\n    def test_merge_tags(self):\n        target_tag = self.setup_tag(name=\"target-tag\")\n        merge_tag1 = self.setup_tag(name=\"merge-tag1\")\n        merge_tag2 = self.setup_tag(name=\"merge-tag2\")\n\n        # Create bookmarks with the merge tags\n        bookmark1 = self.setup_bookmark(tags=[merge_tag1])\n        bookmark2 = self.setup_bookmark(tags=[merge_tag2])\n\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Click the Merge Tags button to open the modal\n        self.page.get_by_text(\"Merge Tags\", exact=True).click()\n\n        modal = self.locate_merge_modal()\n\n        # Fill in the target tag\n        target_input = modal.get_by_label(\"Target tag\")\n        target_input.fill(target_tag.name)\n\n        # Fill in the tags to merge\n        merge_input = modal.get_by_label(\"Tags to merge\")\n        merge_input.fill(f\"{merge_tag1.name} {merge_tag2.name}\")\n\n        # Submit the form\n        modal.get_by_role(\"button\", name=\"Merge Tags\").click()\n\n        # Verify modal is closed\n        expect(modal).not_to_be_visible()\n\n        # Verify the success message is shown\n        self.verify_success_message(\n            'Successfully merged 2 tags (merge-tag1, merge-tag2) into \"target-tag\".'\n        )\n\n        # Verify the merged tags are no longer in the list\n        expect(self.locate_tag_row(\"target-tag\")).to_be_visible()\n        expect(self.locate_tag_row(\"merge-tag1\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"merge-tag2\")).not_to_be_visible()\n\n        # Verify the merge tags were deleted\n        self.assertEqual(\n            Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 1\n        )\n\n        # Verify bookmarks only have the target tag\n        bookmark1.refresh_from_db()\n        bookmark2.refresh_from_db()\n        self.assertCountEqual([target_tag], bookmark1.tags.all())\n        self.assertCountEqual([target_tag], bookmark2.tags.all())\n\n    def test_merge_tags_validation_error(self):\n        target_tag = self.setup_tag(name=\"target-tag\")\n        merge_tag = self.setup_tag(name=\"merge-tag\")\n\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Click the Merge Tags button to open the modal\n        self.page.get_by_text(\"Merge Tags\", exact=True).click()\n\n        modal = self.locate_merge_modal()\n\n        # Submit with empty values\n        modal.get_by_role(\"button\", name=\"Merge Tags\").click()\n\n        # Verify the errors are shown\n        expect(modal.get_by_text(\"This field is required\").first).to_be_visible()\n\n        # Fill in non-existent target tag\n        target_input = modal.get_by_label(\"Target tag\")\n        target_input.fill(\"nonexistent-tag\")\n\n        merge_input = modal.get_by_label(\"Tags to merge\")\n        merge_input.fill(merge_tag.name)\n\n        modal.get_by_role(\"button\", name=\"Merge Tags\").click()\n\n        # Verify error for non-existent target tag\n        expect(\n            modal.get_by_text('Tag \"nonexistent-tag\" does not exist')\n        ).to_be_visible()\n\n        # Fill in valid target but target tag in merge tags\n        target_input.fill(target_tag.name)\n        merge_input.fill(target_tag.name)\n\n        modal.get_by_role(\"button\", name=\"Merge Tags\").click()\n\n        # Verify error for target tag in merge tags\n        expect(\n            modal.get_by_text(\"The target tag cannot be selected for merging\")\n        ).to_be_visible()\n\n        # Verify no tags were deleted\n        self.assertEqual(\n            Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 2\n        )\n\n    def test_search_updates_url_query_params(self):\n        self.setup_tag(name=\"python\")\n        self.setup_tag(name=\"javascript\")\n        self.setup_tag(name=\"typescript\")\n\n        self.open(reverse(\"linkding:tags.index\"))\n\n        # Verify all tags are visible initially\n        expect(self.locate_tag_row(\"python\")).to_be_visible()\n        expect(self.locate_tag_row(\"javascript\")).to_be_visible()\n        expect(self.locate_tag_row(\"typescript\")).to_be_visible()\n\n        # Enter search term and submit\n        search_input = self.page.get_by_placeholder(\"Search tags...\")\n        search_input.fill(\"script\")\n        self.page.get_by_role(\"button\", name=\"Search\").click()\n\n        # Wait for filtered results to appear\n        expect(self.locate_tag_row(\"python\")).not_to_be_visible()\n        expect(self.locate_tag_row(\"javascript\")).to_be_visible()\n        expect(self.locate_tag_row(\"typescript\")).to_be_visible()\n\n        # Verify URL contains search query param\n        self.assertIn(\"search=script\", self.page.url)\n"
  },
  {
    "path": "bookmarks/tests_e2e/helpers.py",
    "content": "import os\n\nfrom django.contrib.staticfiles.testing import LiveServerTestCase\nfrom playwright.sync_api import BrowserContext, Page, expect, sync_playwright\n\nfrom bookmarks.tests.helpers import BookmarkFactoryMixin\n\nSCREENSHOT_DIR = \"test-results/screenshots\"\n\n# Allow Django ORM operations within Playwright's async context\nos.environ.setdefault(\"DJANGO_ALLOW_ASYNC_UNSAFE\", \"true\")\n\n\nclass LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):\n    def setUp(self) -> None:\n        self.client.force_login(self.get_or_create_test_user())\n        self.cookie = self.client.cookies[\"sessionid\"]\n        self.playwright = None\n        self.browser = None\n        self.context = None\n        self.page = None\n\n    def tearDown(self) -> None:\n        if self.page and self._test_has_failed():\n            self._capture_screenshot()\n        if self.browser:\n            self.browser.close()\n        if self.playwright:\n            self.playwright.stop()\n        super().tearDown()\n\n    def _test_has_failed(self) -> bool:\n        \"\"\"Detect if the current test has failed. Works with both Django/unittest and pytest.\"\"\"\n        # Check _outcome for failure info\n        if self._outcome is not None:\n            result = self._outcome.result\n            if result:\n                # pytest stores exception info in _excinfo\n                if hasattr(result, \"_excinfo\") and result._excinfo:\n                    return True\n                # Django/unittest stores failures and errors in the result\n                # Check if THIS test is in failures/errors (not just any test)\n                if hasattr(result, \"failures\"):\n                    for failed_test, _ in result.failures:\n                        if failed_test is self:\n                            return True\n        return False\n\n    def _ensure_playwright(self):\n        if not self.playwright:\n            self.playwright = sync_playwright().start()\n            self.browser = self.playwright.chromium.launch(headless=True)\n\n    def _capture_screenshot(self):\n        os.makedirs(SCREENSHOT_DIR, exist_ok=True)\n        filename = f\"{self.__class__.__name__}_{self._testMethodName}.png\"\n        filepath = os.path.join(SCREENSHOT_DIR, filename)\n        self.page.screenshot(path=filepath, full_page=True)\n\n    def setup_browser(self) -> BrowserContext:\n        self._ensure_playwright()\n        context = self.browser.new_context()\n        context.add_cookies(\n            [\n                {\n                    \"name\": \"sessionid\",\n                    \"value\": self.cookie.value,\n                    \"domain\": self.live_server_url.replace(\"http:\", \"\"),\n                    \"path\": \"/\",\n                }\n            ]\n        )\n        return context\n\n    def open(self, url: str) -> Page:\n        self.context = self.setup_browser()\n        self.page = self.context.new_page()\n        self.page.on(\"pageerror\", self.on_page_error)\n        self.page.goto(self.live_server_url + url)\n        self.page.on(\"load\", self.on_load)\n        self.num_loads = 0\n        return self.page\n\n    def on_page_error(self, error):\n        print(f\"[JS ERROR] {error}\")\n        if hasattr(error, \"stack\"):\n            print(f\"[JS STACK] {error.stack}\")\n\n    def on_load(self):\n        self.num_loads += 1\n\n    def assertReloads(self, count: int):\n        self.assertEqual(self.num_loads, count)\n\n    def resetReloads(self):\n        self.num_loads = 0\n\n    def locate_bookmark_list(self):\n        return self.page.locator(\"ul.bookmark-list\")\n\n    def locate_bookmark(self, title: str):\n        bookmark_tags = self.page.locator(\"ul.bookmark-list > li\")\n        return bookmark_tags.filter(has_text=title)\n\n    def count_bookmarks(self):\n        bookmark_tags = self.page.locator(\"ul.bookmark-list > li\")\n        return bookmark_tags.count()\n\n    def locate_details_modal(self):\n        return self.page.locator(\"ld-details-modal\")\n\n    def open_details_modal(self, bookmark):\n        details_button = self.locate_bookmark(bookmark.title).get_by_text(\"View\")\n        details_button.click()\n\n        details_modal = self.locate_details_modal()\n        expect(details_modal).to_be_visible()\n\n        return details_modal\n\n    def locate_bulk_edit_bar(self):\n        return self.page.locator(\".bulk-edit-bar\")\n\n    def locate_bulk_edit_select_all(self):\n        return self.locate_bulk_edit_bar().locator(\"label.bulk-edit-checkbox.all\")\n\n    def locate_bulk_edit_select_across(self):\n        return self.locate_bulk_edit_bar().locator(\"label.select-across\")\n\n    def locate_bulk_edit_toggle(self):\n        return self.page.get_by_title(\"Bulk edit\")\n\n    def select_bulk_action(self, value: str):\n        return (\n            self.locate_bulk_edit_bar()\n            .locator('select[name=\"bulk_action\"]')\n            .select_option(value)\n        )\n\n    def navigate_menu(self, main_menu_item: str, sub_menu_item: str | None = None):\n        if sub_menu_item:\n            self.page.locator(\"nav\").get_by_role(\"button\", name=main_menu_item).click()\n            self.page.locator(\"nav ul.menu\").get_by_text(\n                sub_menu_item, exact=True\n            ).click()\n        else:\n            self.page.locator(\"nav\").get_by_text(main_menu_item, exact=True).click()\n\n    def locate_confirm_dialog(self):\n        return self.page.locator(\"ld-confirm-dropdown\")\n"
  },
  {
    "path": "bookmarks/type_defs.py",
    "content": "\"\"\"\nStuff in here is only used for type hints\n\"\"\"\n\nfrom django import http\nfrom django.contrib.auth.models import AnonymousUser\n\nfrom bookmarks.models import GlobalSettings, User, UserProfile\n\n\nclass HttpRequest(http.HttpRequest):\n    global_settings: GlobalSettings\n    user_profile: UserProfile\n    user: User | AnonymousUser\n"
  },
  {
    "path": "bookmarks/urls.py",
    "content": "from django.conf import settings\nfrom django.contrib.auth import views as django_auth_views\nfrom django.urls import include, path, re_path\n\nfrom bookmarks import feeds\nfrom bookmarks.admin import linkding_admin_site\nfrom bookmarks.api import routes as api_routes\nfrom bookmarks.views import assets as assets_views\nfrom bookmarks.views import auth as linkding_auth_views\nfrom bookmarks.views import bookmarks as bookmarks_views\nfrom bookmarks.views import bundles as bundles_views\nfrom bookmarks.views import settings as settings_views\nfrom bookmarks.views import tags as tags_views\nfrom bookmarks.views import toasts as toasts_views\nfrom bookmarks.views.custom_css import custom_css as custom_css_view\nfrom bookmarks.views.health import health as health_view\nfrom bookmarks.views.manifest import manifest as manifest_view\nfrom bookmarks.views.opensearch import opensearch as opensearch_view\nfrom bookmarks.views.root import root as root_view\n\nurlpatterns = [\n    # Root view handling redirection based on user authentication\n    re_path(r\"^$\", root_view, name=\"root\"),\n    # Bookmarks\n    path(\"bookmarks\", bookmarks_views.index, name=\"bookmarks.index\"),\n    path(\n        \"bookmarks/action\", bookmarks_views.index_action, name=\"bookmarks.index.action\"\n    ),\n    path(\"bookmarks/archived\", bookmarks_views.archived, name=\"bookmarks.archived\"),\n    path(\n        \"bookmarks/archived/action\",\n        bookmarks_views.archived_action,\n        name=\"bookmarks.archived.action\",\n    ),\n    path(\"bookmarks/shared\", bookmarks_views.shared, name=\"bookmarks.shared\"),\n    path(\n        \"bookmarks/shared/action\",\n        bookmarks_views.shared_action,\n        name=\"bookmarks.shared.action\",\n    ),\n    path(\"bookmarks/new\", bookmarks_views.new, name=\"bookmarks.new\"),\n    path(\"bookmarks/close\", bookmarks_views.close, name=\"bookmarks.close\"),\n    path(\n        \"bookmarks/<int:bookmark_id>/edit\", bookmarks_views.edit, name=\"bookmarks.edit\"\n    ),\n    # Assets\n    path(\n        \"assets/<int:asset_id>\",\n        assets_views.view,\n        name=\"assets.view\",\n    ),\n    path(\n        \"assets/<int:asset_id>/read\",\n        assets_views.read,\n        name=\"assets.read\",\n    ),\n    # Bundles\n    path(\"bundles\", bundles_views.index, name=\"bundles.index\"),\n    path(\"bundles/action\", bundles_views.action, name=\"bundles.action\"),\n    path(\"bundles/new\", bundles_views.new, name=\"bundles.new\"),\n    path(\"bundles/<int:bundle_id>/edit\", bundles_views.edit, name=\"bundles.edit\"),\n    path(\"bundles/preview\", bundles_views.preview, name=\"bundles.preview\"),\n    # Tags\n    path(\"tags\", tags_views.tags_index, name=\"tags.index\"),\n    path(\"tags/new\", tags_views.tag_new, name=\"tags.new\"),\n    path(\"tags/<int:tag_id>/edit\", tags_views.tag_edit, name=\"tags.edit\"),\n    path(\"tags/merge\", tags_views.tag_merge, name=\"tags.merge\"),\n    # Settings\n    path(\"settings\", settings_views.general, name=\"settings.index\"),\n    path(\"settings/general\", settings_views.general, name=\"settings.general\"),\n    path(\"settings/update\", settings_views.update, name=\"settings.update\"),\n    path(\n        \"settings/integrations\",\n        settings_views.integrations,\n        name=\"settings.integrations\",\n    ),\n    path(\n        \"settings/integrations/create-api-token\",\n        settings_views.create_api_token,\n        name=\"settings.integrations.create_api_token\",\n    ),\n    path(\n        \"settings/integrations/delete-api-token\",\n        settings_views.delete_api_token,\n        name=\"settings.integrations.delete_api_token\",\n    ),\n    path(\"settings/import\", settings_views.bookmark_import, name=\"settings.import\"),\n    path(\"settings/export\", settings_views.bookmark_export, name=\"settings.export\"),\n    # Toasts\n    path(\"toasts/acknowledge\", toasts_views.acknowledge, name=\"toasts.acknowledge\"),\n    # API\n    path(\"api/\", include(api_routes.default_router.urls)),\n    path(\"api/bookmarks/\", include(api_routes.bookmark_router.urls)),\n    path(\n        \"api/bookmarks/<int:bookmark_id>/assets/\",\n        include(api_routes.bookmark_asset_router.urls),\n    ),\n    path(\"api/tags/\", include(api_routes.tag_router.urls)),\n    path(\"api/bundles/\", include(api_routes.bundle_router.urls)),\n    path(\"api/user/\", include(api_routes.user_router.urls)),\n    # Feeds\n    path(\"feeds/<str:feed_key>/all\", feeds.AllBookmarksFeed(), name=\"feeds.all\"),\n    path(\n        \"feeds/<str:feed_key>/unread\", feeds.UnreadBookmarksFeed(), name=\"feeds.unread\"\n    ),\n    path(\n        \"feeds/<str:feed_key>/shared\", feeds.SharedBookmarksFeed(), name=\"feeds.shared\"\n    ),\n    path(\"feeds/shared\", feeds.PublicSharedBookmarksFeed(), name=\"feeds.public_shared\"),\n    # Health check\n    path(\"health\", health_view, name=\"health\"),\n    # Manifest\n    path(\"manifest.json\", manifest_view, name=\"manifest\"),\n    # Custom CSS\n    path(\"custom_css\", custom_css_view, name=\"custom_css\"),\n    # OpenSearch\n    path(\"opensearch.xml\", opensearch_view, name=\"opensearch\"),\n]\n\n# Live reload (debug only)\nif settings.DEBUG:\n    from bookmarks.views import reload\n\n    urlpatterns.append(path(\"live_reload\", reload.live_reload, name=\"live_reload\"))\n\n# Put all linkding URLs into a linkding namespace\nurlpatterns = [path(\"\", include((urlpatterns, \"linkding\")))]\n\n# Auth\nurlpatterns += [\n    path(\n        \"login/\",\n        linkding_auth_views.LinkdingLoginView.as_view(redirect_authenticated_user=True),\n        name=\"login\",\n    ),\n    path(\"logout/\", django_auth_views.LogoutView.as_view(), name=\"logout\"),\n    path(\n        \"change-password/\",\n        linkding_auth_views.LinkdingPasswordChangeView.as_view(),\n        name=\"change_password\",\n    ),\n    path(\n        \"password-change-done/\",\n        django_auth_views.PasswordChangeDoneView.as_view(),\n        name=\"password_change_done\",\n    ),\n]\n\n# Admin\nurlpatterns.append(path(\"admin/\", linkding_admin_site.urls))\n\n# OIDC\nif settings.LD_ENABLE_OIDC:\n    urlpatterns.append(path(\"oidc/\", include(\"mozilla_django_oidc.urls\")))\n\n# Debug toolbar\n# if settings.DEBUG:\n#    import debug_toolbar\n#    urlpatterns.append(path(\"__debug__/\", include(debug_toolbar.urls)))\n\n# Context path\nif settings.LD_CONTEXT_PATH:\n    urlpatterns = [path(settings.LD_CONTEXT_PATH, include(urlpatterns))]\n"
  },
  {
    "path": "bookmarks/utils.py",
    "content": "import datetime\nimport logging\nimport re\nimport unicodedata\nimport urllib.parse\nfrom dataclasses import dataclass\n\nfrom django.conf import settings\nfrom django.http import HttpResponseRedirect\nfrom django.template.defaultfilters import pluralize\nfrom django.utils import formats, timezone\n\ntry:\n    with open(\"version.txt\") as f:\n        app_version = f.read().strip(\"\\n\")\nexcept Exception as exc:\n    logging.exception(exc)\n    app_version = \"\"\n\n\ndef unique(elements, key):\n    return list({key(element): element for element in elements}.values())\n\n\nweekday_names = {\n    1: \"Monday\",\n    2: \"Tuesday\",\n    3: \"Wednesday\",\n    4: \"Thursday\",\n    5: \"Friday\",\n    6: \"Saturday\",\n    7: \"Sunday\",\n}\n\n\n@dataclass\nclass DateDelta:\n    years: int\n    months: int\n    weeks: int\n\n\ndef _calculate_date_delta(\n    now: datetime.datetime, value: datetime.datetime\n) -> DateDelta:\n    \"\"\"Calculate the difference between two datetimes in years, months, and weeks.\"\"\"\n    # Full calendar years\n    years = now.year - value.year\n    if (now.month, now.day) < (value.month, value.day):\n        years -= 1\n\n    # Full calendar months\n    months = (now.year - value.year) * 12 + (now.month - value.month)\n    if now.day < value.day:\n        months -= 1\n\n    # Weeks from total days\n    weeks = (now - value).days // 7\n\n    return DateDelta(years=max(0, years), months=max(0, months), weeks=max(0, weeks))\n\n\ndef humanize_absolute_date(\n    value: datetime.datetime, now: datetime.datetime | None = None\n):\n    if not now:\n        now = timezone.now()\n    delta = _calculate_date_delta(now, value)\n    yesterday = now - datetime.timedelta(days=1)\n\n    is_older_than_a_week = delta.years > 0 or delta.months > 0 or delta.weeks > 0\n\n    if is_older_than_a_week:\n        return formats.date_format(value, \"SHORT_DATE_FORMAT\")\n    elif value.day == now.day:\n        return \"Today\"\n    elif value.day == yesterday.day:\n        return \"Yesterday\"\n    else:\n        return weekday_names[value.isoweekday()]\n\n\ndef humanize_relative_date(\n    value: datetime.datetime, now: datetime.datetime | None = None\n):\n    if not now:\n        now = timezone.now()\n    delta = _calculate_date_delta(now, value)\n\n    if delta.years > 0:\n        return f\"{delta.years} year{pluralize(delta.years)} ago\"\n    elif delta.months > 0:\n        return f\"{delta.months} month{pluralize(delta.months)} ago\"\n    elif delta.weeks > 0:\n        return f\"{delta.weeks} week{pluralize(delta.weeks)} ago\"\n    else:\n        yesterday = now - datetime.timedelta(days=1)\n        if value.day == now.day:\n            return \"Today\"\n        elif value.day == yesterday.day:\n            return \"Yesterday\"\n        else:\n            return weekday_names[value.isoweekday()]\n\n\ndef parse_timestamp(value: str):\n    \"\"\"\n    Parses a string timestamp into a datetime value\n    First tries to parse the timestamp as milliseconds.\n    If that fails with an error indicating that the timestamp exceeds the maximum,\n    it tries to parse the timestamp as microseconds, and then as nanoseconds\n    :param value:\n    :return:\n    \"\"\"\n    try:\n        timestamp = int(value)\n    except ValueError:\n        raise ValueError(f\"{value} is not a valid timestamp\") from None\n\n    try:\n        return datetime.datetime.fromtimestamp(timestamp, datetime.UTC)\n    except (OverflowError, ValueError, OSError):\n        pass\n\n    # Value exceeds the max. allowed timestamp\n    # Try parsing as microseconds\n    try:\n        return datetime.datetime.fromtimestamp(timestamp / 1000, datetime.UTC)\n    except (OverflowError, ValueError, OSError):\n        pass\n\n    # Value exceeds the max. allowed timestamp\n    # Try parsing as nanoseconds\n    try:\n        return datetime.datetime.fromtimestamp(timestamp / 1000000, datetime.UTC)\n    except (OverflowError, ValueError, OSError):\n        pass\n\n    # Timestamp is out of range\n    raise ValueError(f\"{value} exceeds maximum value for a timestamp\")\n\n\ndef get_safe_return_url(return_url: str, fallback_url: str):\n    # Use fallback if URL is none or URL is not on same domain\n    if not return_url or not re.match(r\"^/[a-z]+\", return_url):\n        return fallback_url\n    return return_url\n\n\ndef redirect_with_query(request, redirect_url):\n    query_string = urllib.parse.urlencode(request.GET)\n    if query_string:\n        redirect_url += \"?\" + query_string\n\n    return HttpResponseRedirect(redirect_url)\n\n\ndef generate_username(email, claims):\n    # taken from mozilla-django-oidc docs :)\n    # Using Python 3 and Django 1.11+, usernames can contain alphanumeric\n    # (ascii and unicode), _, @, +, . and - characters. So we normalize\n    # it and slice at 150 characters.\n    if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:\n        username = claims[settings.OIDC_USERNAME_CLAIM]\n    else:\n        username = email\n    return unicodedata.normalize(\"NFKC\", username)[:150]\n\n\ndef normalize_url(url: str) -> str:\n    if not url or not isinstance(url, str):\n        return \"\"\n\n    url = url.strip()\n    if not url:\n        return \"\"\n\n    try:\n        parsed = urllib.parse.urlparse(url)\n\n        # Normalize the scheme to lowercase\n        scheme = parsed.scheme.lower()\n\n        # Normalize the netloc (domain) to lowercase\n        netloc = parsed.hostname.lower() if parsed.hostname else \"\"\n        if parsed.port:\n            netloc += f\":{parsed.port}\"\n        if parsed.username:\n            auth = parsed.username\n            if parsed.password:\n                auth += f\":{parsed.password}\"\n            netloc = f\"{auth}@{netloc}\"\n\n        # Remove trailing slashes from all paths\n        path = parsed.path.rstrip(\"/\") if parsed.path else \"\"\n\n        # Sort query parameters alphabetically\n        query = \"\"\n        if parsed.query:\n            query_params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)\n            query_params.sort(key=lambda x: (x[0], x[1]))\n            query = urllib.parse.urlencode(query_params, quote_via=urllib.parse.quote)\n\n        # Keep fragment as-is\n        fragment = parsed.fragment\n\n        # Reconstruct the normalized URL\n        return urllib.parse.urlunparse(\n            (scheme, netloc, path, parsed.params, query, fragment)\n        )\n\n    except (ValueError, AttributeError):\n        return url\n"
  },
  {
    "path": "bookmarks/validators.py",
    "content": "from django.conf import settings\nfrom django.core import validators\n\n\nclass BookmarkURLValidator(validators.URLValidator):\n    \"\"\"\n    Extends default Django URLValidator and cancels validation if it is disabled in settings.\n    This allows to switch URL validation on/off dynamically which helps with testing\n    \"\"\"\n\n    def __call__(self, value):\n        if settings.LD_DISABLE_URL_VALIDATION:\n            return\n\n        super().__call__(value)\n"
  },
  {
    "path": "bookmarks/views/__init__.py",
    "content": ""
  },
  {
    "path": "bookmarks/views/access.py",
    "content": "from django.http import Http404\n\nfrom bookmarks.models import ApiToken, Bookmark, BookmarkAsset, BookmarkBundle, Toast\nfrom bookmarks.type_defs import HttpRequest\n\n\ndef bookmark_read(request: HttpRequest, bookmark_id: int | str):\n    try:\n        bookmark = Bookmark.objects.get(pk=int(bookmark_id))\n    except Bookmark.DoesNotExist:\n        raise Http404(\"Bookmark does not exist\") from None\n\n    is_owner = bookmark.owner == request.user\n    is_shared = (\n        request.user.is_authenticated\n        and bookmark.shared\n        and bookmark.owner.profile.enable_sharing\n    )\n    is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing\n    if not is_owner and not is_shared and not is_public_shared:\n        raise Http404(\"Bookmark does not exist\")\n    if request.method == \"POST\" and not is_owner:\n        raise Http404(\"Bookmark does not exist\")\n\n    return bookmark\n\n\ndef bookmark_write(request: HttpRequest, bookmark_id: int | str):\n    try:\n        return Bookmark.objects.get(pk=bookmark_id, owner=request.user)\n    except Bookmark.DoesNotExist:\n        raise Http404(\"Bookmark does not exist\") from None\n\n\ndef bundle_read(request: HttpRequest, bundle_id: int | str):\n    return bundle_write(request, bundle_id)\n\n\ndef bundle_write(request: HttpRequest, bundle_id: int | str):\n    try:\n        return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)\n    except (BookmarkBundle.DoesNotExist, ValueError):\n        raise Http404(\"Bundle does not exist\") from None\n\n\ndef asset_read(request: HttpRequest, asset_id: int | str):\n    try:\n        asset = BookmarkAsset.objects.get(pk=asset_id)\n    except BookmarkAsset.DoesNotExist:\n        raise Http404(\"Asset does not exist\") from None\n\n    bookmark_read(request, asset.bookmark_id)\n    return asset\n\n\ndef asset_write(request: HttpRequest, asset_id: int | str):\n    try:\n        return BookmarkAsset.objects.get(pk=asset_id, bookmark__owner=request.user)\n    except BookmarkAsset.DoesNotExist:\n        raise Http404(\"Asset does not exist\") from None\n\n\ndef toast_write(request: HttpRequest, toast_id: int | str):\n    try:\n        return Toast.objects.get(pk=toast_id, owner=request.user)\n    except Toast.DoesNotExist:\n        raise Http404(\"Toast does not exist\") from None\n\n\ndef api_token_write(request: HttpRequest, token_id: int | str):\n    try:\n        return ApiToken.objects.get(id=token_id, user=request.user)\n    except (ApiToken.DoesNotExist, ValueError):\n        raise Http404(\"API token does not exist\") from None\n"
  },
  {
    "path": "bookmarks/views/assets.py",
    "content": "import gzip\nimport os\n\nfrom django.conf import settings\nfrom django.http import (\n    Http404,\n    HttpResponse,\n)\nfrom django.shortcuts import render\n\nfrom bookmarks.views import access\n\n\ndef _get_asset_content(asset):\n    filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)\n\n    if not os.path.exists(filepath):\n        raise Http404(\"Asset file does not exist\")\n\n    if asset.gzip:\n        with gzip.open(filepath, \"rb\") as f:\n            content = f.read()\n    else:\n        with open(filepath, \"rb\") as f:\n            content = f.read()\n\n    return content\n\n\ndef view(request, asset_id: int):\n    asset = access.asset_read(request, asset_id)\n    content = _get_asset_content(asset)\n\n    response = HttpResponse(content, content_type=asset.content_type)\n    response[\"Content-Disposition\"] = f'inline; filename=\"{asset.download_name}\"'\n    if asset.content_type and asset.content_type.startswith(\"video/\"):\n        response[\"Content-Security-Policy\"] = \"default-src 'none'; media-src 'self';\"\n    elif asset.content_type == \"application/pdf\":\n        response[\"Content-Security-Policy\"] = \"default-src 'none'; object-src 'self';\"\n    else:\n        response[\"Content-Security-Policy\"] = \"sandbox allow-scripts\"\n    return response\n\n\ndef read(request, asset_id: int):\n    asset = access.asset_read(request, asset_id)\n    content = _get_asset_content(asset)\n    content = content.decode(\"utf-8\")\n\n    return render(\n        request,\n        \"bookmarks/read.html\",\n        {\n            \"content\": content,\n        },\n    )\n"
  },
  {
    "path": "bookmarks/views/auth.py",
    "content": "from django.conf import settings\nfrom django.contrib.auth import views as auth_views\n\nfrom bookmarks.widgets import FormErrorList\n\n\nclass LinkdingLoginView(auth_views.LoginView):\n    \"\"\"\n    Custom login view to lazily add additional context data\n    Allows to override settings in tests\n    \"\"\"\n\n    def get_form(self, form_class=None):\n        form = super().get_form(form_class)\n        form.error_class = FormErrorList\n        return form\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n\n        context[\"enable_oidc\"] = settings.LD_ENABLE_OIDC\n        context[\"disable_login\"] = settings.LD_DISABLE_LOGIN_FORM\n        return context\n\n    def form_invalid(self, form):\n        \"\"\"\n        Return 401 status code on failed login. Should allow integrating with\n        tools like Fail2Ban. Also, Hotwired Turbo requires a non 2xx status\n        code to handle failed form submissions.\n        \"\"\"\n        response = super().form_invalid(form)\n        response.status_code = 401\n        return response\n\n\nclass LinkdingPasswordChangeView(auth_views.PasswordChangeView):\n    def get_form(self, form_class=None):\n        form = super().get_form(form_class)\n        form.error_class = FormErrorList\n        return form\n\n    def form_invalid(self, form):\n        \"\"\"\n        Hotwired Turbo requires a non 2xx status code to handle failed form\n        submissions.\n        \"\"\"\n        response = super().form_invalid(form)\n        response.status_code = 422\n        return response\n"
  },
  {
    "path": "bookmarks/views/bookmarks.py",
    "content": "import urllib.parse\n\nfrom django.conf import settings\nfrom django.contrib.auth.decorators import login_required\nfrom django.db.models import QuerySet\nfrom django.http import (\n    HttpResponseBadRequest,\n    HttpResponseForbidden,\n    HttpResponseRedirect,\n)\nfrom django.shortcuts import render\nfrom django.urls import reverse\n\nfrom bookmarks import queries, utils\nfrom bookmarks.forms import BookmarkForm\nfrom bookmarks.models import (\n    Bookmark,\n    BookmarkSearch,\n)\nfrom bookmarks.services import assets as asset_actions\nfrom bookmarks.services import tasks\nfrom bookmarks.services.bookmarks import (\n    archive_bookmark,\n    archive_bookmarks,\n    create_html_snapshots,\n    delete_bookmarks,\n    mark_bookmarks_as_read,\n    mark_bookmarks_as_unread,\n    refresh_bookmarks_metadata,\n    share_bookmarks,\n    tag_bookmarks,\n    unarchive_bookmark,\n    unarchive_bookmarks,\n    unshare_bookmarks,\n    untag_bookmarks,\n)\nfrom bookmarks.type_defs import HttpRequest\nfrom bookmarks.utils import get_safe_return_url\nfrom bookmarks.views import access, contexts, turbo\n\n\n@login_required\ndef index(request: HttpRequest):\n    if request.method == \"POST\":\n        return search_action(request)\n\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    bookmark_list = contexts.ActiveBookmarkListContext(request, search)\n    bundles = contexts.BundlesContext(request)\n    tag_cloud = contexts.ActiveTagCloudContext(request, search)\n    bookmark_details = contexts.get_details_context(\n        request, contexts.ActiveBookmarkDetailsContext\n    )\n\n    return render_bookmarks_view(\n        request,\n        {\n            \"page_title\": \"Bookmarks - Linkding\",\n            \"bookmark_list\": bookmark_list,\n            \"bundles\": bundles,\n            \"tag_cloud\": tag_cloud,\n            \"details\": bookmark_details,\n        },\n    )\n\n\ndef index_update(request: HttpRequest):\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    bookmark_list = contexts.ActiveBookmarkListContext(request, search)\n    tag_cloud = contexts.ActiveTagCloudContext(request, search)\n    details = contexts.get_details_context(\n        request, contexts.ActiveBookmarkDetailsContext\n    )\n    return render_bookmarks_update(request, bookmark_list, tag_cloud, details)\n\n\n@login_required\ndef archived(request: HttpRequest):\n    if request.method == \"POST\":\n        return search_action(request)\n\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    bookmark_list = contexts.ArchivedBookmarkListContext(request, search)\n    bundles = contexts.BundlesContext(request)\n    tag_cloud = contexts.ArchivedTagCloudContext(request, search)\n    bookmark_details = contexts.get_details_context(\n        request, contexts.ArchivedBookmarkDetailsContext\n    )\n\n    return render_bookmarks_view(\n        request,\n        {\n            \"page_title\": \"Archived bookmarks - Linkding\",\n            \"bookmark_list\": bookmark_list,\n            \"bundles\": bundles,\n            \"tag_cloud\": tag_cloud,\n            \"details\": bookmark_details,\n        },\n    )\n\n\ndef archived_update(request: HttpRequest):\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    bookmark_list = contexts.ArchivedBookmarkListContext(request, search)\n    tag_cloud = contexts.ArchivedTagCloudContext(request, search)\n    details = contexts.get_details_context(\n        request, contexts.ArchivedBookmarkDetailsContext\n    )\n    return render_bookmarks_update(request, bookmark_list, tag_cloud, details)\n\n\ndef shared(request: HttpRequest):\n    if request.method == \"POST\":\n        return search_action(request)\n\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    bookmark_list = contexts.SharedBookmarkListContext(request, search)\n    tag_cloud = contexts.SharedTagCloudContext(request, search)\n    bookmark_details = contexts.get_details_context(\n        request, contexts.SharedBookmarkDetailsContext\n    )\n    user_list = contexts.UserListContext(request, search)\n    return render_bookmarks_view(\n        request,\n        {\n            \"page_title\": \"Shared bookmarks - Linkding\",\n            \"bookmark_list\": bookmark_list,\n            \"tag_cloud\": tag_cloud,\n            \"details\": bookmark_details,\n            \"user_list\": user_list,\n            \"rss_feed_url\": reverse(\"linkding:feeds.public_shared\"),\n        },\n    )\n\n\ndef shared_update(request: HttpRequest):\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    bookmark_list = contexts.SharedBookmarkListContext(request, search)\n    tag_cloud = contexts.SharedTagCloudContext(request, search)\n    details = contexts.get_details_context(\n        request, contexts.SharedBookmarkDetailsContext\n    )\n    return render_bookmarks_update(request, bookmark_list, tag_cloud, details)\n\n\ndef render_bookmarks_view(request: HttpRequest, context):\n    if context[\"details\"]:\n        context[\"page_title\"] = \"Bookmark details - Linkding\"\n\n    if turbo.is_frame(request, \"details-modal\"):\n        return turbo.frame(request, \"bookmarks/details/modal.html\", context)\n\n    return render(\n        request,\n        \"bookmarks/bookmark_page.html\",\n        context,\n    )\n\n\ndef render_bookmarks_update(request, bookmark_list, tag_cloud, details):\n    return turbo.stream(\n        turbo.update(\n            request,\n            \"bookmark-list-container\",\n            \"bookmarks/bookmark_list.html\",\n            {\"bookmark_list\": bookmark_list},\n        ),\n        turbo.update(\n            request,\n            \"tag-cloud-container\",\n            \"bookmarks/tag_cloud.html\",\n            {\"tag_cloud\": tag_cloud},\n        ),\n        turbo.replace(\n            request,\n            \"details-modal\",\n            \"bookmarks/details/modal.html\",\n            {\"details\": details},\n            method=\"morph\",\n        ),\n    )\n\n\ndef search_action(request: HttpRequest):\n    if \"save\" in request.POST:\n        if not request.user.is_authenticated:\n            return HttpResponseForbidden()\n        search = BookmarkSearch.from_request(request, request.POST)\n        request.user_profile.search_preferences = search.preferences_dict\n        request.user_profile.save()\n\n    # redirect to base url including new query params\n    search = BookmarkSearch.from_request(\n        request, request.POST, request.user_profile.search_preferences\n    )\n    base_url = request.path\n    query_params = search.query_params\n    query_string = urllib.parse.urlencode(query_params)\n    url = base_url if not query_string else base_url + \"?\" + query_string\n    return HttpResponseRedirect(url)\n\n\ndef convert_tag_string(tag_string: str):\n    # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated\n    # strings\n    return tag_string.replace(\" \", \",\")\n\n\n@login_required\ndef new(request: HttpRequest):\n    form = BookmarkForm(request)\n    if request.method == \"POST\" and form.is_valid():\n        form.save()\n        if form.is_auto_close:\n            return HttpResponseRedirect(reverse(\"linkding:bookmarks.close\"))\n        else:\n            return HttpResponseRedirect(reverse(\"linkding:bookmarks.index\"))\n\n    status = 422 if request.method == \"POST\" and not form.is_valid() else 200\n    context = {\"form\": form, \"return_url\": reverse(\"linkding:bookmarks.index\")}\n\n    return render(request, \"bookmarks/new.html\", context, status=status)\n\n\n@login_required\ndef edit(request: HttpRequest, bookmark_id: int):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    form = BookmarkForm(request, instance=bookmark)\n    return_url = get_safe_return_url(\n        request.GET.get(\"return_url\"), reverse(\"linkding:bookmarks.index\")\n    )\n\n    if request.method == \"POST\" and form.is_valid():\n        form.save()\n        return HttpResponseRedirect(return_url)\n\n    status = 422 if request.method == \"POST\" and not form.is_valid() else 200\n    context = {\"form\": form, \"bookmark_id\": bookmark_id, \"return_url\": return_url}\n\n    return render(request, \"bookmarks/edit.html\", context, status=status)\n\n\ndef remove(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    bookmark.delete()\n\n\ndef archive(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    archive_bookmark(bookmark)\n\n\ndef unarchive(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    unarchive_bookmark(bookmark)\n\n\ndef unshare(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    bookmark.shared = False\n    bookmark.save()\n\n\ndef mark_as_read(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    bookmark.unread = False\n    bookmark.save()\n\n\ndef create_html_snapshot(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    tasks.create_html_snapshot(bookmark)\n\n\ndef upload_asset(request: HttpRequest, bookmark_id: int | str):\n    if settings.LD_DISABLE_ASSET_UPLOAD:\n        return HttpResponseForbidden(\"Asset upload is disabled\")\n\n    bookmark = access.bookmark_write(request, bookmark_id)\n    file = request.FILES.get(\"upload_asset_file\")\n    if not file:\n        return HttpResponseBadRequest(\"No file provided\")\n\n    asset_actions.upload_asset(bookmark, file)\n\n\ndef remove_asset(request: HttpRequest, asset_id: int | str):\n    asset = access.asset_write(request, asset_id)\n    asset_actions.remove_asset(asset)\n\n\ndef update_state(request: HttpRequest, bookmark_id: int | str):\n    bookmark = access.bookmark_write(request, bookmark_id)\n    bookmark.is_archived = request.POST.get(\"is_archived\") == \"on\"\n    bookmark.unread = request.POST.get(\"unread\") == \"on\"\n    bookmark.shared = request.POST.get(\"shared\") == \"on\"\n    bookmark.save()\n\n\n@login_required\ndef index_action(request: HttpRequest):\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    query = queries.query_bookmarks(request.user, request.user_profile, search)\n\n    response = handle_action(request, query)\n    if response:\n        return response\n\n    if turbo.accept(request):\n        return index_update(request)\n\n    return utils.redirect_with_query(request, reverse(\"linkding:bookmarks.index\"))\n\n\n@login_required\ndef archived_action(request: HttpRequest):\n    search = BookmarkSearch.from_request(\n        request, request.GET, request.user_profile.search_preferences\n    )\n    query = queries.query_archived_bookmarks(request.user, request.user_profile, search)\n\n    response = handle_action(request, query)\n    if response:\n        return response\n\n    if turbo.accept(request):\n        return archived_update(request)\n\n    return utils.redirect_with_query(request, reverse(\"linkding:bookmarks.archived\"))\n\n\n@login_required\ndef shared_action(request: HttpRequest):\n    if \"bulk_execute\" in request.POST:\n        return HttpResponseBadRequest(\"View does not support bulk actions\")\n\n    response = handle_action(request)\n    if response:\n        return response\n\n    if turbo.accept(request):\n        return shared_update(request)\n\n    return utils.redirect_with_query(request, reverse(\"linkding:bookmarks.shared\"))\n\n\ndef handle_action(request: HttpRequest, query: QuerySet[Bookmark] = None):\n    # Single bookmark actions\n    if \"archive\" in request.POST:\n        return archive(request, request.POST[\"archive\"])\n    if \"unarchive\" in request.POST:\n        return unarchive(request, request.POST[\"unarchive\"])\n    if \"remove\" in request.POST:\n        return remove(request, request.POST[\"remove\"])\n    if \"mark_as_read\" in request.POST:\n        return mark_as_read(request, request.POST[\"mark_as_read\"])\n    if \"unshare\" in request.POST:\n        return unshare(request, request.POST[\"unshare\"])\n    if \"create_html_snapshot\" in request.POST:\n        return create_html_snapshot(request, request.POST[\"create_html_snapshot\"])\n    if \"upload_asset\" in request.POST:\n        return upload_asset(request, request.POST[\"upload_asset\"])\n    if \"remove_asset\" in request.POST:\n        return remove_asset(request, request.POST[\"remove_asset\"])\n\n    # State updates\n    if \"update_state\" in request.POST:\n        return update_state(request, request.POST[\"update_state\"])\n\n    # Bulk actions\n    if \"bulk_execute\" in request.POST:\n        if query is None:\n            raise ValueError(\"Query must be provided for bulk actions\")\n\n        bulk_action = request.POST[\"bulk_action\"]\n\n        # Determine set of bookmarks\n        if request.POST.get(\"bulk_select_across\") == \"on\":\n            # Query full list of bookmarks across all pages\n            bookmark_ids = query.only(\"id\").values_list(\"id\", flat=True)\n        else:\n            # Use only selected bookmarks\n            bookmark_ids = request.POST.getlist(\"bookmark_id\")\n\n        if bulk_action == \"bulk_archive\":\n            return archive_bookmarks(bookmark_ids, request.user)\n        if bulk_action == \"bulk_unarchive\":\n            return unarchive_bookmarks(bookmark_ids, request.user)\n        if bulk_action == \"bulk_delete\":\n            return delete_bookmarks(bookmark_ids, request.user)\n        if bulk_action == \"bulk_tag\":\n            tag_string = convert_tag_string(request.POST[\"bulk_tag_string\"])\n            return tag_bookmarks(bookmark_ids, tag_string, request.user)\n        if bulk_action == \"bulk_untag\":\n            tag_string = convert_tag_string(request.POST[\"bulk_tag_string\"])\n            return untag_bookmarks(bookmark_ids, tag_string, request.user)\n        if bulk_action == \"bulk_read\":\n            return mark_bookmarks_as_read(bookmark_ids, request.user)\n        if bulk_action == \"bulk_unread\":\n            return mark_bookmarks_as_unread(bookmark_ids, request.user)\n        if bulk_action == \"bulk_share\":\n            return share_bookmarks(bookmark_ids, request.user)\n        if bulk_action == \"bulk_unshare\":\n            return unshare_bookmarks(bookmark_ids, request.user)\n        if bulk_action == \"bulk_refresh\":\n            return refresh_bookmarks_metadata(bookmark_ids, request.user)\n        if bulk_action == \"bulk_snapshot\":\n            return create_html_snapshots(bookmark_ids, request.user)\n\n\n@login_required\ndef close(request: HttpRequest):\n    return render(request, \"bookmarks/close.html\")\n"
  },
  {
    "path": "bookmarks/views/bundles.py",
    "content": "from django.contrib import messages\nfrom django.contrib.auth.decorators import login_required\nfrom django.http import HttpRequest, HttpResponseRedirect\nfrom django.shortcuts import render\nfrom django.urls import reverse\n\nfrom bookmarks.forms import BookmarkBundleForm\nfrom bookmarks.models import BookmarkBundle, BookmarkSearch\nfrom bookmarks.queries import parse_query_string\nfrom bookmarks.services import bundles\nfrom bookmarks.views import access\nfrom bookmarks.views.contexts import ActiveBookmarkListContext\n\n\n@login_required\ndef index(request: HttpRequest):\n    bundles = BookmarkBundle.objects.filter(owner=request.user).order_by(\"order\")\n    context = {\"bundles\": bundles}\n    return render(request, \"bundles/index.html\", context)\n\n\n@login_required\ndef action(request: HttpRequest):\n    if \"remove_bundle\" in request.POST:\n        remove_bundle_id = request.POST.get(\"remove_bundle\")\n        bundle = access.bundle_write(request, remove_bundle_id)\n        bundle_name = bundle.name\n        bundles.delete_bundle(bundle)\n        messages.success(request, f\"Bundle '{bundle_name}' removed successfully.\")\n\n    elif \"move_bundle\" in request.POST:\n        bundle_id = request.POST.get(\"move_bundle\")\n        bundle_to_move = access.bundle_write(request, bundle_id)\n        move_position = int(request.POST.get(\"move_position\"))\n        bundles.move_bundle(bundle_to_move, move_position)\n\n    return HttpResponseRedirect(reverse(\"linkding:bundles.index\"))\n\n\ndef _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):\n    form_data = request.POST if request.method == \"POST\" else None\n    initial_data = {}\n    if bundle is None and request.method == \"GET\":\n        query_param = request.GET.get(\"q\")\n        if query_param:\n            parsed = parse_query_string(query_param)\n\n            if parsed[\"search_terms\"]:\n                initial_data[\"search\"] = \" \".join(parsed[\"search_terms\"])\n            if parsed[\"tag_names\"]:\n                initial_data[\"all_tags\"] = \" \".join(parsed[\"tag_names\"])\n\n    form = BookmarkBundleForm(form_data, instance=bundle, initial=initial_data)\n\n    if request.method == \"POST\" and form.is_valid():\n        instance = form.save(commit=False)\n\n        if bundle is None:\n            instance.order = None\n            bundles.create_bundle(instance, request.user)\n        else:\n            instance.save()\n\n        messages.success(request, \"Bundle saved successfully.\")\n        return HttpResponseRedirect(reverse(\"linkding:bundles.index\"))\n\n    status = 422 if request.method == \"POST\" and not form.is_valid() else 200\n    bookmark_list = _get_bookmark_list_preview(request, bundle, initial_data)\n    context = {\n        \"form\": form,\n        \"bundle\": bundle,\n        \"bookmark_list\": bookmark_list,\n    }\n\n    return render(request, template, context, status=status)\n\n\n@login_required\ndef new(request: HttpRequest):\n    return _handle_edit(request, \"bundles/new.html\")\n\n\n@login_required\ndef edit(request: HttpRequest, bundle_id: int):\n    bundle = access.bundle_write(request, bundle_id)\n\n    return _handle_edit(request, \"bundles/edit.html\", bundle)\n\n\n@login_required\ndef preview(request: HttpRequest):\n    bookmark_list = _get_bookmark_list_preview(request)\n    context = {\"bookmark_list\": bookmark_list}\n    return render(request, \"bundles/preview.html\", context)\n\n\ndef _get_bookmark_list_preview(\n    request: HttpRequest,\n    bundle: BookmarkBundle | None = None,\n    initial_data: dict = None,\n):\n    if request.method == \"GET\" and bundle:\n        preview_bundle = bundle\n    else:\n        form_data = (\n            request.POST.copy() if request.method == \"POST\" else request.GET.copy()\n        )\n        if initial_data:\n            for key, value in initial_data.items():\n                form_data[key] = value\n\n        form_data[\"name\"] = \"Preview Bundle\"  # Set dummy name for form validation\n        form = BookmarkBundleForm(form_data)\n        preview_bundle = form.save(commit=False)\n\n    search = BookmarkSearch(bundle=preview_bundle)\n    bookmark_list = ActiveBookmarkListContext(request, search)\n    bookmark_list.is_preview = True\n    return bookmark_list\n"
  },
  {
    "path": "bookmarks/views/contexts.py",
    "content": "import re\nimport urllib.parse\n\nfrom django.conf import settings\nfrom django.core.paginator import Paginator\nfrom django.db import models\nfrom django.http import Http404\nfrom django.urls import reverse\n\nfrom bookmarks import queries, utils\nfrom bookmarks.forms import BookmarkSearchForm\nfrom bookmarks.models import (\n    Bookmark,\n    BookmarkAsset,\n    BookmarkBundle,\n    BookmarkSearch,\n    Tag,\n    User,\n    UserProfile,\n)\nfrom bookmarks.services.search_query_parser import (\n    OrExpression,\n    SearchQueryParseError,\n    parse_search_query,\n    strip_tag_from_query,\n)\nfrom bookmarks.services.wayback import generate_fallback_webarchive_url\nfrom bookmarks.type_defs import HttpRequest\nfrom bookmarks.views import access\n\nCJK_RE = re.compile(r\"[\\u4e00-\\u9fff]+\")\n\n\nclass RequestContext:\n    index_view = \"linkding:bookmarks.index\"\n    action_view = \"linkding:bookmarks.index.action\"\n\n    def __init__(self, request: HttpRequest):\n        self.request = request\n        self.index_url = reverse(self.index_view)\n        self.action_url = reverse(self.action_view)\n        self.query_params = request.GET.copy()\n        self.query_params.pop(\"details\", None)\n\n        self.query_is_valid = True\n        self.query_error_message = None\n        self.search_expression = None\n        if not request.user_profile.legacy_search:\n            try:\n                self.search_expression = parse_search_query(request.GET.get(\"q\"))\n            except SearchQueryParseError as e:\n                self.query_is_valid = False\n                self.query_error_message = e.message\n\n    def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:\n        query_params = self.query_params.copy()\n        if add:\n            query_params.update(add)\n        if remove:\n            for key in remove:\n                query_params.pop(key, None)\n        encoded_params = query_params.urlencode()\n        return view_url + \"?\" + encoded_params if encoded_params else view_url\n\n    def index(self, add: dict = None, remove: dict = None) -> str:\n        return self.get_url(self.index_url, add=add, remove=remove)\n\n    def action(self, add: dict = None, remove: dict = None) -> str:\n        return self.get_url(self.action_url, add=add, remove=remove)\n\n    def details(self, bookmark_id: int) -> str:\n        return self.get_url(self.index_url, add={\"details\": bookmark_id})\n\n    def get_bookmark_query_set(self, search: BookmarkSearch):\n        raise NotImplementedError(\"Must be implemented by subclass\")\n\n    def get_tag_query_set(self, search: BookmarkSearch):\n        raise NotImplementedError(\"Must be implemented by subclass\")\n\n\nclass ActiveBookmarksContext(RequestContext):\n    index_view = \"linkding:bookmarks.index\"\n    action_view = \"linkding:bookmarks.index.action\"\n\n    def get_bookmark_query_set(self, search: BookmarkSearch):\n        return queries.query_bookmarks(\n            self.request.user, self.request.user_profile, search\n        )\n\n    def get_tag_query_set(self, search: BookmarkSearch):\n        return queries.query_bookmark_tags(\n            self.request.user, self.request.user_profile, search\n        )\n\n\nclass ArchivedBookmarksContext(RequestContext):\n    index_view = \"linkding:bookmarks.archived\"\n    action_view = \"linkding:bookmarks.archived.action\"\n\n    def get_bookmark_query_set(self, search: BookmarkSearch):\n        return queries.query_archived_bookmarks(\n            self.request.user, self.request.user_profile, search\n        )\n\n    def get_tag_query_set(self, search: BookmarkSearch):\n        return queries.query_archived_bookmark_tags(\n            self.request.user, self.request.user_profile, search\n        )\n\n\nclass SharedBookmarksContext(RequestContext):\n    index_view = \"linkding:bookmarks.shared\"\n    action_view = \"linkding:bookmarks.shared.action\"\n\n    def get_bookmark_query_set(self, search: BookmarkSearch):\n        user = User.objects.filter(username=search.user).first()\n        public_only = not self.request.user.is_authenticated\n        return queries.query_shared_bookmarks(\n            user, self.request.user_profile, search, public_only\n        )\n\n    def get_tag_query_set(self, search: BookmarkSearch):\n        user = User.objects.filter(username=search.user).first()\n        public_only = not self.request.user.is_authenticated\n        return queries.query_shared_bookmark_tags(\n            user, self.request.user_profile, search, public_only\n        )\n\n\nclass BookmarkItem:\n    def __init__(\n        self,\n        context: RequestContext,\n        bookmark: Bookmark,\n        user: User,\n        profile: UserProfile,\n    ) -> None:\n        self.bookmark = bookmark\n\n        is_editable = bookmark.owner == user\n        self.is_editable = is_editable\n\n        self.id = bookmark.id\n        self.url = bookmark.url\n        self.title = bookmark.resolved_title\n        self.description = bookmark.resolved_description\n        self.notes = bookmark.notes\n        self.tag_names = bookmark.tag_names\n        self.tags = [AddTagItem(context, tag) for tag in bookmark.tags.all()]\n        self.tags.sort(key=lambda item: item.name)\n        if bookmark.latest_snapshot_id:\n            self.snapshot_url = reverse(\n                \"linkding:assets.view\", args=[bookmark.latest_snapshot_id]\n            )\n            self.snapshot_title = \"View latest snapshot\"\n        else:\n            self.snapshot_url = bookmark.web_archive_snapshot_url\n            self.snapshot_title = (\n                \"View snapshot on the Internet Archive Wayback Machine\"\n            )\n            if not self.snapshot_url:\n                self.snapshot_url = generate_fallback_webarchive_url(\n                    bookmark.url, bookmark.date_added\n                )\n        self.favicon_file = bookmark.favicon_file\n        self.preview_image_file = bookmark.preview_image_file\n        self.is_archived = bookmark.is_archived\n        self.unread = bookmark.unread\n        self.owner = bookmark.owner\n        self.details_url = context.details(bookmark.id)\n\n        css_classes = []\n        if bookmark.unread:\n            css_classes.append(\"unread\")\n        if bookmark.shared:\n            css_classes.append(\"shared\")\n\n        self.css_classes = \" \".join(css_classes)\n\n        if profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE:\n            self.display_date = utils.humanize_relative_date(bookmark.date_added)\n        elif (\n            profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE\n        ):\n            self.display_date = utils.humanize_absolute_date(bookmark.date_added)\n\n        self.show_notes_button = bookmark.notes and not profile.permanent_notes\n        self.show_mark_as_read = is_editable and bookmark.unread\n        self.show_unshare = is_editable and bookmark.shared and profile.enable_sharing\n\n        self.has_extra_actions = (\n            self.show_notes_button or self.show_mark_as_read or self.show_unshare\n        )\n\n\nclass BookmarkListContext:\n    request_context = RequestContext\n\n    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:\n        request_context = self.request_context(request)\n        user = request.user\n        user_profile = request.user_profile\n\n        self.request = request\n        self.search = search\n        self.query_is_valid = request_context.query_is_valid\n        self.query_error_message = request_context.query_error_message\n\n        query_set = request_context.get_bookmark_query_set(self.search)\n        page_number = request.GET.get(\"page\")\n        paginator = Paginator(query_set, user_profile.items_per_page)\n        bookmarks_page = paginator.get_page(page_number)\n        # Prefetch related objects, this avoids n+1 queries when accessing fields in templates\n        models.prefetch_related_objects(bookmarks_page.object_list, \"owner\", \"tags\")\n\n        self.items = [\n            BookmarkItem(request_context, bookmark, user, user_profile)\n            for bookmark in bookmarks_page\n        ]\n        self.is_empty = paginator.count == 0\n        self.bookmarks_page = bookmarks_page\n        self.bookmarks_total = paginator.count\n\n        self.return_url = request_context.index()\n        self.action_url = request_context.action()\n\n        self.link_target = user_profile.bookmark_link_target\n        self.date_display = user_profile.bookmark_date_display\n        self.description_display = user_profile.bookmark_description_display\n        self.description_max_lines = user_profile.bookmark_description_max_lines\n        self.show_url = user_profile.display_url\n        self.show_view_action = user_profile.display_view_bookmark_action\n        self.show_edit_action = user_profile.display_edit_bookmark_action\n        self.show_archive_action = user_profile.display_archive_bookmark_action\n        self.show_remove_action = user_profile.display_remove_bookmark_action\n        self.show_favicons = user_profile.enable_favicons\n        self.show_preview_images = user_profile.enable_preview_images\n        self.show_notes = user_profile.permanent_notes\n        self.collapse_side_panel = user_profile.collapse_side_panel\n        self.is_preview = False\n        self.snapshot_feature_enabled = settings.LD_ENABLE_SNAPSHOTS\n\n    @staticmethod\n    def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):\n        query_params = search.query_params\n        if page is not None:\n            query_params[\"page\"] = page\n        query_string = urllib.parse.urlencode(query_params)\n\n        return base_url if query_string == \"\" else base_url + \"?\" + query_string\n\n    @staticmethod\n    def generate_action_url(\n        search: BookmarkSearch, base_action_url: str, return_url: str\n    ):\n        query_params = search.query_params\n        query_params[\"return_url\"] = return_url\n        query_string = urllib.parse.urlencode(query_params)\n\n        return (\n            base_action_url\n            if query_string == \"\"\n            else base_action_url + \"?\" + query_string\n        )\n\n\nclass ActiveBookmarkListContext(BookmarkListContext):\n    list_title = \"Bookmarks\"\n    search_mode = \"\"\n    bulk_edit_enabled = True\n    bulk_edit_disabled_actions = \"bulk_unarchive\"\n    request_context = ActiveBookmarksContext\n\n\nclass ArchivedBookmarkListContext(BookmarkListContext):\n    list_title = \"Archived bookmarks\"\n    search_mode = \"archived\"\n    bulk_edit_enabled = True\n    bulk_edit_disabled_actions = \"bulk_archive\"\n    request_context = ArchivedBookmarksContext\n\n\nclass SharedBookmarkListContext(BookmarkListContext):\n    list_title = \"Shared bookmarks\"\n    search_mode = \"shared\"\n    bulk_edit_enabled = False\n    bulk_edit_disabled_actions = \"\"\n    request_context = SharedBookmarksContext\n\n\nclass AddTagItem:\n    def __init__(self, context: RequestContext, tag: Tag):\n        self.tag = tag\n        self.name = tag.name\n\n        params = context.query_params.copy()\n        query_with_tag = params.get(\"q\", \"\")\n        if isinstance(context.search_expression, OrExpression):\n            # If the current search expression is an OR expression, wrap in parentheses\n            query_with_tag = f\"({query_with_tag})\"\n        query_with_tag = f\"{query_with_tag} #{tag.name}\".strip()\n\n        params[\"q\"] = query_with_tag\n        params.pop(\"details\", None)\n        params.pop(\"page\", None)\n\n        if context.request.user_profile.legacy_search:\n            self.query_string = self._generate_query_string_legacy(context, tag)\n        else:\n            self.query_string = self._generate_query_string(context, tag)\n\n    @staticmethod\n    def _generate_query_string(context: RequestContext, tag: Tag) -> str:\n        params = context.query_params.copy()\n        query_with_tag = params.get(\"q\", \"\")\n        if isinstance(context.search_expression, OrExpression):\n            # If the current search expression is an OR expression, wrap in parentheses\n            query_with_tag = f\"({query_with_tag})\"\n        query_with_tag = f\"{query_with_tag} #{tag.name}\".strip()\n\n        params[\"q\"] = query_with_tag\n        params.pop(\"details\", None)\n        params.pop(\"page\", None)\n\n        return params.urlencode()\n\n    @staticmethod\n    def _generate_query_string_legacy(context: RequestContext, tag: Tag) -> str:\n        params = context.query_params.copy()\n        query_with_tag = params.get(\"q\", \"\")\n        query_with_tag = f\"{query_with_tag} #{tag.name}\".strip()\n\n        params[\"q\"] = query_with_tag\n        params.pop(\"details\", None)\n        params.pop(\"page\", None)\n\n        return params.urlencode()\n\n\nclass RemoveTagItem:\n    def __init__(self, context: RequestContext, tag: Tag):\n        self.tag = tag\n        self.name = tag.name\n\n        if context.request.user_profile.legacy_search:\n            self.query_string = self._generate_query_string_legacy(context, tag)\n        else:\n            self.query_string = self._generate_query_string(context, tag)\n\n    @staticmethod\n    def _generate_query_string(context: RequestContext, tag: Tag) -> str:\n        params = context.query_params.copy()\n        query = params.get(\"q\", \"\")\n        profile = context.request.user_profile\n        query_without_tag = strip_tag_from_query(query, tag.name, profile)\n\n        params[\"q\"] = query_without_tag\n        params.pop(\"details\", None)\n        params.pop(\"page\", None)\n\n        return params.urlencode()\n\n    @staticmethod\n    def _generate_query_string_legacy(context: RequestContext, tag: Tag) -> str:\n        params = context.request.GET.copy()\n        if params.__contains__(\"q\"):\n            # Split query string into parts\n            query_string = params.__getitem__(\"q\")\n            query_parts = query_string.split()\n            # Remove tag with hash\n            tag_name_with_hash = \"#\" + tag.name\n            query_parts = [\n                part\n                for part in query_parts\n                if str.lower(part) != str.lower(tag_name_with_hash)\n            ]\n            # When using lax tag search, also remove tag without hash\n            profile = context.request.user_profile\n            if profile.tag_search == UserProfile.TAG_SEARCH_LAX:\n                query_parts = [\n                    part\n                    for part in query_parts\n                    if str.lower(part) != str.lower(tag.name)\n                ]\n            # Rebuild query string\n            query_string = \" \".join(query_parts)\n            params.__setitem__(\"q\", query_string)\n\n        # Remove details ID and page number\n        params.pop(\"details\", None)\n        params.pop(\"page\", None)\n\n        return params.urlencode()\n\n\nclass TagGroup:\n    def __init__(\n        self, context: RequestContext, char: str, highlight_first_char: bool = True\n    ):\n        self.context = context\n        self.tags = []\n        self.char = char\n        self.highlight_first_char = highlight_first_char\n\n    def __repr__(self):\n        return f\"<{self.char} TagGroup>\"\n\n    def add_tag(self, tag: Tag):\n        self.tags.append(AddTagItem(self.context, tag))\n\n    @staticmethod\n    def create_tag_groups(context: RequestContext, mode: str, tags: set[Tag]):\n        if mode == UserProfile.TAG_GROUPING_ALPHABETICAL:\n            return TagGroup._create_tag_groups_alphabetical(context, tags)\n        elif mode == UserProfile.TAG_GROUPING_DISABLED:\n            return TagGroup._create_tag_groups_disabled(context, tags)\n        else:\n            raise ValueError(f\"{mode} is not a valid tag grouping mode\")\n\n    @staticmethod\n    def _create_tag_groups_alphabetical(context: RequestContext, tags: set[Tag]):\n        # Ensure groups, as well as tags within groups, are ordered alphabetically\n        sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))\n        group = None\n        groups = []\n\n        cjk_used = False\n        cjk_group = TagGroup(context, \"Ideographic\")\n\n        # Group tags that start with a different character than the previous one\n        for tag in sorted_tags:\n            tag_char = tag.name[0].lower()\n            if CJK_RE.match(tag_char):\n                cjk_used = True\n                cjk_group.add_tag(tag)\n            elif not group or group.char != tag_char:\n                group = TagGroup(context, tag_char)\n                groups.append(group)\n                group.add_tag(tag)\n            else:\n                group.add_tag(tag)\n\n        if cjk_used:\n            groups.append(cjk_group)\n        return groups\n\n    @staticmethod\n    def _create_tag_groups_disabled(context: RequestContext, tags: set[Tag]):\n        if len(tags) == 0:\n            return []\n\n        sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))\n        group = TagGroup(context, \"Ungrouped\", highlight_first_char=False)\n        for tag in sorted_tags:\n            group.add_tag(tag)\n\n        return [group]\n\n\nclass TagCloudContext:\n    request_context = RequestContext\n\n    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:\n        request_context = self.request_context(request)\n        user_profile = request.user_profile\n\n        self.request = request\n        self.search = search\n\n        query_set = request_context.get_tag_query_set(self.search)\n        tags = list(query_set)\n        selected_tags = self.get_selected_tags()\n        unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))\n        unique_selected_tags = utils.unique(\n            selected_tags, key=lambda x: str.lower(x.name)\n        )\n        has_selected_tags = len(unique_selected_tags) > 0\n        unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)\n        groups = TagGroup.create_tag_groups(\n            request_context, user_profile.tag_grouping, unselected_tags\n        )\n\n        selected_tag_items = []\n        for tag in unique_selected_tags:\n            selected_tag_items.append(RemoveTagItem(request_context, tag))\n\n        self.tags = unique_tags\n        self.groups = groups\n        self.selected_tags = selected_tag_items\n        self.has_selected_tags = has_selected_tags\n\n    def get_selected_tags(self):\n        raise NotImplementedError(\"Must be implemented by subclass\")\n\n    def get_selected_tags_legacy(self, tags: list[Tag]):\n        parsed_query = queries.parse_query_string(self.search.q)\n        tag_names = parsed_query[\"tag_names\"]\n        if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:\n            tag_names = tag_names + parsed_query[\"search_terms\"]\n        tag_names = [tag_name.lower() for tag_name in tag_names]\n\n        return [tag for tag in tags if tag.name.lower() in tag_names]\n\n\nclass ActiveTagCloudContext(TagCloudContext):\n    request_context = ActiveBookmarksContext\n\n    def get_selected_tags(self):\n        return list(\n            queries.get_tags_for_query(\n                self.request.user, self.request.user_profile, self.search.q\n            )\n        )\n\n\nclass ArchivedTagCloudContext(TagCloudContext):\n    request_context = ArchivedBookmarksContext\n\n    def get_selected_tags(self):\n        return list(\n            queries.get_tags_for_query(\n                self.request.user, self.request.user_profile, self.search.q\n            )\n        )\n\n\nclass SharedTagCloudContext(TagCloudContext):\n    request_context = SharedBookmarksContext\n\n    def get_selected_tags(self):\n        user = User.objects.filter(username=self.search.user).first()\n        public_only = not self.request.user.is_authenticated\n        return list(\n            queries.get_shared_tags_for_query(\n                user, self.request.user_profile, self.search.q, public_only\n            )\n        )\n\n\nclass BookmarkAssetItem:\n    def __init__(self, asset: BookmarkAsset):\n        self.asset = asset\n\n        self.id = asset.id\n        self.display_name = asset.display_name\n        self.asset_type = asset.asset_type\n        self.file = asset.file\n        self.file_size = asset.file_size\n        self.content_type = asset.content_type\n        self.status = asset.status\n\n        icon_classes = []\n        text_classes = []\n        if asset.status == BookmarkAsset.STATUS_PENDING:\n            icon_classes.append(\"text-tertiary\")\n            text_classes.append(\"text-tertiary\")\n        elif asset.status == BookmarkAsset.STATUS_FAILURE:\n            icon_classes.append(\"text-error\")\n            text_classes.append(\"text-error\")\n        else:\n            icon_classes.append(\"icon-color\")\n\n        self.icon_classes = \" \".join(icon_classes)\n        self.text_classes = \" \".join(text_classes)\n\n\nclass BookmarkDetailsContext:\n    request_context = RequestContext\n\n    def __init__(self, request: HttpRequest, bookmark: Bookmark):\n        request_context = self.request_context(request)\n\n        user = request.user\n        user_profile = request.user_profile\n\n        self.edit_return_url = request_context.details(bookmark.id)\n        self.action_url = request_context.action(add={\"details\": bookmark.id})\n        self.delete_url = request_context.action()\n        self.close_url = request_context.index()\n\n        self.bookmark = bookmark\n        self.tags = [AddTagItem(request_context, tag) for tag in bookmark.tags.all()]\n        self.tags.sort(key=lambda item: item.name)\n\n        self.profile = request.user_profile\n        self.is_editable = bookmark.owner == user\n        self.sharing_enabled = user_profile.enable_sharing\n        self.preview_image_enabled = user_profile.enable_preview_images\n        self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file\n        self.snapshots_enabled = settings.LD_ENABLE_SNAPSHOTS\n        self.uploads_enabled = not settings.LD_DISABLE_ASSET_UPLOAD\n\n        self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url\n        if not self.web_archive_snapshot_url:\n            self.web_archive_snapshot_url = generate_fallback_webarchive_url(\n                bookmark.url, bookmark.date_added\n            )\n\n        self.assets = [\n            BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()\n        ]\n        self.has_pending_assets = any(\n            asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets\n        )\n        self.latest_snapshot = next(\n            (\n                asset\n                for asset in self.assets\n                if asset.asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT\n                and asset.status == BookmarkAsset.STATUS_COMPLETE\n            ),\n            None,\n        )\n\n\nclass ActiveBookmarkDetailsContext(BookmarkDetailsContext):\n    request_context = ActiveBookmarksContext\n\n\nclass ArchivedBookmarkDetailsContext(BookmarkDetailsContext):\n    request_context = ArchivedBookmarksContext\n\n\nclass SharedBookmarkDetailsContext(BookmarkDetailsContext):\n    request_context = SharedBookmarksContext\n\n\ndef get_details_context(\n    request: HttpRequest, context_type\n) -> BookmarkDetailsContext | None:\n    bookmark_id = request.GET.get(\"details\")\n    if not bookmark_id:\n        return None\n\n    try:\n        bookmark = access.bookmark_read(request, bookmark_id)\n    except Http404:\n        # just ignore, might end up in a situation where the bookmark was deleted\n        # in between navigating back and forth\n        return None\n\n    return context_type(request, bookmark)\n\n\nclass BundlesContext:\n    def __init__(self, request: HttpRequest) -> None:\n        self.request = request\n        self.user = request.user\n        self.user_profile = request.user_profile\n\n        self.bundles = (\n            BookmarkBundle.objects.filter(owner=self.user).order_by(\"order\").all()\n        )\n        self.is_empty = len(self.bundles) == 0\n\n        selected_bundle_id = (\n            int(request.GET.get(\"bundle\")) if request.GET.get(\"bundle\") else None\n        )\n        self.selected_bundle = next(\n            (bundle for bundle in self.bundles if bundle.id == selected_bundle_id),\n            None,\n        )\n\n\nclass UserListContext:\n    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:\n        public_only = not request.user.is_authenticated\n        users = queries.query_shared_bookmark_users(\n            request.user_profile, search, public_only\n        )\n        users = sorted(users, key=lambda x: str.lower(x.username))\n        self.form = BookmarkSearchForm(search, editable_fields=[\"user\"], users=users)\n"
  },
  {
    "path": "bookmarks/views/custom_css.py",
    "content": "from django.http import HttpResponse\n\ncustom_css_cache_max_age = 2592000  # 30 days\n\n\ndef custom_css(request):\n    css = request.user_profile.custom_css\n    response = HttpResponse(css, content_type=\"text/css\")\n    response[\"Cache-Control\"] = f\"public, max-age={custom_css_cache_max_age}\"\n    return response\n"
  },
  {
    "path": "bookmarks/views/health.py",
    "content": "from django.db import connections\nfrom django.http import JsonResponse\n\nfrom bookmarks.views.settings import app_version\n\n\ndef health(request):\n    code = 200\n    response = {\"version\": app_version, \"status\": \"healthy\"}\n\n    try:\n        connections[\"default\"].ensure_connection()\n    except Exception:\n        response[\"status\"] = \"unhealthy\"\n        code = 500\n\n    return JsonResponse(response, status=code)\n"
  },
  {
    "path": "bookmarks/views/manifest.py",
    "content": "from django.conf import settings\nfrom django.http import JsonResponse\n\n\ndef manifest(request):\n    response = {\n        \"short_name\": \"linkding\",\n        \"name\": \"linkding\",\n        \"description\": \"Self-hosted bookmark service\",\n        \"start_url\": \"bookmarks\",\n        \"display\": \"standalone\",\n        \"scope\": \"/\" + settings.LD_CONTEXT_PATH,\n        \"theme_color\": \"#5856e0\",\n        \"background_color\": (\n            \"#161822\" if request.user_profile.theme == \"dark\" else \"#ffffff\"\n        ),\n        \"icons\": [\n            {\n                \"src\": \"/\" + settings.LD_CONTEXT_PATH + \"static/logo.svg\",\n                \"type\": \"image/svg+xml\",\n                \"sizes\": \"512x512\",\n                \"purpose\": \"any\",\n            },\n            {\n                \"src\": \"/\" + settings.LD_CONTEXT_PATH + \"static/logo-512.png\",\n                \"type\": \"image/png\",\n                \"sizes\": \"512x512\",\n                \"purpose\": \"any\",\n            },\n            {\n                \"src\": \"/\" + settings.LD_CONTEXT_PATH + \"static/logo-192.png\",\n                \"type\": \"image/png\",\n                \"sizes\": \"192x192\",\n                \"purpose\": \"any\",\n            },\n            {\n                \"src\": \"/\" + settings.LD_CONTEXT_PATH + \"static/maskable-logo.svg\",\n                \"type\": \"image/svg+xml\",\n                \"sizes\": \"512x512\",\n                \"purpose\": \"maskable\",\n            },\n            {\n                \"src\": \"/\" + settings.LD_CONTEXT_PATH + \"static/maskable-logo-512.png\",\n                \"type\": \"image/png\",\n                \"sizes\": \"512x512\",\n                \"purpose\": \"maskable\",\n            },\n            {\n                \"src\": \"/\" + settings.LD_CONTEXT_PATH + \"static/maskable-logo-192.png\",\n                \"type\": \"image/png\",\n                \"sizes\": \"192x192\",\n                \"purpose\": \"maskable\",\n            },\n        ],\n        \"shortcuts\": [\n            {\n                \"name\": \"Add bookmark\",\n                \"url\": \"/\" + settings.LD_CONTEXT_PATH + \"bookmarks/new\",\n            },\n            {\n                \"name\": \"Archived\",\n                \"url\": \"/\" + settings.LD_CONTEXT_PATH + \"bookmarks/archived\",\n            },\n            {\n                \"name\": \"Unread\",\n                \"url\": \"/\" + settings.LD_CONTEXT_PATH + \"bookmarks?unread=yes\",\n            },\n            {\n                \"name\": \"Untagged\",\n                \"url\": \"/\" + settings.LD_CONTEXT_PATH + \"bookmarks?q=!untagged\",\n            },\n            {\n                \"name\": \"Shared\",\n                \"url\": \"/\" + settings.LD_CONTEXT_PATH + \"bookmarks/shared\",\n            },\n        ],\n        \"screenshots\": [\n            {\n                \"src\": \"/\"\n                + settings.LD_CONTEXT_PATH\n                + \"static/linkding-screenshot.png\",\n                \"type\": \"image/png\",\n                \"sizes\": \"2158x1160\",\n                \"form_factor\": \"wide\",\n            }\n        ],\n        \"share_target\": {\n            \"action\": \"/\" + settings.LD_CONTEXT_PATH + \"bookmarks/new\",\n            \"method\": \"GET\",\n            \"enctype\": \"application/x-www-form-urlencoded\",\n            \"params\": {\n                \"url\": \"url\",\n                \"text\": \"url\",\n                \"title\": \"title\",\n            },\n        },\n    }\n\n    return JsonResponse(response, status=200)\n"
  },
  {
    "path": "bookmarks/views/opensearch.py",
    "content": "from django.shortcuts import render\nfrom django.urls import reverse\n\n\ndef opensearch(request):\n    base_url = request.build_absolute_uri(reverse(\"linkding:root\"))\n    bookmarks_url = request.build_absolute_uri(reverse(\"linkding:bookmarks.index\"))\n\n    return render(\n        request,\n        \"opensearch.xml\",\n        {\n            \"base_url\": base_url,\n            \"bookmarks_url\": bookmarks_url,\n        },\n        content_type=\"application/opensearchdescription+xml\",\n        status=200,\n    )\n"
  },
  {
    "path": "bookmarks/views/reload.py",
    "content": "import json\nimport threading\nimport uuid\nfrom pathlib import Path\nfrom queue import Empty, Queue\n\nfrom django.dispatch import receiver\nfrom django.http import StreamingHttpResponse\nfrom django.utils.autoreload import autoreload_started, file_changed\n\n_styles_dir = Path(__file__).resolve().parent.parent / \"styles\"\n_static_dir = Path(__file__).resolve().parent.parent / \"static\"\n\n_server_id = str(uuid.uuid4())\n\n_active_connections = set()\n_connections_lock = threading.Lock()\n\n\ndef _event_stream():\n    client_queue = Queue()\n\n    with _connections_lock:\n        _active_connections.add(client_queue)\n\n    try:\n        data = json.dumps({\"server_id\": _server_id})\n        yield f\"event: connected\\ndata: {data}\\n\\n\"\n\n        while True:\n            try:\n                data = client_queue.get(timeout=30)\n                yield f\"event: file_change\\ndata: {data}\\n\\n\"\n            except Empty:\n                yield \": keepalive\\n\\n\"\n    finally:\n        with _connections_lock:\n            _active_connections.discard(client_queue)\n\n\ndef live_reload(request):\n    response = StreamingHttpResponse(_event_stream(), content_type=\"text/event-stream\")\n    response[\"Cache-Control\"] = \"no-cache\"\n    return response\n\n\n@receiver(autoreload_started)\ndef handle_auto_reload(sender, **kwargs):\n    sender.watch_dir(_styles_dir, \"**/*.css\")\n    sender.watch_dir(_static_dir, \"bundle.js\")\n\n\n@receiver(file_changed)\ndef handle_file_changed(sender, file_path, **kwargs):\n    print(f\"File changed: {file_path}\")\n    data = json.dumps({\"file_path\": str(file_path)})\n    with _connections_lock:\n        for queue in _active_connections:\n            queue.put(data)\n\n    # Return True for CSS/JS files to prevent Django server restart\n    if file_path.suffix in (\".css\", \".js\"):\n        return True\n"
  },
  {
    "path": "bookmarks/views/root.py",
    "content": "from django.http import HttpResponseRedirect\nfrom django.urls import reverse\n\nfrom bookmarks.models import GlobalSettings\n\n\ndef root(request):\n    # Redirect unauthenticated users to the configured landing page\n    if not request.user.is_authenticated:\n        settings = request.global_settings\n\n        if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS:\n            return HttpResponseRedirect(reverse(\"linkding:bookmarks.shared\"))\n        else:\n            return HttpResponseRedirect(reverse(\"login\"))\n\n    # Redirect authenticated users to the bookmarks page\n    return HttpResponseRedirect(reverse(\"linkding:bookmarks.index\"))\n"
  },
  {
    "path": "bookmarks/views/settings.py",
    "content": "import logging\nimport time\nfrom functools import lru_cache\n\nimport requests\nfrom django.conf import settings as django_settings\nfrom django.contrib import messages\nfrom django.contrib.auth.decorators import login_required\nfrom django.core.exceptions import PermissionDenied\nfrom django.db.models import prefetch_related_objects\nfrom django.http import HttpResponse, HttpResponseRedirect\nfrom django.shortcuts import render\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom bookmarks.forms import GlobalSettingsForm, UserProfileForm\nfrom bookmarks.models import (\n    ApiToken,\n    Bookmark,\n    FeedToken,\n    GlobalSettings,\n)\nfrom bookmarks.services import exporter, importer, tasks\nfrom bookmarks.type_defs import HttpRequest\nfrom bookmarks.utils import app_version\nfrom bookmarks.views import access\n\nlogger = logging.getLogger(__name__)\n\n\n@login_required\ndef general(request: HttpRequest, status=200, context_overrides=None):\n    enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS\n    has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS\n    success_message = _find_message_with_tag(\n        messages.get_messages(request), \"settings_success_message\"\n    )\n    error_message = _find_message_with_tag(\n        messages.get_messages(request), \"settings_error_message\"\n    )\n    version_info = get_version_info(get_ttl_hash())\n\n    profile_form = UserProfileForm(instance=request.user_profile)\n    global_settings_form = None\n    if request.user.is_superuser:\n        global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get())\n\n    if context_overrides is None:\n        context_overrides = {}\n\n    return render(\n        request,\n        \"settings/general.html\",\n        {\n            \"form\": profile_form,\n            \"global_settings_form\": global_settings_form,\n            \"enable_refresh_favicons\": enable_refresh_favicons,\n            \"has_snapshot_support\": has_snapshot_support,\n            \"success_message\": success_message,\n            \"error_message\": error_message,\n            \"version_info\": version_info,\n            **context_overrides,\n        },\n        status=status,\n    )\n\n\n@login_required\ndef update(request: HttpRequest):\n    if request.method == \"POST\":\n        if \"update_profile\" in request.POST:\n            return update_profile(request)\n        if \"update_global_settings\" in request.POST:\n            update_global_settings(request)\n            messages.success(\n                request, \"Global settings updated\", \"settings_success_message\"\n            )\n        if \"refresh_favicons\" in request.POST:\n            tasks.schedule_refresh_favicons(request.user)\n            messages.success(\n                request,\n                \"Scheduled favicon update. This may take a while...\",\n                \"settings_success_message\",\n            )\n        if \"create_missing_html_snapshots\" in request.POST:\n            count = tasks.create_missing_html_snapshots(request.user)\n            if count > 0:\n                messages.success(\n                    request,\n                    f\"Queued {count} missing snapshots. This may take a while...\",\n                    \"settings_success_message\",\n                )\n            else:\n                messages.success(\n                    request, \"No missing snapshots found.\", \"settings_success_message\"\n                )\n\n    return HttpResponseRedirect(reverse(\"linkding:settings.general\"))\n\n\ndef update_profile(request: HttpRequest):\n    user = request.user\n    profile = user.profile\n    favicons_were_enabled = profile.enable_favicons\n    previews_were_enabled = profile.enable_preview_images\n    form = UserProfileForm(request.POST, instance=profile)\n    if form.is_valid():\n        form.save()\n        messages.success(request, \"Profile updated\", \"settings_success_message\")\n        # Load missing favicons if the feature was just enabled\n        if profile.enable_favicons and not favicons_were_enabled:\n            tasks.schedule_bookmarks_without_favicons(request.user)\n        # Load missing preview images if the feature was just enabled\n        if profile.enable_preview_images and not previews_were_enabled:\n            tasks.schedule_bookmarks_without_previews(request.user)\n\n        return HttpResponseRedirect(reverse(\"linkding:settings.general\"))\n\n    messages.error(\n        request,\n        \"Profile update failed, check the form below for errors\",\n        \"settings_error_message\",\n    )\n    return general(request, 422, {\"form\": form})\n\n\ndef update_global_settings(request):\n    user = request.user\n    if not user.is_superuser:\n        raise PermissionDenied()\n\n    form = GlobalSettingsForm(request.POST, instance=GlobalSettings.get())\n    if form.is_valid():\n        form.save()\n    return form\n\n\n# Cache API call response, for one hour when using get_ttl_hash with default params\n@lru_cache(maxsize=1)\ndef get_version_info(ttl_hash=None):\n    latest_version = None\n    try:\n        latest_version_url = (\n            \"https://api.github.com/repos/sissbruecker/linkding/releases/latest\"\n        )\n        response = requests.get(latest_version_url, timeout=5)\n        json = response.json()\n        if response.status_code == 200 and \"name\" in json:\n            latest_version = json[\"name\"][1:]\n    except requests.exceptions.RequestException:\n        pass\n\n    latest_version_info = \"\"\n    if latest_version == app_version:\n        latest_version_info = \" (latest)\"\n    elif latest_version is not None:\n        latest_version_info = f\" (latest: {latest_version})\"\n\n    return f\"{app_version}{latest_version_info}\"\n\n\ndef get_ttl_hash(seconds=3600):\n    \"\"\"Return the same value within `seconds` time period\"\"\"\n    return round(time.time() / seconds)\n\n\n@login_required\ndef integrations(request):\n    application_url = request.build_absolute_uri(reverse(\"linkding:bookmarks.new\"))\n\n    api_tokens = ApiToken.objects.filter(user=request.user).order_by(\"-created\")\n    api_token_key = request.session.pop(\"api_token_key\", None)\n    api_token_name = request.session.pop(\"api_token_name\", None)\n    api_success_message = _find_message_with_tag(\n        messages.get_messages(request), \"api_success_message\"\n    )\n\n    feed_token = FeedToken.objects.get_or_create(user=request.user)[0]\n\n    all_feed_url = reverse(\"linkding:feeds.all\", args=[feed_token.key])\n    unread_feed_url = reverse(\"linkding:feeds.unread\", args=[feed_token.key])\n    shared_feed_url = reverse(\"linkding:feeds.shared\", args=[feed_token.key])\n    public_shared_feed_url = reverse(\"linkding:feeds.public_shared\")\n\n    return render(\n        request,\n        \"settings/integrations.html\",\n        {\n            \"application_url\": application_url,\n            \"api_tokens\": api_tokens,\n            \"api_token_key\": api_token_key,\n            \"api_token_name\": api_token_name,\n            \"api_success_message\": api_success_message,\n            \"all_feed_url\": all_feed_url,\n            \"unread_feed_url\": unread_feed_url,\n            \"shared_feed_url\": shared_feed_url,\n            \"public_shared_feed_url\": public_shared_feed_url,\n        },\n    )\n\n\n@login_required\ndef create_api_token(request):\n    if request.method == \"POST\":\n        name = request.POST.get(\"name\", \"\").strip()\n        if not name:\n            name = \"API Token\"\n\n        token = ApiToken(user=request.user, name=name)\n        token.save()\n\n        request.session[\"api_token_key\"] = token.key\n        request.session[\"api_token_name\"] = token.name\n\n        messages.success(\n            request,\n            f'API token \"{token.name}\" created successfully',\n            \"api_success_message\",\n        )\n\n        return HttpResponseRedirect(reverse(\"linkding:settings.integrations\"))\n\n    return render(request, \"settings/create_api_token_modal.html\")\n\n\n@login_required\ndef delete_api_token(request):\n    if request.method == \"POST\":\n        token_id = request.POST.get(\"token_id\")\n        token = access.api_token_write(request, token_id)\n        token_name = token.name\n        token.delete()\n        messages.success(\n            request,\n            f'API token \"{token_name}\" has been deleted.',\n            \"api_success_message\",\n        )\n\n    return HttpResponseRedirect(reverse(\"linkding:settings.integrations\"))\n\n\n@login_required\ndef bookmark_import(request: HttpRequest):\n    import_file = request.FILES.get(\"import_file\")\n    import_options = importer.ImportOptions(\n        map_private_flag=request.POST.get(\"map_private_flag\") == \"on\"\n    )\n\n    if import_file is None:\n        messages.error(\n            request, \"Please select a file to import.\", \"settings_error_message\"\n        )\n        return HttpResponseRedirect(reverse(\"linkding:settings.general\"))\n\n    try:\n        content = import_file.read().decode()\n        result = importer.import_netscape_html(content, request.user, import_options)\n        success_msg = str(result.success) + \" bookmarks were successfully imported.\"\n        messages.success(request, success_msg, \"settings_success_message\")\n        if result.failed > 0:\n            err_msg = (\n                str(result.failed)\n                + \" bookmarks could not be imported. Please check the logs for more details.\"\n            )\n            messages.error(request, err_msg, \"settings_error_message\")\n    except Exception:\n        logging.exception(\"Unexpected error during bookmark import\")\n        messages.error(\n            request,\n            \"An error occurred during bookmark import.\",\n            \"settings_error_message\",\n        )\n\n    return HttpResponseRedirect(reverse(\"linkding:settings.general\"))\n\n\n@login_required\ndef bookmark_export(request: HttpRequest):\n    # noinspection PyBroadException\n    try:\n        bookmarks = Bookmark.objects.filter(owner=request.user)\n        # Prefetch tags to prevent n+1 queries\n        prefetch_related_objects(bookmarks, \"tags\")\n        file_content = exporter.export_netscape_html(list(bookmarks))\n\n        # Generate filename with current date and time\n        current_time = timezone.now()\n        filename = current_time.strftime(\"bookmarks_%Y-%m-%d_%H-%M-%S.html\")\n\n        response = HttpResponse(content_type=\"text/plain; charset=UTF-8\")\n        response[\"Content-Disposition\"] = f'attachment; filename=\"{filename}\"'\n        response.write(file_content)\n\n        return response\n    except Exception:\n        return general(\n            request,\n            context_overrides={\n                \"export_error\": \"An error occurred during bookmark export.\"\n            },\n        )\n\n\ndef _find_message_with_tag(messages, tag):\n    for message in messages:\n        if message.extra_tags == tag:\n            return message\n"
  },
  {
    "path": "bookmarks/views/tags.py",
    "content": "from django.contrib import messages\nfrom django.contrib.auth.decorators import login_required\nfrom django.core.paginator import Paginator\nfrom django.db import transaction\nfrom django.db.models import Count\nfrom django.http import HttpResponseRedirect\nfrom django.shortcuts import get_object_or_404, render\nfrom django.urls import reverse\n\nfrom bookmarks.forms import TagForm, TagMergeForm\nfrom bookmarks.models import Bookmark, Tag\nfrom bookmarks.type_defs import HttpRequest\nfrom bookmarks.utils import redirect_with_query\nfrom bookmarks.views import turbo\n\n\n@login_required\ndef tags_index(request: HttpRequest):\n    if request.method == \"POST\" and \"delete_tag\" in request.POST:\n        tag_id = request.POST.get(\"delete_tag\")\n        tag = get_object_or_404(Tag, id=tag_id, owner=request.user)\n        tag.delete()\n        return redirect_with_query(request, reverse(\"linkding:tags.index\"))\n\n    search = request.GET.get(\"search\", \"\").strip()\n    unused_only = request.GET.get(\"unused\", \"\") == \"true\"\n    sort = request.GET.get(\"sort\", \"name-asc\")\n\n    tags_queryset = Tag.objects.filter(owner=request.user).annotate(\n        bookmark_count=Count(\"bookmark\")\n    )\n\n    if sort == \"name-desc\":\n        tags_queryset = tags_queryset.order_by(\"-name\")\n    elif sort == \"count-asc\":\n        tags_queryset = tags_queryset.order_by(\"bookmark_count\", \"name\")\n    elif sort == \"count-desc\":\n        tags_queryset = tags_queryset.order_by(\"-bookmark_count\", \"name\")\n    else:  # Default: name-asc\n        tags_queryset = tags_queryset.order_by(\"name\")\n    total_tags = tags_queryset.count()\n\n    if search:\n        tags_queryset = tags_queryset.filter(name__icontains=search)\n\n    if unused_only:\n        tags_queryset = tags_queryset.filter(bookmark_count=0)\n\n    paginator = Paginator(tags_queryset, 50)\n    page_number = request.GET.get(\"page\")\n    page = paginator.get_page(page_number)\n\n    context = {\n        \"page\": page,\n        \"search\": search,\n        \"unused_only\": unused_only,\n        \"sort\": sort,\n        \"total_tags\": total_tags,\n    }\n\n    return render(request, \"tags/index.html\", context)\n\n\n@login_required\ndef tag_new(request: HttpRequest):\n    form_data = request.POST if request.method == \"POST\" else None\n    form = TagForm(user=request.user, data=form_data)\n\n    if request.method == \"POST\":\n        if form.is_valid():\n            tag = form.save()\n            messages.success(request, f'Tag \"{tag.name}\" created successfully.')\n            return HttpResponseRedirect(reverse(\"linkding:tags.index\"))\n        else:\n            return turbo.stream(\n                turbo.replace(\n                    request,\n                    \"tag-modal\",\n                    \"tags/new.html\",\n                    {\"form\": form},\n                    method=\"morph\",\n                )\n            )\n\n    return render(request, \"tags/new.html\", {\"form\": form})\n\n\n@login_required\ndef tag_edit(request: HttpRequest, tag_id: int):\n    tag = get_object_or_404(Tag, id=tag_id, owner=request.user)\n    form_data = request.POST if request.method == \"POST\" else None\n    form = TagForm(user=request.user, data=form_data, instance=tag)\n\n    if request.method == \"POST\":\n        if form.is_valid():\n            form.save()\n            return redirect_with_query(request, reverse(\"linkding:tags.index\"))\n        else:\n            return turbo.stream(\n                turbo.replace(\n                    request,\n                    \"tag-modal\",\n                    \"tags/edit.html\",\n                    {\"tag\": tag, \"form\": form},\n                    method=\"morph\",\n                )\n            )\n\n    return render(request, \"tags/edit.html\", {\"tag\": tag, \"form\": form})\n\n\n@login_required\ndef tag_merge(request: HttpRequest):\n    form_data = request.POST if request.method == \"POST\" else None\n    form = TagMergeForm(user=request.user, data=form_data)\n\n    if request.method == \"POST\":\n        if form.is_valid():\n            target_tag = form.cleaned_data[\"target_tag\"]\n            merge_tags = form.cleaned_data[\"merge_tags\"]\n\n            with transaction.atomic():\n                BookmarkTag = Bookmark.tags.through\n\n                # Get all bookmarks that have any of the merge tags, but do not\n                # already have the target tag\n                bookmark_ids = list(\n                    Bookmark.objects.filter(tags__in=merge_tags)\n                    .exclude(tags=target_tag)\n                    .values_list(\"id\", flat=True)\n                    .distinct()\n                )\n\n                # Create new relationships to the target tag\n                new_relationships = [\n                    BookmarkTag(tag_id=target_tag.id, bookmark_id=bookmark_id)\n                    for bookmark_id in bookmark_ids\n                ]\n\n                if new_relationships:\n                    BookmarkTag.objects.bulk_create(new_relationships)\n\n                # Bulk delete all relationships for merge tags\n                merge_tag_ids = [tag.id for tag in merge_tags]\n                BookmarkTag.objects.filter(tag_id__in=merge_tag_ids).delete()\n\n                # Delete the merged tags\n                tag_names = [tag.name for tag in merge_tags]\n                Tag.objects.filter(id__in=merge_tag_ids).delete()\n\n                messages.success(\n                    request,\n                    f'Successfully merged {len(merge_tags)} tags ({\", \".join(tag_names)}) into \"{target_tag.name}\".',\n                )\n\n            return HttpResponseRedirect(reverse(\"linkding:tags.index\"))\n        else:\n            return turbo.stream(\n                turbo.replace(\n                    request,\n                    \"tag-modal\",\n                    \"tags/merge.html\",\n                    {\"form\": form},\n                    method=\"morph\",\n                )\n            )\n\n    return render(request, \"tags/merge.html\", {\"form\": form})\n"
  },
  {
    "path": "bookmarks/views/toasts.py",
    "content": "from django.contrib.auth.decorators import login_required\nfrom django.http import HttpResponseRedirect\nfrom django.urls import reverse\n\nfrom bookmarks.utils import get_safe_return_url\nfrom bookmarks.views import access\n\n\n@login_required\ndef acknowledge(request):\n    toast = access.toast_write(request, request.POST[\"toast\"])\n    toast.acknowledged = True\n    toast.save()\n\n    return_url = get_safe_return_url(\n        request.GET.get(\"return_url\"), reverse(\"linkding:bookmarks.index\")\n    )\n    return HttpResponseRedirect(return_url)\n"
  },
  {
    "path": "bookmarks/views/turbo.py",
    "content": "from django.http import HttpRequest, HttpResponse\nfrom django.template import loader\n\n\ndef accept(request: HttpRequest):\n    is_turbo_request = \"text/vnd.turbo-stream.html\" in request.headers.get(\"Accept\", \"\")\n    disable_turbo = request.POST.get(\"disable_turbo\", \"false\") == \"true\"\n\n    return is_turbo_request and not disable_turbo\n\n\ndef is_frame(request: HttpRequest, frame: str) -> bool:\n    return request.headers.get(\"Turbo-Frame\") == frame\n\n\ndef frame(request: HttpRequest, template_name: str, context: dict) -> HttpResponse:\n    \"\"\"\n    Renders the specified template into an HTML skeleton including <head> with\n    respective metadata. The template should only contain a frame. Used for\n    Turbo Frame requests that modify the top frame's URL.\n    \"\"\"\n    html = loader.render_to_string(\"shared/top_frame.html\", context, request)\n    content = loader.render_to_string(template_name, context, request)\n    html = html.replace(\"<!--content-->\", content)\n    response = HttpResponse(html, status=200)\n    return response\n\n\ndef update(\n    request: HttpRequest,\n    target: str,\n    template_name: str,\n    context: dict,\n    method: str | None = \"\",\n) -> str:\n    \"\"\"Render a template wrapped in an update turbo-stream element.\"\"\"\n    content = loader.render_to_string(template_name, context, request)\n    method_attr = f' method=\"{method}\"' if method else \"\"\n    return f'<turbo-stream action=\"update\"{method_attr} target=\"{target}\"><template>{content}</template></turbo-stream>'\n\n\ndef replace(\n    request: HttpRequest,\n    target: str,\n    template_name: str,\n    context: dict,\n    method: str | None = \"\",\n) -> str:\n    \"\"\"Render a template wrapped in a replace turbo-stream element.\"\"\"\n    content = loader.render_to_string(template_name, context, request)\n    method_attr = f' method=\"{method}\"' if method else \"\"\n    return f'<turbo-stream action=\"replace\"{method_attr} target=\"{target}\"><template>{content}</template></turbo-stream>'\n\n\ndef stream(*streams: str) -> HttpResponse:\n    \"\"\"Combine multiple stream elements into a turbo-stream response.\"\"\"\n    return HttpResponse(\n        \"\\n\".join(streams),\n        status=200,\n        content_type=\"text/vnd.turbo-stream.html\",\n    )\n"
  },
  {
    "path": "bookmarks/widgets.py",
    "content": "from django import forms\nfrom django.forms.utils import ErrorList\nfrom django.utils.html import escape, format_html\nfrom django.utils.safestring import mark_safe\n\n\nclass FormErrorList(ErrorList):\n    template_name = \"shared/error_list.html\"\n\n\nclass FormInput(forms.TextInput):\n    def __init__(self, attrs=None):\n        default_attrs = {\"class\": \"form-input\", \"autocomplete\": \"off\"}\n        if attrs:\n            default_attrs.update(attrs)\n        super().__init__(default_attrs)\n\n\nclass FormNumberInput(forms.NumberInput):\n    def __init__(self, attrs=None):\n        default_attrs = {\"class\": \"form-input\"}\n        if attrs:\n            default_attrs.update(attrs)\n        super().__init__(default_attrs)\n\n\nclass FormSelect(forms.Select):\n    def __init__(self, attrs=None):\n        default_attrs = {\"class\": \"form-select\"}\n        if attrs:\n            default_attrs.update(attrs)\n        super().__init__(default_attrs)\n\n\nclass FormTextarea(forms.Textarea):\n    def __init__(self, attrs=None):\n        default_attrs = {\"class\": \"form-input\"}\n        if attrs:\n            default_attrs.update(attrs)\n        super().__init__(default_attrs)\n\n\nclass FormCheckbox(forms.CheckboxInput):\n    def __init__(self, attrs=None):\n        super().__init__(attrs)\n        self.label = \"Label\"\n\n    def render(self, name, value, attrs=None, renderer=None):\n        checkbox_html = super().render(name, value, attrs, renderer)\n        input_id = attrs.get(\"id\") if attrs else None\n        return format_html(\n            '<div class=\"form-checkbox\">'\n            \"{}\"\n            '<i class=\"form-icon\"></i>'\n            '<label for=\"{}\">{}</label>'\n            \"</div>\",\n            checkbox_html,\n            input_id,\n            self.label,\n        )\n\n\nclass TagAutocomplete(forms.TextInput):\n    def __init__(self, attrs=None):\n        super().__init__(attrs)\n\n    def render(self, name, value, attrs=None, renderer=None):\n        # Merge self.attrs with passed attrs\n        final_attrs = self.build_attrs(self.attrs, attrs)\n\n        input_id = final_attrs.get(\"id\")\n        input_value = escape(value) if value else \"\"\n        aria_describedby = final_attrs.get(\"aria-describedby\")\n        input_class = final_attrs.get(\"class\")\n\n        html = f'<ld-tag-autocomplete input-id=\"{input_id}\" input-name=\"{name}\" input-value=\"{input_value}\"'\n        if aria_describedby:\n            html += f' input-aria-describedby=\"{aria_describedby}\"'\n        if input_class:\n            html += f' input-class=\"{input_class}\"'\n        html += \"></ld-tag-autocomplete>\"\n\n        return mark_safe(html)\n"
  },
  {
    "path": "bookmarks/wsgi.py",
    "content": "\"\"\"\nWSGI config for linkding.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\"\"\"\n\nimport os\n\nfrom django.core.wsgi import get_wsgi_application\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"bookmarks.settings\")\n\napplication = get_wsgi_application()\n"
  },
  {
    "path": "bootstrap.sh",
    "content": "#!/usr/bin/env bash\n# Bootstrap script that gets executed in new Docker containers\n\nLD_SERVER_HOST=\"${LD_SERVER_HOST:-[::]}\"\nLD_SERVER_PORT=\"${LD_SERVER_PORT:-9090}\"\n\n# Create data folder if it does not exist\nmkdir -p data\n# Create favicon folder if it does not exist\nmkdir -p data/favicons\n# Create previews folder if it does not exist\nmkdir -p data/previews\n# Create assets folder if it does not exist\nmkdir -p data/assets\n\n# Generate secret key file if it does not exist\npython manage.py generate_secret_key\n# Run database migration\npython manage.py migrate\n# Enable WAL journal mode for SQLite databases\npython manage.py enable_wal\n# Create initial superuser if defined in options / environment variables\npython manage.py create_initial_superuser\n# Migrate legacy background tasks to Huey\npython manage.py migrate_tasks\n\n# Ensure folders are owned by the right user\nchown -R www-data: /etc/linkding/data\n\n# Start processes\n# Experimental: use supervisor to manage all processes, enables logging background tasks to stdout/stderr\nif [ \"$LD_SUPERVISOR_MANAGED\" = \"True\" ]; then\n  exec supervisord -c supervisord-all.conf\n# Default: start background task processor as daemon, then uwsgi as main process\nelse\n  if [ \"$LD_DISABLE_BACKGROUND_TASKS\" != \"True\" ]; then\n    supervisord -c supervisord-tasks.conf\n  fi\n  exec uwsgi --http $LD_SERVER_HOST:$LD_SERVER_PORT uwsgi.ini\nfi\n"
  },
  {
    "path": "docker/alpine.Dockerfile",
    "content": "FROM node:22-alpine AS node-build\nWORKDIR /etc/linkding\n# install build dependencies\nCOPY rollup.config.mjs postcss.config.js package.json package-lock.json ./\nRUN npm ci\n# copy files needed for JS build\nCOPY bookmarks/frontend ./bookmarks/frontend\nCOPY bookmarks/styles ./bookmarks/styles\n# run build\nRUN npm run build\n\n\nFROM python:3.13.7-alpine3.22 AS build-deps\n# Add required packages\n# alpine-sdk linux-headers pkgconfig: build Python packages from source\n# libpq-dev: build Postgres client from source\n# icu-dev sqlite-dev: build Sqlite ICU extension\n# libffi-dev openssl-dev rust cargo: build Python cryptography from source\nRUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo\nWORKDIR /etc/linkding\n# install uv, use installer script for now as distroless images are not availabe for armv7\nADD https://astral.sh/uv/0.8.13/install.sh /uv-installer.sh\nRUN chmod +x /uv-installer.sh && /uv-installer.sh\n# install python dependencies\nCOPY pyproject.toml uv.lock ./\nRUN /root/.local/bin/uv sync --no-dev --group postgres\n\n\nFROM build-deps AS compile-icu\n# Defines SQLite version\n# Since this is only needed for downloading the header files this probably\n# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU\n# extension do not change\nARG SQLITE_RELEASE_YEAR=2023\nARG SQLITE_RELEASE=3430000\n\n# Compile the ICU extension needed for case-insensitive search and ordering\n# with SQLite. This does:\n# - Download SQLite amalgamation for header files\n# - Download ICU extension source file\n# - Compile ICU extension\nRUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQLITE_RELEASE}.zip && \\\n    unzip sqlite-amalgamation-${SQLITE_RELEASE}.zip && \\\n    cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3.h ./sqlite3.h && \\\n    cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3ext.h ./sqlite3ext.h && \\\n    wget https://www.sqlite.org/src/raw/ext/icu/icu.c?name=91c021c7e3e8bbba286960810fa303295c622e323567b2e6def4ce58e4466e60 -O icu.c && \\\n    gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so\n\n\nFROM python:3.13.7-alpine3.22 AS linkding\nLABEL org.opencontainers.image.source=\"https://github.com/sissbruecker/linkding\"\n# install runtime dependencies\nRUN apk update && apk add bash curl icu libpq mailcap libssl3\n# create www-data user and group\nRUN set -x ; \\\n  addgroup -g 82 -S www-data ; \\\n  adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1\nWORKDIR /etc/linkding\n# copy python dependencies\nCOPY --from=build-deps /etc/linkding/.venv /etc/linkding/.venv\n# copy output from node build\nCOPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/\n# copy compiled icu extension\nCOPY --from=compile-icu /etc/linkding/libicu.so libicu.so\n# copy application code\nCOPY . .\n# Activate virtual env\nENV VIRTUAL_ENV=/etc/linkding/.venv\nENV PATH=\"/etc/linkding/.venv/bin:$PATH\"\n# Generate static files, remove source styles that are not needed\nRUN mkdir data && \\\n    python manage.py collectstatic\n\n# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453\nENV UWSGI_MAX_FD=4096\n# Expose uwsgi server at port 9090\nEXPOSE 9090\n# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman\nRUN chmod g+w . && \\\n    chmod +x ./bootstrap.sh\n\nHEALTHCHECK --interval=30s --retries=3 --timeout=1s \\\nCMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1\n\nCMD [\"./bootstrap.sh\"]\n\n\nFROM node:22-alpine AS ublock-build\nWORKDIR /etc/linkding\n# Install necessary tools\n# Download and unzip the latest uBlock Origin Lite release\n# Patch manifest to enable annoyances by default\nRUN apk add --no-cache curl jq unzip && \\\n    TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \\\n    DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/uBOLite_$TAG.chromium.zip && \\\n    echo \"Downloading $DOWNLOAD_URL\" && \\\n    curl -L -o uBOLite.zip $DOWNLOAD_URL && \\\n    unzip uBOLite.zip -d uBOLite.chromium.mv3 && \\\n    rm uBOLite.zip && \\\n    jq '.declarative_net_request.rule_resources |= map(if .id == \"annoyances-overlays\" or .id == \"annoyances-cookies\" or .id == \"annoyances-social\" or .id == \"annoyances-widgets\" or .id == \"annoyances-others\" then .enabled = true else . end)' \\\n        uBOLite.chromium.mv3/manifest.json > temp.json && \\\n    mv temp.json uBOLite.chromium.mv3/manifest.json\n\n\nFROM linkding AS linkding-plus\n# install node, chromium\nRUN apk update && apk add nodejs npm chromium-swiftshader\n# install single-file-cli\nRUN npm install -g single-file-cli@2.0.75\n# copy uBlock\nCOPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/\n# create chromium profile folder for user running background tasks and set permissions\nRUN mkdir -p chromium-profile &&  \\\n    chown -R www-data:www-data chromium-profile &&  \\\n    chown -R www-data:www-data uBOLite.chromium.mv3\n# enable snapshot support\nENV LD_ENABLE_SNAPSHOTS=True\n"
  },
  {
    "path": "docker/default.Dockerfile",
    "content": "FROM node:22-alpine AS node-build\nWORKDIR /etc/linkding\n# install build dependencies\nCOPY rollup.config.mjs postcss.config.js package.json package-lock.json ./\nRUN npm ci\n# copy files needed for JS build\nCOPY bookmarks/frontend ./bookmarks/frontend\nCOPY bookmarks/styles ./bookmarks/styles\n# run build\nRUN npm run build\n\n\nFROM python:3.13.7-slim-trixie AS build-deps\n# Add required packages\n# build-essential pkg-config: build Python packages from source\n# libpq-dev: build Postgres client from source\n# libicu-dev libsqlite3-dev: build Sqlite ICU extension\nRUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev libicu-dev libsqlite3-dev libffi-dev wget unzip\nWORKDIR /etc/linkding\n# install uv, use installer script for now as distroless images are not availabe for armv7\nADD https://astral.sh/uv/0.8.13/install.sh /uv-installer.sh\nRUN chmod +x /uv-installer.sh && /uv-installer.sh\n# install python dependencies\nCOPY pyproject.toml uv.lock ./\nRUN /root/.local/bin/uv sync --no-dev --group postgres\n\n\nFROM build-deps AS compile-icu\n# Defines SQLite version\n# Since this is only needed for downloading the header files this probably\n# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU\n# extension do not change\nARG SQLITE_RELEASE_YEAR=2023\nARG SQLITE_RELEASE=3430000\n\n# Compile the ICU extension needed for case-insensitive search and ordering\n# with SQLite. This does:\n# - Download SQLite amalgamation for header files\n# - Download ICU extension source file\n# - Compile ICU extension\nRUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQLITE_RELEASE}.zip && \\\n    unzip sqlite-amalgamation-${SQLITE_RELEASE}.zip && \\\n    cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3.h ./sqlite3.h && \\\n    cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3ext.h ./sqlite3ext.h && \\\n    wget https://www.sqlite.org/src/raw/ext/icu/icu.c?name=91c021c7e3e8bbba286960810fa303295c622e323567b2e6def4ce58e4466e60 -O icu.c && \\\n    gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so\n\n\nFROM python:3.13.7-slim-trixie AS linkding\nLABEL org.opencontainers.image.source=\"https://github.com/sissbruecker/linkding\"\n# install runtime dependencies\nRUN apt-get update && apt-get -y install media-types libpq-dev libicu-dev libssl3t64 curl\nWORKDIR /etc/linkding\n# copy python dependencies\nCOPY --from=build-deps /etc/linkding/.venv /etc/linkding/.venv\n# copy output from node build\nCOPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/\n# copy compiled icu extension\nCOPY --from=compile-icu /etc/linkding/libicu.so libicu.so\n# copy application code\nCOPY . .\n# Activate virtual env\nENV VIRTUAL_ENV=/etc/linkding/.venv\nENV PATH=\"/etc/linkding/.venv/bin:$PATH\"\n# Generate static files\nRUN mkdir data && \\\n    python manage.py collectstatic\n\n# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453\nENV UWSGI_MAX_FD=4096\n# Expose uwsgi server at port 9090\nEXPOSE 9090\n# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman\nRUN chmod g+w . && \\\n    chmod +x ./bootstrap.sh\n\nHEALTHCHECK --interval=30s --retries=3 --timeout=1s \\\nCMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1\n\nCMD [\"./bootstrap.sh\"]\n\n\nFROM node:22-alpine AS ublock-build\nWORKDIR /etc/linkding\n# Install necessary tools\n# Download and unzip the latest uBlock Origin Lite release\n# Patch manifest to enable annoyances by default\nRUN apk add --no-cache curl jq unzip && \\\n    TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \\\n    DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/uBOLite_$TAG.chromium.zip && \\\n    echo \"Downloading $DOWNLOAD_URL\" && \\\n    curl -L -o uBOLite.zip $DOWNLOAD_URL && \\\n    unzip uBOLite.zip -d uBOLite.chromium.mv3 && \\\n    rm uBOLite.zip && \\\n    jq '.declarative_net_request.rule_resources |= map(if .id == \"annoyances-overlays\" or .id == \"annoyances-cookies\" or .id == \"annoyances-social\" or .id == \"annoyances-widgets\" or .id == \"annoyances-others\" then .enabled = true else . end)' \\\n        uBOLite.chromium.mv3/manifest.json > temp.json && \\\n    mv temp.json uBOLite.chromium.mv3/manifest.json\n\n\nFROM linkding AS linkding-plus\n# install chromium\nRUN apt-get update && apt-get -y install chromium\n# install node\nENV NODE_MAJOR=22\nRUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \\\n    curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg && \\\n    echo \"deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main\" | tee /etc/apt/sources.list.d/nodesource.list && \\\n    apt-get update && apt-get install -y nodejs\n# install single-file-cli\nRUN npm install -g single-file-cli@2.0.75\n# copy uBlock\nCOPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/\n# create chromium profile folder for user running background tasks and set permissions\nRUN mkdir -p chromium-profile &&  \\\n    chown -R www-data:www-data chromium-profile &&  \\\n    chown -R www-data:www-data uBOLite.chromium.mv3\n# enable snapshot support\nENV LD_ENABLE_SNAPSHOTS=True\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  linkding:\n    container_name: \"${LD_CONTAINER_NAME:-linkding}\"\n    image: sissbruecker/linkding:latest\n    ports:\n      - \"${LD_HOST_PORT:-9090}:9090\"\n    volumes:\n      - \"${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data\"\n    env_file:\n      - .env\n    restart: unless-stopped"
  },
  {
    "path": "docs/.gitignore",
    "content": "# build output\ndist/\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n\n# environment variables\n.env\n.env.production\n\n# macOS-specific files\n.DS_Store\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Starlight Starter Kit: Basics\n\n[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)\n\n```\nnpm create astro@latest -- --template starlight\n```\n\n[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)\n[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)\n[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)\n\n> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!\n\n## 🚀 Project Structure\n\nInside of your Astro + Starlight project, you'll see the following folders and files:\n\n```\n.\n├── public/\n├── src/\n│   ├── assets/\n│   ├── content/\n│   │   ├── docs/\n│   │   └── config.ts\n│   └── env.d.ts\n├── astro.config.mjs\n├── package.json\n└── tsconfig.json\n```\n\nStarlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.\n\nImages can be added to `src/assets/` and embedded in Markdown with a relative link.\n\nStatic assets, like favicons, can be placed in the `public/` directory.\n\n## 🧞 Commands\n\nAll commands are run from the root of the project, from a terminal:\n\n| Command                   | Action                                           |\n| :------------------------ | :----------------------------------------------- |\n| `npm install`             | Installs dependencies                            |\n| `npm run dev`             | Starts local dev server at `localhost:4321`      |\n| `npm run build`           | Build your production site to `./dist/`          |\n| `npm run preview`         | Preview your build locally, before deploying     |\n| `npm run astro ...`       | Run CLI commands like `astro add`, `astro check` |\n| `npm run astro -- --help` | Get help using the Astro CLI                     |\n\n## 👀 Want to learn more?\n\nCheck out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).\n"
  },
  {
    "path": "docs/astro.config.mjs",
    "content": "// @ts-check\nimport { defineConfig } from \"astro/config\";\nimport starlight from \"@astrojs/starlight\";\n\n// https://astro.build/config\nexport default defineConfig({\n  integrations: [\n    starlight({\n      title: \"linkding\",\n      logo: {\n        src: \"./src/assets/logo.svg\",\n      },\n      social: [\n        {\n          icon: \"github\",\n          label: \"GitHub\",\n          href: \"https://github.com/sissbruecker/linkding\",\n        },\n      ],\n      sidebar: [\n        {\n          label: \"Getting Started\",\n          items: [\n            { label: \"Installation\", slug: \"installation\" },\n            { label: \"Options\", slug: \"options\" },\n            { label: \"Managed Hosting\", slug: \"managed-hosting\" },\n            { label: \"Browser Extension\", slug: \"browser-extension\" },\n          ],\n        },\n        {\n          label: \"Guides\",\n          items: [\n            { label: \"Backups\", slug: \"backups\" },\n            { label: \"Search\", slug: \"search\" },\n            { label: \"Archiving\", slug: \"archiving\" },\n            { label: \"Auto Tagging\", slug: \"auto-tagging\" },\n            { label: \"Keyboard Shortcuts\", slug: \"shortcuts\" },\n            { label: \"How To\", slug: \"how-to\" },\n            { label: \"Troubleshooting\", slug: \"troubleshooting\" },\n            { label: \"Admin\", slug: \"admin\" },\n            { label: \"REST API\", slug: \"api\" },\n          ],\n        },\n        {\n          label: \"Resources\",\n          items: [\n            { label: \"Community\", slug: \"community\" },\n            { label: \"Acknowledgements\", slug: \"acknowledgements\" },\n          ],\n        },\n      ],\n      customCss: [\"./src/styles/custom.css\"],\n      editLink: {\n        baseUrl: \"https://github.com/sissbruecker/linkding/edit/master/docs/\",\n      },\n    }),\n  ],\n});\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"linkding-docs\",\n  \"type\": \"module\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"dev\": \"astro dev\",\n    \"start\": \"astro dev\",\n    \"build\": \"rm -rf dist && astro check && astro build\",\n    \"preview\": \"astro preview\",\n    \"astro\": \"astro\"\n  },\n  \"dependencies\": {\n    \"@astrojs/starlight\": \"^0.37.1\",\n    \"astro\": \"^5.6.1\",\n    \"sharp\": \"^0.34.2\"\n  }\n}\n"
  },
  {
    "path": "docs/src/assets/linkding_shortcut.json",
    "content": "{\n  \"categories\": [\n    {\n      \"id\": \"e260b423-db01-4743-a671-2cd38594c63c\",\n      \"layoutType\": \"wide_grid\",\n      \"name\": \"Shortcuts\",\n      \"shortcuts\": [\n        {\n          \"bodyContent\": \"{{7b26d228-4ad6-4b1c-8b7b-076dc03385cc}}\",\n          \"codeOnPrepare\": \"const sharedValue \\u003d getVariable(\\u0027text_and_url\\u0027)\\nconst matches \\u003d sharedValue.match(/\\\\bhttps?:\\\\/\\\\/\\\\S+/gi);\\nconst url \\u003d matches[0];\\nsetVariable(\\u0027cleaned_url\\u0027, url);\",\n          \"contentType\": \"application/json\",\n          \"description\": \"bookmark link\",\n          \"headers\": [\n            {\n              \"id\": \"b66dd9b9-13e8-4802-b527-6e32f3980f4b\",\n              \"key\": \"Authorization\",\n              \"value\": \"Token {{908e3a30-ae82-400d-93c8-561c36d11d6d}}\"\n            }\n          ],\n          \"iconName\": \"flat_grey_pin\",\n          \"id\": \"871c3219-9e9f-46bb-8a7f-78f1496f78fc\",\n          \"method\": \"POST\",\n          \"name\": \"Linkding\",\n          \"responseHandling\": {\n            \"failureOutput\": \"simple\",\n            \"uiType\": \"toast\"\n          },\n          \"url\": \"{{26253fe2-d202-4ce8-acd1-55c1ad3ae7d1}}/api/bookmarks/\"\n        }\n      ]\n    }\n  ],\n  \"variables\": [\n    {\n      \"id\": \"26253fe2-d202-4ce8-acd1-55c1ad3ae7d1\",\n      \"key\": \"linkding_instance\",\n      \"value\": \"https://your.linkding.host.no.slashed.end\"\n    },\n    {\n      \"id\": \"a3c8efa2-3e3a-4bb4-8919-3e831f95fe6a\",\n      \"jsonEncode\": true,\n      \"key\": \"linkding_tag\",\n      \"message\": \"Comma separated\",\n      \"title\": \"One or more tags\",\n      \"type\": \"text\"\n    },\n    {\n      \"id\": \"908e3a30-ae82-400d-93c8-561c36d11d6d\",\n      \"key\": \"linkding_api_key\",\n      \"value\": \"your_api_key_here\"\n    },\n    {\n      \"id\": \"d76696e7-1ee1-4d98-b6f9-b570ec69ef40\",\n      \"key\": \"cleaned_url\"\n    },\n    {\n      \"flags\": 1,\n      \"id\": \"da66cdad-8118-4a87-9581-4db33852b610\",\n      \"key\": \"text_and_url\",\n      \"message\": \"Any text that contains one URL\",\n      \"title\": \"URL\",\n      \"type\": \"text\"\n    },\n    {\n      \"data\": \"{\\\"select\\\":{\\\"multi_select\\\":\\\"false\\\",\\\"separator\\\":\\\",\\\"}}\",\n      \"id\": \"7b26d228-4ad6-4b1c-8b7b-076dc03385cc\",\n      \"key\": \"tag_yes_no_default\",\n      \"options\": [\n        {\n          \"id\": \"9365e43e-0572-4621-ac06-caec1ccff09d\",\n          \"label\": \"Tagged\",\n          \"value\": \"{{5be61e61-d8f5-475b-b1b1-88ddaebf8fd5}}\"\n        },\n        {\n          \"id\": \"9f1caeaf-af57-42b4-8b10-4391354ad0f0\",\n          \"label\": \"Untagged and unread\",\n          \"value\": \"{{71ac9c4d-c03e-4b6f-ad75-9c112a591c50}}\"\n        }\n      ],\n      \"title\": \"Tagged or unread?\",\n      \"type\": \"select\"\n    },\n    {\n      \"id\": \"5be61e61-d8f5-475b-b1b1-88ddaebf8fd5\",\n      \"key\": \"request_body_tagged\",\n      \"value\": \"{ \\\"url\\\": \\\"{{d76696e7-1ee1-4d98-b6f9-b570ec69ef40}}\\\", \\\"tag_names\\\": [ \\\"{{a3c8efa2-3e3a-4bb4-8919-3e831f95fe6a}}\\\" ] }\"\n    },\n    {\n      \"id\": \"71ac9c4d-c03e-4b6f-ad75-9c112a591c50\",\n      \"key\": \"request_body_untagged\",\n      \"value\": \"{ \\\"url\\\": \\\"{{d76696e7-1ee1-4d98-b6f9-b570ec69ef40}}\\\", \\\"unread\\\": true }\"\n    }\n  ],\n  \"version\": 56\n}\n"
  },
  {
    "path": "docs/src/components/Card.astro",
    "content": "---\nimport {icons} from './icons';\ninterface Props {\n    icon: keyof typeof icons;\n    title: string;\n}\n\nconst {icon, title} = Astro.props;\n---\n\n<article class=\"card sl-flex\">\n  <p class=\"title sl-flex\">\n      {icon && <span class=\"icon\" set:html={icons[icon]}/>}\n    <span set:html={title}/>\n  </p>\n  <div class=\"body\">\n    <slot/>\n  </div>\n</article>\n\n<style>\n    .card {\n        flex-direction: column;\n        gap: clamp(0.5rem, calc(0.125rem + 1vw), 1rem);\n    }\n\n    .title {\n        font-weight: 600;\n        font-size: var(--sl-text-h4);\n        color: var(--sl-color-white);\n        line-height: var(--sl-line-height-headings);\n        gap: .8rem;\n        align-items: center;\n    }\n\n    .card .icon {\n        border-radius: 0.25rem;\n        color: var(--sl-color-text-accent);\n    }\n\n    .card .body {\n        margin: 0;\n        font-size: clamp(var(--sl-text-sm), calc(0.5rem + 1vw), var(--sl-text-body));\n    }\n</style>\n"
  },
  {
    "path": "docs/src/components/icons.ts",
    "content": "export const icons = {\n    'focus': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><circle cx=\"12\" cy=\"12\" r=\".5\" fill=\"currentColor\" /><path d=\"M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0\" /></svg>`,\n    'settings': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z\" /><path d=\"M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" /></svg>`,\n    'plus': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M12 5l0 14\" /><path d=\"M5 12l14 0\" /></svg>`,\n    'archive': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z\" /><path d=\"M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10\" /><path d=\"M10 12l4 0\" /></svg>`,\n    'checkbox': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z\" /><path d=\"M9 12l2 2l4 -4\" /></svg>`,\n    'file-export': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M14 3v4a1 1 0 0 0 1 1h4\" /><path d=\"M11.5 21h-4.5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v5m-5 6h7m-3 -3l3 3l-3 3\" /></svg>`,\n    'users': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0\" /><path d=\"M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2\" /><path d=\"M16 3.13a4 4 0 0 1 0 7.75\" /><path d=\"M21 21v-2a4 4 0 0 0 -3 -3.85\" /></svg>`,\n    'login': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2\" /><path d=\"M21 12h-13l3 -3\" /><path d=\"M11 15l-3 -3\" /></svg>`,\n    'puzzle': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1\" /></svg>`,\n    'cloud': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M6.657 18c-2.572 0 -4.657 -2.007 -4.657 -4.483c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 1.927 -1.551 3.487 -3.465 3.487h-11.878\" /></svg>`,\n    'mood-smile': `<svg  xmlns=\"http://www.w3.org/2000/svg\"  width=\"24\"  height=\"24\"  viewBox=\"0 0 24 24\"  fill=\"none\"  stroke=\"currentColor\"  stroke-width=\"2\"  stroke-linecap=\"round\"  stroke-linejoin=\"round\"  class=\"icon icon-tabler icons-tabler-outline icon-tabler-mood-smile\"><path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0\" /><path d=\"M9 10l.01 0\" /><path d=\"M15 10l.01 0\" /><path d=\"M9.5 15a3.5 3.5 0 0 0 5 0\" /></svg>`,\n}"
  },
  {
    "path": "docs/src/content/config.ts",
    "content": "import { defineCollection } from 'astro:content';\nimport { docsSchema } from '@astrojs/starlight/schema';\n\nexport const collections = {\n\tdocs: defineCollection({ schema: docsSchema() }),\n};\n"
  },
  {
    "path": "docs/src/content/docs/acknowledgements.md",
    "content": "---\ntitle: \"Acknowledgements\"\ndescription: \"Acknowledgements and thanks to contributors and sponsors\"\n---\n\n## PikaPods\n\n[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Thanks to PikaPods for making this possible.\n\nSee the table below for a list of donations.\n\n<table>\n  <thead>\n    <tr>\n      <th>Source</th>\n      <th>Description</th>\n      <th>Amount</th>\n      <th>Donated to</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><a href=\"https://www.pikapods.com/\">PikaPods</a></td>\n      <td>Linkding hosting June 2022 - September 2023</td>\n      <td>$163.50</td>\n      <td><a href=\"/donations/2023-10-11-internet-archive.png\">Internet Archive</a></td>\n    </tr>\n    <tr>\n      <td><a href=\"https://www.pikapods.com/\">PikaPods</a></td>\n      <td>Linkding hosting October 2023 - September 2024</td>\n      <td>$287.04</td>\n      <td>\n        <a href=\"/donations/2024-10-04-django.png\">Django</a><br>\n        <a href=\"/donations/2024-10-04-singlefile.png\">SingleFile</a><br>\n        <a href=\"/donations/2024-10-04-internet-archive.png\">Internet Archive</a><br>\n        <a href=\"/donations/2024-10-04-noyb.png\">NOYB</a>\n      </td>\n    </tr>\n  </tbody>\n</table>\n\n## JetBrains\n\nJetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding. Thanks!\n"
  },
  {
    "path": "docs/src/content/docs/admin.md",
    "content": "---\ntitle: \"Admin\"\ndescription: \"How to use the linkding admin app\"\n---\n\nThis document describes how to make use of the admin app that comes as part of each linkding installation. This is the default Django admin app with some linkding specific customizations.\n\nThe admin app provides several features that are not available in the linkding UI:\n- User management and user self-management\n- Bookmark and tag management, including bulk operations\n\n## Linkding administration page\n\nTo open the Admin app, go the *Settings* view and click on the *Admin* tab. This should open a new window with the admin app.\n\nAlternatively you can open the URL directly by adding `/admin` to the URL of your linkding installation.\n\n## User management\n\nGo to the linkding administration page and select *Users*.\nHere you can add and delete users, and change the password of a user.\n\nOnce you have added a user you can, if needed, give the user staff status, which means this user can also access the linkding administration page.\n\nThis page also allows you to change your own password if necessary.\n\n## Bookmark management\n\nWhile the linkding UI itself now has a bulk edit feature for bookmarks you can also use the admin app to manage bookmarks or to do bulk operations. \n\nIn the main linkding administration page, choose *Bookmarks*.\n\nFirst select the bookmarks to operate on:\n\n- Specify a filter to determine which bookmarks to operate on:\n  - In the column *by username*, you can choose to filter for bookmarks for a specific user\n  - In the column *by is archived*, you can choose to filter for bookmarks that are either archived or not\n  - In the column *by tags*, you can choose to filter for specific tags\n  - In the search box you can also add a text filter (note that this doesn't use the same search syntax as the linkding UI itself)  \n\nNow a list of bookmarks which match your filter is displayed, each bookmark on a separate line.\nEach line starts with a checkbox.\nEither choose the individual bookmarks you want to do a bulk operation on, or choose the top checkbox to select all shown bookmarks.\n\nOpen the \"Action\" select box to choose the desired bulk operation:\n\n- Delete\n- Archive\n- Unarchive\n\nClick the button next to the checkbox to execute the operation.\n\n## Tag management\n\nWhile linkding UI currently only allows to create or assign tags, you can use the admin app to manage your tags. This can be especially useful if you want to clean up your tag collection.\n\nIn the main linkding administration page, choose *Tags*.\n\nSimilar to bookmarks management described above you can now specify which tags to operate on by specifying a filter and then selecting the individual tags.\n\nOpen the \"Action\" select box to choose the desired bulk operation:\n\n- Delete\n- Delete unused tags - this will only delete the selected tags that are currently not assigned to any bookmark\n\nClick the button next to the checkbox to execute the operation.\n\nNote that deleting a tag does not affect the bookmarks that are tagged with this tag, it only removes the tag from those bookmarks.\n"
  },
  {
    "path": "docs/src/content/docs/api.md",
    "content": "---\ntitle: \"API\"\ndescription: \"How to use the REST API of linkding\"\n---\n\nThe application provides a REST API that can be used by 3rd party applications to manage bookmarks.\n\n## Authentication\n\nAll requests against the API must be authorized using an authorization token. The application automatically generates an\nAPI token for each user, which can be accessed through the *Settings* page.\n\nThe token needs to be passed as `Authorization` header in the HTTP request:\n\n```\nAuthorization: Token <Token>\n```\n\n## Resources\n\nThe following resources are available:\n\n### Bookmarks\n\n**List**\n\n```\nGET /api/bookmarks/\n```\n\nList bookmarks.\n\nParameters:\n\n- `q` - Filters results using a search phrase using the same logic as through the UI\n- `limit` - Limits the max. number of results. Default is `100`.\n- `offset` - Index from which to start returning results\n- `modified_since` - Filter results to only include bookmarks modified after the specified date (format: ISO 8601, e.g. \"2025-01-01T00:00:00Z\")\n- `added_since` - Filter results to only include bookmarks added after the specified date (format: ISO 8601, e.g. \"2025-05-29T00:00:00Z\")\n- `bundle` - Filter results by bundle id to only include bookmarks matched by a given bundle\n\nExample response:\n\n```json\n{\n  \"count\": 123,\n  \"next\": \"http://127.0.0.1:8000/api/bookmarks/?limit=100&offset=100\",\n  \"previous\": null,\n  \"results\": [\n    {\n      \"id\": 1,\n      \"url\": \"https://example.com\",\n      \"title\": \"Example title\",\n      \"description\": \"Example description\",\n      \"notes\": \"Example notes\",\n      \"web_archive_snapshot_url\": \"https://web.archive.org/web/20200926094623/https://example.com\",\n      \"favicon_url\": \"http://127.0.0.1:8000/static/https_example_com.png\",\n      \"preview_image_url\": \"http://127.0.0.1:8000/static/0ac5c53db923727765216a3a58e70522.jpg\",\n      \"is_archived\": false,\n      \"unread\": false,\n      \"shared\": false,\n      \"tag_names\": [\n        \"tag1\",\n        \"tag2\"\n      ],\n      \"date_added\": \"2020-09-26T09:46:23.006313Z\",\n      \"date_modified\": \"2020-09-26T16:01:14.275335Z\"\n    },\n    ...\n  ]\n}\n```\n\n**List Archived**\n\n```\nGET /api/bookmarks/archived/\n```\n\nList archived bookmarks.\n\nParameters and response are the same as for the regular list endpoint.\n\n**Retrieve**\n\n```\nGET /api/bookmarks/<id>/\n```\n\nRetrieves a single bookmark by ID.\n\n**Check**\n\n```\nGET /api/bookmarks/check/?url=https%3A%2F%2Fexample.com\n```\n\nAllows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the\nresponse holds the bookmark data, otherwise it is `null`.\n\nAlso returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property\ncontains the tag names that would be automatically added when creating a bookmark for that URL.\n\nExample response:\n\n```json\n{\n  \"bookmark\": {\n    \"id\": 1,\n    \"url\": \"https://example.com\",\n    \"title\": \"Example title\",\n    \"description\": \"Example description\",\n    ...\n  },\n  \"metadata\": {\n    \"title\": \"Scraped website title\",\n    \"description\": \"Scraped website description\",\n    ...\n  },\n  \"auto_tags\": [\n    \"tag1\",\n    \"tag2\"\n  ]\n}\n```\n\n**Create**\n\n```\nPOST /api/bookmarks/\n```\n\nCreates a new bookmark. Tags are simply assigned using their names. Including\n`is_archived: true` saves a bookmark directly to the archive.\n\nIf the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If\nyou are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint\nto check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in\nthe future to return an error instead.\n\nIf the title and description are not provided or empty, the application automatically tries to scrape them from the\nbookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.\n\nExample payload:\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"title\": \"Example title\",\n  \"description\": \"Example description\",\n  \"notes\": \"Example notes\",\n  \"is_archived\": false,\n  \"unread\": false,\n  \"shared\": false,\n  \"tag_names\": [\n    \"tag1\",\n    \"tag2\"\n  ]\n}\n```\n\n**Update**\n\n```\nPUT /api/bookmarks/<id>/\nPATCH /api/bookmarks/<id>/\n```\n\nUpdates a bookmark.\nWhen using `POST`, at least all required fields must be provided (currently only `url`).\nWhen using `PATCH`, only the fields that should be updated need to be provided.\nRegardless which method is used, any field that is not provided is not modified.\nTags are simply assigned using their names.\n\nIf the provided URL is already bookmarked this returns an error.\n\nExample payload:\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"title\": \"Example title\",\n  \"description\": \"Example description\",\n  \"tag_names\": [\n    \"tag1\",\n    \"tag2\"\n  ]\n}\n```\n\n**Archive**\n\n```\nPOST /api/bookmarks/<id>/archive/\n```\n\nArchives a bookmark.\n\n**Unarchive**\n\n```\nPOST /api/bookmarks/<id>/unarchive/\n```\n\nUnarchives a bookmark.\n\n**Delete**\n\n```\nDELETE /api/bookmarks/<id>/\n```\n\nDeletes a bookmark by ID.\n\n### Bookmark Assets\n\n**List**\n\n```\nGET /api/bookmarks/<bookmark_id>/assets/\n```\n\nList assets for a specific bookmark.\n\nExample response:\n\n```json\n{\n  \"count\": 2,\n  \"next\": null,\n  \"previous\": null,\n  \"results\": [\n    {\n      \"id\": 1,\n      \"bookmark\": 1,\n      \"asset_type\": \"snapshot\",\n      \"date_created\": \"2023-10-01T12:00:00Z\",\n      \"content_type\": \"text/html\",\n      \"display_name\": \"HTML snapshot from 10/01/2023\",\n      \"status\": \"complete\"\n    },\n    {\n      \"id\": 2,\n      \"bookmark\": 1,\n      \"asset_type\": \"upload\",\n      \"date_created\": \"2023-10-01T12:05:00Z\",\n      \"content_type\": \"image/png\",\n      \"display_name\": \"example.png\",\n      \"status\": \"complete\"\n    }\n  ]\n}\n```\n\n**Retrieve**\n\n```\nGET /api/bookmarks/<bookmark_id>/assets/<id>/\n```\n\nRetrieves a single asset by ID for a specific bookmark.\n\n**Download**\n\n```\nGET /api/bookmarks/<bookmark_id>/assets/<id>/download/\n```\n\nDownloads the asset file.\n\n**Upload**\n\n```\nPOST /api/bookmarks/<bookmark_id>/assets/upload/\n```\n\nUploads a new asset for a specific bookmark. The request must be a `multipart/form-data` request with a single part\nnamed `file` containing the file to upload.\n\nExample response:\n\n```json\n{\n  \"id\": 3,\n  \"bookmark\": 1,\n  \"asset_type\": \"upload\",\n  \"date_created\": \"2023-10-01T12:10:00Z\",\n  \"content_type\": \"application/pdf\",\n  \"display_name\": \"example.pdf\",\n  \"status\": \"complete\"\n}\n```\n\n**Delete**\n\n```\nDELETE /api/bookmarks/<bookmark_id>/assets/<id>/\n```\n\nDeletes an asset by ID for a specific bookmark.\n\n### Tags\n\n**List**\n\n```\nGET /api/tags/\n```\n\nList tags.\n\nParameters:\n\n- `limit` - Limits the max. number of results. Default is `100`.\n- `offset` - Index from which to start returning results\n\nExample response:\n\n```json\n{\n  \"count\": 123,\n  \"next\": \"http://127.0.0.1:8000/api/tags/?limit=100&offset=100\",\n  \"previous\": null,\n  \"results\": [\n    {\n      \"id\": 1,\n      \"name\": \"example\",\n      \"date_added\": \"2020-09-26T09:46:23.006313Z\"\n    },\n    ...\n  ]\n}\n```\n\n**Retrieve**\n\n```\nGET /api/tags/<id>/\n```\n\nRetrieves a single tag by ID.\n\n**Create**\n\n```\nPOST /api/tags/\n```\n\nCreates a new tag.\n\nExample payload:\n\n```json\n{\n  \"name\": \"example\"\n}\n```\n\n### Bundles\n\n**List**\n\n```\nGET /api/bundles/\n```\n\nList bundles.\n\nParameters:\n\n- `limit` - Limits the max. number of results. Default is `100`.\n- `offset` - Index from which to start returning results\n\nExample response:\n\n```json\n{\n  \"count\": 3,\n  \"next\": null,\n  \"previous\": null,\n  \"results\": [\n    {\n      \"id\": 1,\n      \"name\": \"Work Resources\",\n      \"search\": \"productivity tools\",\n      \"any_tags\": \"work productivity\",\n      \"all_tags\": \"\",\n      \"excluded_tags\": \"personal\",\n      \"order\": 0,\n      \"date_created\": \"2020-09-26T09:46:23.006313Z\",\n      \"date_modified\": \"2020-09-26T16:01:14.275335Z\"\n    },\n    {\n      \"id\": 2,\n      \"name\": \"Tech Articles\",\n      \"search\": \"\",\n      \"any_tags\": \"programming development\",\n      \"all_tags\": \"\",\n      \"excluded_tags\": \"outdated\",\n      \"order\": 1,\n      \"date_created\": \"2020-09-27T10:15:30.123456Z\",\n      \"date_modified\": \"2020-09-27T10:15:30.123456Z\"\n    },\n    ...\n  ]\n}\n```\n\n**Retrieve**\n\n```\nGET /api/bundles/<id>/\n```\n\nRetrieves a single bundle by ID.\n\n**Create**\n\n```\nPOST /api/bundles/\n```\n\nCreates a new bundle. If no `order` is specified, the bundle will be automatically assigned the next available order position.\n\nExample payload:\n\n```json\n{\n  \"name\": \"My Bundle\",\n  \"search\": \"search terms\",\n  \"any_tags\": \"tag1 tag2\",\n  \"all_tags\": \"required-tag\",\n  \"excluded_tags\": \"excluded-tag\",\n  \"order\": 5\n}\n```\n\n**Update**\n\n```\nPUT /api/bundles/<id>/\nPATCH /api/bundles/<id>/\n```\n\nUpdates a bundle.\nWhen using `PUT`, all fields except read-only ones should be provided.\nWhen using `PATCH`, only the fields that should be updated need to be provided.\n\nExample payload:\n\n```json\n{\n  \"name\": \"Updated Bundle Name\",\n  \"search\": \"updated search terms\",\n  \"any_tags\": \"new-tag1 new-tag2\",\n  \"order\": 10\n}\n```\n\n**Delete**\n\n```\nDELETE /api/bundles/<id>/\n```\n\nDeletes a bundle by ID.\n\n### User\n\n**Profile**\n\n```\nGET /api/user/profile/\n```\n\nUser preferences.\n\nExample response:\n\n```json\n{\n  \"theme\": \"auto\",\n  \"bookmark_date_display\": \"relative\",\n  \"bookmark_link_target\": \"_blank\",\n  \"web_archive_integration\": \"enabled\",\n  \"tag_search\": \"lax\",\n  \"enable_sharing\": true,\n  \"enable_public_sharing\": true,\n  \"enable_favicons\": false,\n  \"display_url\": false,\n  \"permanent_notes\": false,\n  \"search_preferences\": {\n    \"sort\": \"title_asc\",\n    \"shared\": \"off\",\n    \"unread\": \"off\"\n  }\n}\n```\n"
  },
  {
    "path": "docs/src/content/docs/archiving.md",
    "content": "---\ntitle: \"Archiving\"\ndescription: \"How to archive web pages with linkding\"\n---\n\nLinkding allows to archive bookmarked web pages as HTML files. This can be useful to preserve the content of a web page in case it goes offline or its content changes. The sections below explain different methods to archive web pages and store them in linkding.\n\n## Server-based Archiving\n\nLinkding can automatically create HTML snapshots whenever a bookmark is added. This feature is only available in the `latest-plus` Docker image (see [Installation](/installation#using-docker)), and is automatically active when using that image.\n\nThe snapshots are created using [singlefile-cli](https://github.com/gildas-lormeau/single-file-cli), which effectively runs a headless Chromium instance on the server to convert the web page into a single HTML file. Linkding will also load the [uBlock Origin Lite extension](https://github.com/uBlockOrigin/uBOL-home) into Chromium to attempt to block ads and other unwanted content.\n\nWhen bookmarking a URL that points directly to a PDF file, linkding will download the PDF instead of creating an HTML snapshot. This happens automatically based on the content type of the URL, and the downloaded PDF will be stored as an asset alongside the bookmark, just like HTML snapshots.\n\nThis method is fairly easy to set up, but also has several downsides:\n- The Docker image is significantly larger than the base image, as it includes a Chromium installation.\n- Running Chromium requires significantly more memory, at least 1 GB of RAM.\n- The Docker image is not available for ARM v7 platforms.\n- Creating snapshots from a headless browser is not always reliable, for example:\n  - It may trigger anti-bot measures on the website.\n  - Websites that require login will not show the same content you would see in your browser.\n\nRead on for alternative methods to archive web pages directly from your browser, which do not require the `latest-plus` Docker image and will save the web page exactly as you see it in your browser.\n\n## Using the Singlefile Browser Extension\n\n[Singlefile](https://github.com/gildas-lormeau/SingleFile) is a popular browser extension for saving a web page as a single HTML file. By default, the extension saves the file to your local disk, but it can also be configured to send the file to a REST API endpoint. Linkding provides a REST API endpoint for this purpose.\n\nTo use the Singlefile extension with linkding, follow these steps:\n- Install the [Singlefile extension](https://github.com/gildas-lormeau/SingleFile) in your browser\n- Open the extension's settings\n- Under `Destination`, select `upload to a REST Form API`\n- Under `URL` enter the URL of your linkding installation, followed by `/api/bookmarks/singlefile/` (e.g. `https://linkding.example.com/api/bookmarks/singlefile/`)\n- Under `authorization token`, enter the REST API token that you can get from your linkding settings page\n- Under `data field name` enter `file`\n- Under `URL field name` enter `url`\n\nNow, when you click the Singlefile extension icon, the web page will be saved as a single HTML file and uploaded to your linkding installation.\nNote that if a bookmark for that URL does not exist, linkding will automatically create a new bookmark. If a bookmark already exists, the snapshot will be added to the existing bookmark.\n\nUsing this method has some downsides, in that you can not provide any additional metadata to the bookmark, such as tags or a description. Read on for how to use the linkding extension directly to create bookmarks with metadata and run the Singlefile extension.\n\n## Using the linkding Browser Extension\n\nThe linkding extension allows you to quickly add bookmarks from your browser. It can also integrate with the Singlefile extension to automatically create a snapshot of the web page when adding a bookmark.\n\nTo integrate with the Singlefile extension, follow these steps:\n- First, install and configure the Singlefile extension as described above so that it uploads files to your linkding installation\n- Open the linkding extension settings\n- Enable the `Run Singlefile after adding new bookmark` option\n- Save the settings\n\nNow, when you add a bookmark through the linkding extension, it will automatically trigger the Singlefile extension to create a snapshot of the web page, which will then be uploaded to your linkding installation and stored under the newly added bookmark.\n\nNote that when the option is enabled, linkding will not attempt to create an HTML snapshot on the server, even if you are using the `latest-plus` Docker image. The linkding extension will not trigger Singlefile when updating an existing bookmark. If you want to create a new snapshot for an existing bookmark, you can do so manually by clicking the Singlefile extension icon.\n\n### Extension Store Compatibility\n\nThe linkding and Singlefile extensions communicate using store-specific extension IDs. For the integration to work, both extensions must be installed from the same browser extension store. For example:\n- If you install linkding from the Chrome Web Store, install Singlefile from the Chrome Web Store as well\n- If you install linkding from Firefox Add-ons, install Singlefile from Firefox Add-ons as well\n\nThis is particularly relevant for Microsoft Edge users, as Edge can install extensions from both the Chrome Web Store and the Microsoft Edge Add-ons store. If the extensions are installed from different stores, the automatic Singlefile integration will not work.\n"
  },
  {
    "path": "docs/src/content/docs/auto-tagging.md",
    "content": "---\ntitle: \"Auto tagging\"\ndescription: \"How to automatically assign tags to bookmarks based on predefined rules\"\n---\n\nAuto tagging allows to automatically add tags to bookmarks based on predefined rules. This makes categorizing commonly\nbookmarked websites easier and faster.\n\nAuto tagging rules can be defined in the profile settings. Each rule maps a URL pattern to one or more tags. For\nexample:\n\n```\nyoutube.com video\nreddit.com/r/Music music reddit\n```\n\nWhen a bookmark is created or updated, the URL of the bookmark is parsed to extract the hostname, path, query\nstring and fragment. These components are then compared against the patterns defined in the auto tagging rules. If all components\nmatch, the tags associated with the rule are added to the bookmark. Both the bookmark form in the web interface and the\nbrowser extension will show a preview of the tags that will be added based on the auto tagging rules.\n\nThe URL matching works like this:\n\n- **Hostname Matching**: The hostname of the bookmark URL is compared to the hostname in the rule. If the rule does not\n  specify a subdomain, it matches all subdomains. For example, a rule with `youtube.com` will match both\n  `www.youtube.com` and `m.youtube.com`. If a subdomain is specified in the rule, it will only match that subdomain. For\n  example, a rule with `gist.github.com` will only match `gist.github.com`.\n- **Path Matching**: The path of the bookmark URL is compared to the path in the rule. If the rule does not specify a\n  path, it matches all paths. For example, a rule with `reddit.com` will match `reddit.com/r/music`,\n  `reddit.com/r/gaming`, etc. If a path is specified in the rule, it will only match that path and all its subpaths. For\n  example, a rule with `reddit.com/r/music` will match `reddit.com/r/music`, `reddit.com/r/music/new`, etc.\n- **Query String Matching**: The query string parameters of the bookmark URL are compared to those in the rule. If the\n  rule does not specify any query string parameters, it matches all query strings. If the rule specifies a query string\n  it will only match if the bookmark URL contains all the specified query string parameters with their respective\n  values.\n- **Fragment Matching**: The URL fragment (part after the # symbol) is also compared when present in a rule. If the rule\n  specifies a fragment, it will match any URL whose fragment starts with the specified fragment. For example, a rule with\n  `example.com/#/projects` will match URLs like `example.com/#/projects/123` or `example.com/#/projects/456`.\n\nNote that URL matching currently does not support any kind of wildcards. Rule matching only works based on the URL, not\non the content of the website or any other aspect of the bookmark.\n\n## Example\n\nConsider the following auto tagging rule:\n\n```\nreddit.com/r/Music music reddit\n```\n\nWhen adding a bookmark for a URL like `https://www.reddit.com/r/Music/comments/...`, the auto tagging mechanism will:\n\n1. Parse the URL to extract the hostname (`www.reddit.com`), path (`/r/Music/comments/...`), and query string (none).\n2. Match the hostname against the pattern. The domain `reddit.com` matches. Since the rule does not specify a subdomain,\n   it also matches `www.reddit.com`.\n3. Match the path against the pattern. The path `/r/Music` also matches the nested path `/r/Music/comments/...`.\n4. Match the query string. Since the rule does not specify a query string, it matches all query strings.\n5. The tags `music` and `reddit` will be added to the bookmark.\n"
  },
  {
    "path": "docs/src/content/docs/backups.md",
    "content": "---\ntitle: \"Backups\"\ndescription: \"How to back up your Linkding installation\"\n---\n\nLinkding stores all data in the application's data folder.\nThe full path to that folder in the Docker container is `/etc/linkding/data`.\nAs described in the installation docs, you should mount the `/etc/linkding/data` folder to a folder on your host system.\n\nThe data folder contains the following contents that are relevant for backups:\n- `db.sqlite3` - the SQLite database\n- `assets` - folder that contains HTML snapshots of bookmarks\n- `favicons` - folder that contains downloaded favicons\n- `previews` - folder that contains downloaded preview images\n\nThe following sections explain how to back up the individual contents.\n\n## Full backup\n\nlinkding provides a CLI command to create a full backup of the data folder. This creates a zip file that contains backups of the database, assets, favicons, and preview images.\n\n:::note\nThis method assumes that you are using the default SQLite database.\nIf you are using a different database, such as Postgres, you'll have to back up the database and other contents of the data folder manually.\n:::\n\nTo create a full backup, execute the following command:\n```shell\ndocker exec -it linkding python manage.py full_backup /etc/linkding/data/backup.zip\n```\nThis creates a `backup.zip` file in the Docker container under `/etc/linkding/data`.\n\nTo copy the backup file to your host system, execute the following command:\n```shell\ndocker cp linkding:/etc/linkding/data/backup.zip backup.zip\n```\nThis copies the backup file from the Docker container to the current folder on your host system.\nNow you can move that file to your backup location.\n\nTo restore a backup: \n- Extract the zip file in a folder of your new installation.\n- Rename the extracted folder to `data`.\n- When starting the Docker container, mount that folder to `/etc/linkding/data` as explained in the README.\n- Then start the Docker container.\n\n## Alternative backup methods\n\nIf you can't use the full backup method, this section describes alternatives how to back up the individual contents of the data folder.\n\n### SQLite database backup\n\nlinkding includes a CLI command for creating a backup copy of the database.\n\n:::caution\nWhile the SQLite database is just a single file, it is not recommended to just copy that file.\nThis method is not transaction safe and may result in a [corrupted database](https://www.sqlite.org/howtocorrupt.html).\nUse one of the backup methods described below.\n:::\n\n:::caution\nThis method is deprecated and may be removed in the future.\nPlease use the full backup method described above.\n:::\n\nTo create a backup, execute the following command:\n```shell\ndocker exec -it linkding python manage.py backup /etc/linkding/data/backup.sqlite3\n```\nThis creates a `backup.sqlite3` file in the Docker container under `/etc/linkding/data`.\n\nTo copy the backup file to your host system, execute the following command:\n```shell\ndocker cp linkding:/etc/linkding/data/backup.sqlite3 backup.sqlite3\n```\nThis copies the backup file from the Docker container to the current folder on your host system.\nNow you can move that file to your backup location.\n\nTo restore the backup, just copy the backup file to the data folder of your new installation and rename it to `db.sqlite3`. Then start the Docker container.\n\n### SQLite database SQL dump\n\nRequires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.\n\nWith this method you create a plain text file with the SQL statements to recreate the SQLite database.\nTo create a backup, execute the following command in the data folder on your host system:\n```shell\nsqlite3 db.sqlite3 .dump > backup.sql\n```\nThis creates a `backup.sql` which you can copy to your backup location.\nAs this is a plain text file you can also commit it to any revision management system, like git.\nUsing git, you can commit the changes, followed by a git push to a remote repository.\n\n### Exporting bookmarks from the UI\n\nThis is the least technical option to back up bookmarks, but has several limitations:\n- It does not export user profiles.\n- It only exports your own bookmarks, not those of other users.\n- It does not export URLs of snapshots on the Internet Archive Wayback machine.\n- It does not export HTML snapshots of bookmarks. Even if you backup and restore the assets folder, the bookmarks will not be linked to the snapshots anymore.\n- It does not export favicons or preview images.\n\nOnly use this method if you are fine with the above limitations.\n\nTo export bookmarks from the UI, open the general settings.\nIn the Export section, click on the *Download* button to download an HTML file containing all your bookmarks.\nThen move that file to your backup location.\n\nTo restore bookmarks, open the general settings on your new installation.\nIn the Import section, click on the *Choose file* button to select the HTML file you downloaded before.\nThen click on the *Import* button to import the bookmarks.\n\n### Assets\n\nIf you are using the HTML snapshots feature, you should also do backups of the `assets` folder.\nIt contains the HTML snapshots files of your bookmarks which are referenced from the database.\n\nTo back up the assets, then you have to copy the `assets` folder to your backup location.\n\nTo restore the assets, copy the `assets` folder back to the data folder of your new installation.\n\n### Favicons\n\nDoing a backup of the icons is optional, as they can be downloaded again.\n\nIf you choose not to back up the icons, you can just restore the database and then click the _Refresh Favicons_ button in the general settings.\nThis will download all missing icons again.\n\nIf you want to back up the icons, then you have to copy the `favicons` folder to your backup location.\n\nTo restore the icons, copy the `favicons` folder back to the data folder of your new installation.\n"
  },
  {
    "path": "docs/src/content/docs/browser-extension.md",
    "content": "---\ntitle: \"Browser Extension\"\ndescription: \"Browser extension for linkding\"\n---\n\nlinkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:\n- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)\n- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)\n\nThe extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).\n"
  },
  {
    "path": "docs/src/content/docs/community.md",
    "content": "---\ntitle: \"Community\"\ndescription: \"Community projects around linkding\"\n---\n\nThis section lists community projects around using linkding. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.\n\n## Mobile and Desktop Apps\n\n| Name | Description | Author |\n| ---- | ----------- | ------ |\n| [cosmicding](https://github.com/vkhitrin/cosmicding) | Desktop client built using [libcosmic](https://github.com/pop-os/libcosmic). | [vkhitrin](https://github.com/vkhitrin) |\n| [LinkBuddy](https://github.com/peterto/LinkBuddy) | An open-source Android and iOS client for linkding, written in React Native. Android apk available on [github](https://github.com/peterto/LinkBuddy/releases) and iOS version on [Apple AppStore](https://apps.apple.com/us/app/linkbuddy-for-linkding/id6740408952). | [peterto](https://github.com/peterto) |\n| [Linkdy](https://github.com/JGeek00/linkdy) | An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). | [JGeek00](https://github.com/JGeek00) |\n| [Linklater](https://github.com/danielyrovas/linklater) | An open-source Android client written in Kotlin. | [danielyrovas](https://github.com/danielyrovas) |\n| [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) | An iOS client for linkding. | [amoscardino](https://github.com/amoscardino) |\n| [Pinkt](https://github.com/fibelatti/pinboard-kotlin) | An Android client for linkding. | [fibelatti](https://github.com/fibelatti) |\n\n## Browser Extensions\n\n| Name | Description | Author |\n| ---- | ----------- | ------ |\n| [linkding-extension](https://github.com/jeroenpardon/linkding-extension) | An alternative to the official browser extension that wraps the linkding bookmarklet. | [jeroenpardon](https://github.com/jeroenpardon) |\n| [linkding-injector](https://github.com/Fivefold/linkding-injector) | Injects search results from linkding into the sidebar of search pages like google and duckduckgo. | [Fivefold](https://github.com/Fivefold) |\n\n## Web and CLI Apps\n\n| Name | Description | Author |\n| ---- | ----------- | ------ |\n| [DingDrop](https://github.com/marb08/DingDrop) | A Telegram bot that allows you to quickly save bookmarks to your Linkding instance via Telegram using Linkding APIs. | [marb08](https://github.com/marb08) |\n| [feed2linkding](https://codeberg.org/strubbl/feed2linkding) | A commandline utility to add all web feed item links to linkding via API call. | [Strubbl](https://github.com/Strubbl) |\n| [Komrade](https://codeberg.org/kodemonaut/komrade) | A simple docker based startpage/dashboard which syncs with Linkding, Linkwarden or Nextcloud Bookmarks. | [kodemonaut](https://codeberg.org/kodemonaut) |\n| [Linka!](https://github.com/cmsax/linka) | Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. | [cmsax](https://github.com/cmsax) |\n| [linkding-archiver](https://github.com/sebw/linkding-archiver) | A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. | [sebw](https://github.com/sebw) |\n| [linkding-cli](https://github.com/bachya/linkding-cli) | A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). | [bachya](https://github.com/bachya) |\n| [linkdinger](https://github.com/lmmendes/linkdinger) | A Telegram bot that saves links directly to Linkding, with support for tags, notes, and searching bookmarks. | [lmmendes](https://github.com/lmmendes) |\n| [linkding-healthcheck](https://github.com/sebw/linkding-healthcheck) | A Go application that checks the health of your bookmarks and add a tag on dead and problematic URLs. | [sebw](https://github.com/sebw) |\n| [linkding-media-archiver](https://github.com/proog/linkding-media-archiver) | Automatically downloads media files for your bookmarks with yt-dlp and makes them available within Linkding. | [proog](https://github.com/proog) |\n| [linkding-reminder](https://github.com/sebw/linkding-reminder) | A Python application that will send an email reminder for links with a specific tag. | [sebw](https://github.com/sebw) |\n| [linktiles](https://github.com/haondt/linktiles) | A web app that displays your links as tiles in a configurable mosaic. | [haondt](https://github.com/haondt) |\n| [Pocket2Linkding](https://github.com/hkclark/Pocket2Linkding/) | A tool to migrate from Mozilla Pocket to linkding. Preserves the date the link was added to pocket and any tags. | [hkclark](https://github.com/hkclark) |\n| [serchding](https://github.com/ldwgchen/serchding) | Full-text search for linkding. | [ldwgchen](https://github.com/ldwgchen) |\n\n## Utilities\n\n| Name | Description | Author |\n| ---- | ----------- | ------ |\n| [alfred-linkding-bookmarks](https://github.com/firefingers21/alfred-linkding-bookmarks) | An Alfred workflow for searching and opening linkding bookmarks. | [FireFingers21](https://github.com/FireFingers21) |\n| [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) | Helm Chart for deploying linkding inside a Kubernetes cluster. | [pascaliske](https://github.com/pascaliske) |\n| [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) | An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation. | [andrewdolphin](https://github.com/andrewdolphin) |\n| [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) | iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup. | [joshdick](https://joshdick.net) |\n| [k8s + s3](https://github.com/jzck/linkding-k8s-s3) | Setup for hosting stateless linkding on k8s with sqlite replicated to s3. | [jzck](https://github.com/jzck) |\n| [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) | A browser bookmarklet to open all links on the current Linkding page in new tabs. | [ukcuddlyguy](https://github.com/ukcuddlyguy) |\n| [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) | A group of saved request templates for API testing. | [gingerbeardman](https://github.com/gingerbeardman) |\n\n## Libraries\n\n| Name | Description | Author |\n| ---- | ----------- | ------ |\n| [aiolinkding](https://github.com/bachya/aiolinkding) | A Python3, async library to interact with the linkding REST API. | [bachya](https://github.com/bachya) |\n| [go-linkding](https://github.com/piero-vic/go-linkding) | A Go client library to interact with the linkding REST API. | [piero-vic](https://github.com/piero-vic) |\n| [linkding-api](https://github.com/vbsampath/linkding-api) | A Javascript library implementing linkding REST API. | [vbsampath](https://github.com/vbsampath) |\n| [linkding-rs](https://github.com/zbrox/linkding-rs) | A Rust client library to interact with the linkding REST API with cross platform support to be easily used in Android or iOS apps. | [zbrox](https://github.com/zbrox) |\n\n"
  },
  {
    "path": "docs/src/content/docs/how-to.md",
    "content": "---\ntitle: \"How to\"\ndescription: \"Collection of tips and tricks around using linkding\"\n---\n\nCollection of tips and tricks around using linkding.\n\n## Using the bookmarklet on Android/Chrome\n\nThis how-to explains the usage of the standard linkding bookmarklet on Android / Chrome. \n\nChrome on Android does not permit running bookmarklets in the same way you can on a desktop system. There is however a workaround that is explained here.\n\n**Note** that this only works with Chrome and not with other browsers on Android.\n\nCreate a bookmark of your linkding deployment by clicking the star icon which you find in the three dots menu in the top right. Next you have to edit the bookmark. Edit the URL and replace it it with the bookmarklet code of your instance and give it an easy to type name like `bm` for bookmark or `ld` for linkding:\n\n```\njavascript:window.open(`http://<YOUR_INSTANCE_HERE>/bookmarks/new?url=${encodeURIComponent(window.location)}&auto_close`)\n```\n\nNow when you are browsing the web and you want to save the current page as a bookmark to your linkding instance simply type `bm` into the address bar and select it from the results. The bookmarklet code will trigger and you will be redirected so save the current page.\n\nFor more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-android/\n\n## Using the PWA (Progressive Web App) to save links using the native share sheet on Android\n\n - Visit your instance in Chrome (works best) or another Chromium-based web browser.\n - A popup should appear (first time only) prompting you to install Linkding as an app. If it doesn't, tap the 3-dot menu then tap \"Add to Home screen\", then tap \"Install\".\n - Linkding will now show up in Android's native share sheet. Share a link from any app or browser and tap Linkding to save a link.\n - Optional: when the share sheet is displayed, tap and hold Linkding, then tap \"Pin\" to pin it to the top.\n\n## Using HTTP Shortcuts app on Android\n\n**Note** This allows you to share URL from any app to tag and bookmark it to linkding\n\n- Install HTTP Shortcuts from [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts) or [F-Droid](https://f-droid.org/en/packages/ch.rmy.android.http_shortcuts/).\n\n- Copy the URL of [linkding_shortcut.json](https://raw.githubusercontent.com/sissbruecker/linkding/master/docs/src/assets/linkding_shortcut.json).\n\n- Open HTTP Shortcuts, tap the 3-dot-button at the top-right corner, tap `Import/Export`, then tap `Import from URL`.\n\n- Paste the URL you copied earlier, tap OK, go back, tap the 3-dot-button again, then tap `Variables`.\n\n- Edit the `values` of `linkding_instance` and `linkding_api_key`.\n\nTry using share button on an app, a new item `Send to...` should appear on the share sheet. You can also manually share by tapping the shortcut inside the HTTP Shortcuts app itself.\n\n## Create a share action on iOS for adding bookmarks to linkding\n\nThis how-to explains how to make use of the app shortcuts iOS app to create a share action that can be used in Safari for adding bookmarks to your linkding instance.\n\nTo install the shortcut:\n- Download the [Shortcut](https://raw.githubusercontent.com/sissbruecker/linkding/master/docs/src/assets/Add%20To%20Linkding.shortcut) on your iOS device\n- Tap the downloaded file, which brings up the Shortcuts app\n- Confirm that you want to add the shortcut\n- In the shortcut, change `https://linkding.mydomain.com` to the URL of your linkding instance\n- Confirm / close the shortcut\n\nTo use the shortcut:\n- Open Safari and navigate to the page you want to bookmark\n- Tap the share button\n- Scroll down and tap \"Add To Linkding\"\n- This opens linkding in a Safari overlay where you can configure the bookmark\n- When you're done, tap \"Save\"\n- After the bookmark is saved you can close the overlay\n\nAt the bottom of the share sheet there is a button for configuring share actions. You can use this to move the \"Add To Linkding\" action to the top of the share sheet if you like.\n\n:::note\nYou can also check the [Community section](/community) for other pre-made shortcuts that you can use.\n:::\n\n## Increase the font size\n\nThe font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:\n\n```css\n:root {\n  --font-size: 0.75rem;\n  --font-size-sm: 0.7rem;\n  --font-size-lg: 0.9rem;\n}\n\n.bookmark-list {\n  line-height: 1.15rem;\n}\n\n.tag-cloud {\n  line-height: 1.15rem;\n}\n```\n\nYou can adjust the `--font-size`, `--font-size-sm` and `--font-size-lg` variables to your liking.\nNote that increasing the font might also require you to adjust the line-height in certain places to better separate texts from each other.\nAs an example, the above also increases the font size and line height of the bookmark list and tag cloud.\n"
  },
  {
    "path": "docs/src/content/docs/index.mdx",
    "content": "---\ntitle: linkding\ndescription: A self-hosted bookmarking service that is designed to be minimal, fast and easy to set up.\ntemplate: splash\nhero:\n  tagline: A self-hosted bookmark manager designed to be minimal, fast, and easy to set up.\n  actions:\n    - text: Get started\n      link: /installation\n      icon: right-arrow\n    - text: GitHub\n      link: https://github.com/sissbruecker/linkding\n      icon: external\n      variant: minimal\n      attrs:\n        target: _blank\n    - text: Demo\n      link: https://demo.linkding.link\n      icon: external\n      variant: minimal\n      attrs:\n        target: _blank\n---\n\nimport { CardGrid } from '@astrojs/starlight/components';\nimport Card from '../../components/Card.astro';\n\n<a href=\"/linkding-screenshot.png\" className=\"hero-image light\">\n<img src=\"/linkding-screenshot.png\"/>\n</a>\n<a href=\"/linkding-screenshot-dark.png\" className=\"hero-image dark\">\n<img src=\"/linkding-screenshot-dark.png\"/>\n</a>\n\n\n## Features\n\n<CardGrid>\n\t<Card title=\"Focused\" icon=\"focus\">\n\t\tOptimized for readability, allowing to quickly add and find bookmarks without distractions.\n\t</Card>\n\t<Card title=\"Customizable\" icon=\"settings\">\n\t\tFeatures can be enabled or disabled as needed, adjustable UI through a number of settings.\n\t</Card>\n\t<Card title=\"Metadata\" icon=\"plus\">\n\t\tAutomatically fetches titles, descriptions, icons and preview images of bookmarked websites.\n\t</Card>\n\t<Card title=\"Archiving\" icon=\"archive\">\n\t\tAutomatically create snapshots of bookmarked websites, either as local HTML file or on the Internet Archive.\n\t</Card>\n\t<Card title=\"Bulk editing\" icon=\"checkbox\">\n\t\tApply any operation to a selection of bookmarks or the whole collection.\n\t</Card>\n\t<Card title=\"Import / Export\" icon=\"file-export\">\n\t\tImport and export bookmarks in the Netscape HTML format.\n\t</Card>\n\t<Card title=\"Multi-User\" icon=\"users\">\n\t\tSupports multiple users, with the ability to share bookmarks with other users or guests.\n\t</Card>\n\t<Card title=\"Browser extension\" icon=\"puzzle\">\n\t\tExtensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe) allow adding and searching bookmarks from within the browser.\n\t</Card>\n\t<Card title=\"REST API\" icon=\"cloud\">\n\t\tREST API for developing scripts or 3rd party apps.\n\t</Card>\n\t<Card title=\"Low maintenance\" icon=\"mood-smile\">\n\t\tA single Docker container, using SQLite as database. Automated migrations, zero breaking changes.\n\t</Card>\n</CardGrid>\n"
  },
  {
    "path": "docs/src/content/docs/installation.md",
    "content": "---\ntitle: \"Installation\"\ndescription: \"How to install linkding\"\n---\n\nlinkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).\nThe Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.\n\nlinkding uses an SQLite database by default.\nAlternatively, linkding supports PostgreSQL, see the [database options](/options#ld_db_engine) for more information.\n\n##  Using Docker\n\nThe Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.\n\n<table>\n  <thead>\n    <tr>\n      <th>Tag</th>\n      <th>Description</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code>latest</code></td>\n      <td>Provides the basic functionality of linkding</td>\n    </tr>\n    <tr>\n      <td><code>latest-plus</code></td>\n      <td>\n        Includes feature for archiving websites as HTML snapshots\n        <ul>\n          <li>Significantly larger image size as it includes a Chromium installation</li>\n          <li>Requires more runtime memory to run Chromium</li>\n          <li>Requires more disk space for storing HTML snapshots</li>\n        </ul>            \n      </td>\n    </tr>\n    <tr>\n      <td><code>latest-alpine</code></td>\n      <td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>\n    </tr>    \n    <tr>\n      <td><code>latest-plus-alpine</code></td>\n      <td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>\n    </tr>    \n  </tbody>\n</table>\n\nTo install linkding using Docker you can just run the image from either [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding) or [GitHub Container Registry](https://github.com/sissbruecker/linkding/pkgs/container/linkding):\n```shell\n# Using Docker Hub\ndocker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest\n\n# Using GitHub Container Registry\ndocker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d ghcr.io/sissbruecker/linkding:latest\n```\n\nIn the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.\n\nIf everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.\n\nTo upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.\n\nTo complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.\n\n##  Using Docker Compose\n\nTo install linkding using [Docker Compose](https://docs.docker.com/compose/), you can download the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Also download the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file, rename it to `.env`, configure the parameters, and then run:\n```shell\ndocker-compose up -d\n```\n\nTo complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.\n\n## User Setup\n\nThe linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:\n\n**Docker**\n```shell\ndocker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com\n```\n\n**Docker Compose**\n```shell\ndocker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com\n```\n\nThe command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.\n\nAlternatively, you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](/options#ld_superuser_name).\n\n## Reverse Proxy Setup\n\nWhen using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.\n\n<details>\n<summary>Apache</summary>\n\nApache2 does not change the headers by default, and should not\nneed additional configuration.\n\nAn example virtual host that proxies to linkding might look like:\n```\n<VirtualHost *:9100>\n    <Proxy *>\n        Order deny,allow\n        Allow from all\n    </Proxy>\n\n    ProxyPass / http://linkding:9090/\n    ProxyPassReverse / http://linkding:9090/\n</VirtualHost>\n```\n\nFor a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)\n\nIf you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](/options#ld_csrf_trusted_origins).\n\n</details>\n\n<details>\n<summary>Caddy 2</summary>\n\nCaddy does not change the headers by default, and should not need any further configuration.\n\nIf you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](/options#ld_csrf_trusted_origins).\n\n</details>\n\n<details>\n<summary>Nginx</summary>\n\nNginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.\nTo forward the correct headers to linkding, add the following directives to the location block of your Nginx config:\n```\nlocation /linkding {\n    ...\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n}\n```\n\n</details>\n\nInstead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the  [`LD_CSRF_TRUSTED_ORIGINS` option](/options#ld_csrf_trusted_origins).\n"
  },
  {
    "path": "docs/src/content/docs/managed-hosting.md",
    "content": "---\ntitle: \"Managed Hosting\"\ndescription: \"Managed hosting options for linkding\"\n---\n\nSelf-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.\n\n## Fully Managed\n\nThe following services provide fully managed hosting for linkding, including automatic updates and backups:\n\n- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](/acknowledgements#pikapods))\n- [CloudBreak](https://cloudbreak.app/products/linkding/?utm_medium=referral&utm_source=linkding-docs&utm_content=managed-hosting&rby=linkding-docs-managed-hosting) - Managed hosting for linkding, US regions available.\n\n## Self-Managed\n\nThe following guides provide instructions for hosting a linkding installation on various platforms, however you are still responsible for updates and backups:\n\n- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)\n- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app\n- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)\n"
  },
  {
    "path": "docs/src/content/docs/options.md",
    "content": "---\ntitle: \"Options\"\ndescription: \"Options for configuring linkding\"\n---\n\nThis document lists the options that linkding can be configured with and explains how to use them in the individual install scenarios.\n\n## Using options\n\n### Docker\n\nOptions are passed as environment variables to the Docker container by using the `-e` argument when using `docker run`. For example:\n\n```\ndocker run --name linkding -p 9090:9090 -d -e LD_DISABLE_URL_VALIDATION=True sissbruecker/linkding:latest\n```\n\nFor multiple options, use one `-e` argument per option.\n\n### Docker-compose\n\nFor docker-compose options are configured using an `.env` file.\nFollow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.\n\n## List of options\n\n### `LD_SUPERUSER_NAME`\n\nValues: `String` | Default = None\n\nWhen set, creates an initial superuser with the specified username when starting the container.\nDoes nothing if the user already exists.\n\nSee [`LD_SUPERUSER_PASSWORD`](#ld_superuser_password) on how to configure the respective password.\n\n### `LD_SUPERUSER_PASSWORD`\n\nValues: `String` | Default = None\n\nThe password for the initial superuser.\nWhen left undefined, the superuser will be created without a usable password, which means the user can not authenticate using credentials / through the login form, and can only be authenticated using proxy authentication (see [`LD_ENABLE_AUTH_PROXY`](#ld_enable_auth_proxy)).\n\n### `LD_DISABLE_BACKGROUND_TASKS`\n\nValues: `True`, `False` | Default = `False`\n\nDisables background tasks, such as creating snapshots for bookmarks on the [the Internet Archive Wayback Machine](https://archive.org/web/).\nEnabling this flag will prevent the background task processor from starting up, and prevents scheduling tasks.\nThis might be useful if you are experiencing performance issues or other problematic behaviour due to background task processing.\n\n### `LD_SUPERVISOR_MANAGED` (Experimental)\n\nValues: `True`, `False` | Default = `False`\n\nChanges how processes are managed within the container.\nWhen enabled, supervisor manages both the background task processor and the web server (uwsgi).\nThis enables background task logs to appear in the container output (visible via `docker logs`).\nAt the moment, supervisor will automatically restart crashed processes and the `LD_DISABLE_BACKGROUND_TASKS` setting is ignored.\n\nWhen disabled (default), the background task processor runs as a daemon and uwsgi runs as the main process.\nBackground task logs are written to a file (`background_tasks.log`) instead of the container output.\n\n### `LD_DISABLE_URL_VALIDATION`\n\nValues: `True`, `False` | Default = `False`\n\nCompletely disables URL validation for bookmarks.\nThis can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.\n\n### `LD_REQUEST_MAX_CONTENT_LENGTH`\n\nValues: `Integer` as bytes | Default = `None`\n\nConfigures the maximum content length for POST requests in the uwsgi application server. This can be used to prevent uploading large files that might cause the server to run out of memory. By default, the server does not limit the content length.\n\n### `LD_REQUEST_TIMEOUT`\n\nValues: `Integer` as seconds | Default = `60`\n\nConfigures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.\n\n### `LD_SERVER_HOST`\n\nValues: Valid address for socket to bind to | Default = `[::]`\n\nAllows to set a custom host for the UWSGI server running in the container. The default creates a dual stack socket, which will respond to IPv4 and IPv6 requests. IPv4 requests are logged as IPv4-mapped IPv6 addresses, such as \"::ffff:127.0.0.1\". If reverting to an IPv4-only socket is desired, this can be set to \"0.0.0.0\".\n\n### `LD_SERVER_PORT`\n\nValues: Valid port number | Default = `9090`\n\nAllows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.\n\n### `LD_CONTEXT_PATH`\n\nValues: `String` | Default = None\n\nAllows configuring the context path of the website. Useful for setting up Nginx reverse proxy.\nThe context path must end with a slash. For example: `linkding/`\n\n### `LD_ENABLE_AUTH_PROXY`\n\nValues: `True`, `False` | Default = `False`\n\nEnables support for authentication proxies such as Authelia.\nThis effectively disables credentials-based authentication and instead authenticates users if a specific request header contains a known username.\nYou must make sure that your proxy (nginx, Traefik, Caddy, ...) forwards this header from your auth proxy to linkding. Check the documentation of your auth proxy and your reverse proxy on how to correctly set this up.\n\nNote that this automatically creates new users in the database if they do not already exist.\n\nEnabling this setting also requires configuring the following options:\n- `LD_AUTH_PROXY_USERNAME_HEADER` - The name of the request header that the auth proxy passes to the proxied application (linkding in this case), so that the application can identify the user.\nCheck the documentation of your auth proxy to get this information.\nNote that the request headers are rewritten in linkding: all HTTP headers are prefixed with `HTTP_`, all letters are in uppercase, and dashes are replaced with underscores.\nFor example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_AUTH_PROXY_USERNAME_HEADER` needs to be configured as `HTTP_REMOTE_USER`.\n- `LD_AUTH_PROXY_LOGOUT_URL` - The URL that linkding should redirect to after a logout.\nBy default, the logout redirects to the login URL, which means the user will be automatically authenticated again.\nInstead, you might want to configure the logout URL of the auth proxy here.\n\n### `LD_ENABLE_OIDC`\n\nValues: `True`, `False` | Default = `False`\n\nEnables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.\nWhen enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.\nUsers are associated by the email address provided from the OIDC provider, which is by default also used as username in linkding. You can configure a custom claim to be used as username with `OIDC_USERNAME_CLAIM`.\nIf there is no user with that email address as username, a new user is created automatically.\n\nThis requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.\nIn general, you should find the required information in the UI of your OIDC provider, or its documentation.\n\nThe options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support.\nPlease check their documentation for more information on the options.\n\nThe following options can be configured:\n- `OIDC_OP_AUTHORIZATION_ENDPOINT` - The authorization endpoint of the OIDC provider.\n- `OIDC_OP_TOKEN_ENDPOINT` - The token endpoint of the OIDC provider.\n- `OIDC_OP_USER_ENDPOINT` - The user info endpoint of the OIDC provider.\n- `OIDC_OP_JWKS_ENDPOINT` - The JWKS endpoint of the OIDC provider.\n- `OIDC_RP_CLIENT_ID` - The client ID of the application.\n- `OIDC_RP_CLIENT_SECRET` - The client secret of the application.\n- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.\n- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.\n- `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`.\n- `OIDC_RP_SCOPES` - Scopes asked for on the authorization flow. Default is `oidc email profile`.\n- `OIDC_USERNAME_CLAIM` - A custom claim to used as username for new accounts, for example `preferred_username`. If the configured claim does not exist or is empty, the email claim is used as fallback. Default is `email`.\n\n#### `OIDC` and `LD_SUPERUSER_NAME`\n\nAs noted above, OIDC matches users by email address, but `LD_SUPERUSER_NAME` will only set the username.\nInstead of setting `LD_SUPERUSER_NAME` it is recommended that you use the method described in [User setup](/installation#user-setup) to configure a superuser with both username and email address.\nThis way when OIDC searches for a matching user it will find the superuser account you created.\nNote that you should create the superuser **before** logging in with OIDC for the first time.\n\n<details>\n\n<summary>Authelia Example</summary>\n\n#### Linkding Configuration\n\n```bash\nLD_ENABLE_OIDC=True\nOIDC_OP_AUTHORIZATION_ENDPOINT=https://auth.example.com/api/oidc/authorization\nOIDC_OP_TOKEN_ENDPOINT=https://auth.example.com/api/oidc/token\nOIDC_OP_USER_ENDPOINT=https://auth.example.com/api/oidc/userinfo\nOIDC_OP_JWKS_ENDPOINT=https://auth.example.com/jwks.json\nOIDC_RP_CLIENT_ID=linkding\nOIDC_RP_CLIENT_SECRET=myClientSecret\n```\n#### Authelia Configuration\n\n```yaml\nidentity_providers:\n  oidc:\n    # --- more OIDC provider configuration ---\n\n    clients:\n      - id: linkding\n        description: Linkding\n        # docker run --rm authelia/authelia:latest authelia crypto rand --length 64 --charset alphanumeric\n        secret: myClientSecret\n        public: false\n        token_endpoint_auth_method: client_secret_post\n        scopes:\n          - openid\n          - email\n          - profile\n        redirect_uris:\n          - https://linkding.example.com/oidc/callback/\n```\n\n</details>\n\n### `LD_DISABLE_LOGIN_FORM`\n\nValues: `True`, `False` | Default = `False`\n\nDisables the login form on the login page.\nThis is useful when you want to enforce authentication through OIDC only.\nWhen enabled, users will not be able to log in using their username and password, and only the \"Login with OIDC\" button will be shown on the login page.\n\n### `LD_CSRF_TRUSTED_ORIGINS`\n\nValues: `String` | Default = None\n\nList of trusted origins / host names to allow for `POST` requests, for example when logging in, or saving bookmarks.\nFor these type of requests, the `Origin` header must match the `Host` header, otherwise the request will fail with a `403` status code, and the message `CSRF verification failed.`\n\nThis option allows to declare a list of trusted origins that will be accepted even if the headers do not match. This can be the case when using a reverse proxy that rewrites the `Host` header, such as Nginx.\n\nFor example, to allow requests to https://linkding.mydomain.com, configure the setting to `https://linkding.mydomain.com`.\nNote that the setting **must** include the correct protocol (`https` or `http`), and **must not** include the application / context path.\nMultiple origins can be specified by separating them with a comma (`,`).\n\nThis setting is adopted from the Django framework used by linkding, more information on the setting is available in the [Django documentation](https://docs.djangoproject.com/en/4.0/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS).\n\n### `LD_USE_X_FORWARDED_HOST`\n\nValues: `true` or `false` | Default =  `false`\n\nIf enabled the server will trust the `X-Forwarded-Host` header over the `Host` header to determine the hostname of the server. This should only be enabled if a proxy which sets this header is in use.\n\nThis setting is adopted from the Django framework used by linkding, more information on the setting is available in the [Django documentation](https://docs.djangoproject.com/en/6.0/ref/settings/#std-setting-USE_X_FORWARDED_HOST).\n\n### `LD_LOG_X_FORWARDED_FOR`\n\nValues: `true` or `false` | Default =  `false`\n\nSet uWSGI [log-x-forwarded-for](https://uwsgi-docs.readthedocs.io/en/latest/Options.html?#log-x-forwarded-for) parameter allowing to keep the real IP of clients in logs when using a reverse proxy.\n\n### `LD_DB_ENGINE`\n\nValues: `postgres` or `sqlite` | Default = `sqlite`\n\nDatabase engine used by linkding to store data.\nCurrently, linkding supports SQLite and PostgreSQL.\nBy default, linkding uses SQLite, for which you don't need to configure anything.\nAll the other database variables below are only required for configured PostgresSQL.\n\n### `LD_DB_DATABASE`\n\nValues: `String` | Default =  `linkding`\n\nThe name of the database.\n\n### `LD_DB_USER`\n\nValues: `String` | Default =  `linkding`\n\nThe name of the user to connect to the database server.\n\n### `LD_DB_PASSWORD`\n\nValues: `String` | Default =  None\n\nThe password of the user to connect to the database server.\nThe password must be configured when using a database other than SQLite, there is no default value.\n\n### `LD_DB_HOST`\n\nValues: `String` | Default =  `localhost`\n\nThe hostname or IP of the database server.\n\n### `LD_DB_PORT`\n\nValues: `Integer` | Default =  None\n\nThe port of the database server.\nShould use the default port if left empty, for example `5432` for PostgresSQL.\n\n### `LD_DB_OPTIONS`\n\nValues: `String` | Default = `{}`\n\nA json string with additional options for the database. Passed directly to OPTIONS.\n\n### `LD_FAVICON_PROVIDER`\n\nValues: `String` | Default =  `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32`\n\nThe favicon provider used for downloading icons if they are enabled in the user profile settings.\nThe default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.\n\nThis setting allows to configure a custom provider in form of a URL.\nWhen calling the provider with the URL of a website, it must return the image data for the favicon of that website.\nThe configured favicon provider URL must contain a placeholder that will be replaced with the URL of the website for which to download the favicon.\nThe available placeholders are:\n- `{url}` - Includes the scheme and hostname of the website, for example `https://example.com`\n- `{domain}` - Includes only the hostname of the website, for example `example.com`\n\nWhich placeholder you need to use depends on the respective favicon provider, please check their documentation or usage examples.\nSee the default URL for how to insert the placeholder to the favicon provider URL.\n\nAlternative favicon providers:\n- DuckDuckGo: `https://icons.duckduckgo.com/ip3/{domain}.ico`\n\n\n### `LD_SINGLEFILE_TIMEOUT_SEC`\n\nValues: `Float` | Default =  60.0\n\nWhen creating HTML archive snapshots, control the timeout for how long to wait for the snapshot to complete, in `seconds`.\nDefaults to 60 seconds; on lower-powered hardware you may need to increase this value.\n\n### `LD_SINGLEFILE_OPTIONS`\n\nValues: `String` | Default = None\n\nWhen creating HTML archive snapshots, pass additional options to the `single-file` application that is used to create snapshots.\nSee `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js\n\nExample: `LD_SINGLEFILE_OPTIONS=--user-agent=\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0\"`\n\n### `LD_DISABLE_REQUEST_LOGS`\n\nValues: `true` or `false` | Default =  `false`\n\nSet uWSGI [disable-logging](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#disable-logging) parameter to disable request logs, except for requests with a client (4xx) or server (5xx) error response.\n"
  },
  {
    "path": "docs/src/content/docs/search.md",
    "content": "---\ntitle: Search\n---\n\nlinkding provides a comprehensive search function for finding bookmarks. This guide gives on overview of the search capabilities and provides some examples.\n\n## Search Expressions\n\nEvery search query is made up of one or more expressions. An expression can be a single word, a phrase, a tag, or a combination of these using boolean operators. The table below summarizes the different expression types:\n\n| Expression   | Example                            | Description                                                |\n|--------------|------------------------------------|------------------------------------------------------------|\n| Word         | `history`                          | Search for a single word in title, description, notes, URL |\n| Phrase       | `\"history of rome\"`                | Search for an exact phrase by enclosing it in quotes       |\n| Tag          | `#book`                            | Search for a tag                                           |\n| AND operator | `#history and #book`               | Both expressions must match                                |\n| OR operator  | `#book or #article`                | Either expression must match                               |\n| NOT operator | `not #article`                     | Expression must not match                                  |\n| Grouping     | `#history and (#book or #article)` | Control evaluation order using parenthesis                 |\n\nWhen combining multiple words, phrases or tags without an explicit operator, the `and` operator is assumed. For example:\n```\nhistory rome #book\n```\nis equivalent to:\n```\nhistory and rome and #book\n```\n\nSome additional rules to keep in mind:\n- Words, phrases, tags, and operators are all case-insensitive.\n- Tags must be prefixed with a `#` symbol. If the *lax* tag search mode is enabled in the settings, the `#` prefix is optional. In that case searching for a word will return both bookmarks containing that word or bookmarks tagged with that word.\n- An operator (`and`, `or`, `not`) can not be used as a search term as such. To explicitly search for these words, use a phrase: `\"beyond good and evil\"`, `\"good or bad\"`, `\"not found\"`.\n\n## Examples\n\nHere are some example search queries and their meanings:\n\n```\nhistory rome #book\n```\nSearch bookmarks that contain both \"history\" and \"rome\", and are tagged with \"book\".\n\n```\n\"history of rome\" #book\n```\nSearch bookmarks that contain the exact phrase \"history of rome\" and are tagged with \"book\".\n\n```\n#article or #book\n```\nSearch bookmarks that are tagged with either \"article\" or \"book\".\n\n```\nrome (#article or #book)\n```\nSearch bookmarks that contain \"rome\" and are tagged with either \"article\" or \"book\".\n\n```\nhistory rome not #article\n```\nSearch bookmarks that contain both \"history\" and \"rome\", but are not tagged with \"article\".\n\n```\nhistory rome not (#article or #book)\n```\nSearch bookmarks that contain both \"history\" and \"rome\", but are not tagged with either \"article\" or \"book\".\n\n## Legacy Search\n\nA new search engine that supports the above expressions was introduced in linkding v1.44.0.\nIf you run into any issues with the new search, you can switch back to the old one by enabling legacy search in the settings.\nPlease report any issues you encounter with the new search on [GitHub](https://github.com/sissbruecker/linkding/issues) so they can be addressed.\nThis option will be removed in a future version.\n"
  },
  {
    "path": "docs/src/content/docs/shortcuts.md",
    "content": "---\ntitle: \"Keyboard Shortcuts\"\ndescription: \"Keyboard Shortcuts\"\n---\n\nThe following keyboard shortcuts are currently available:\n\n| Action                                                                                    | Shortcut                            |\n|-------------------------------------------------------------------------------------------|-------------------------------------|\n| Add new bookmark                                                                          | <kbd>n</kbd>                        |\n| Focus search input                                                                        | <kbd>s</kbd>                        |\n| Navigate bookmarks                                                                        | <kbd>↑</kbd>, <kbd>↓</kbd>          |\n| Toggle bookmark notes                                                                     | <kbd>e</kbd>                        |\n"
  },
  {
    "path": "docs/src/content/docs/troubleshooting.md",
    "content": "---\ntitle: \"Troubleshooting\"\ndescription: \"Common issues and solutions\"\n---\n\n## Login fails with `403 CSRF verfication failed`\n\nThis can be the case when using a reverse proxy that rewrites the `Host` header, such as Nginx.\nSince linkding version 1.15, the application includes a CSRF check that verifies that the `Origin` request header matches the `Host` header.\nIf the `Host` header is modified by the reverse proxy then this check fails.\n\nTo fix this, check the [reverse proxy setup documentation](/installation#reverse-proxy-setup) on how to configure header forwarding for your proxy server, or alternatively configure the  [`LD_CSRF_TRUSTED_ORIGINS` option](/options#ld_csrf_trusted_origins) to the URL from which you are accessing your linkding instance.\n\n## Automatically detected title and description are incorrect\n\nlinkding automatically fetches the title and description of the web page from the metadata in the HTML `<head>`. By default, this happens on the server, which can return different results than what you see in your browser, for example, if a website uses JavaScript to dynamically change the title or description, or if a website requires login. Alternatively, both the browser extension and the bookmarklet can use the metadata directly from the page you are currently viewing in your browser. Note that for some websites this can give worse results, as not all websites correctly update the metadata in `<head>` while browsing the website (which is why fetching a fresh page on the server is still the default).\n\nTo use the title and description that you see in your browser:\n- When using the linkding browser extension, enable the *Use browser metadata* option in the options of the extension.\n- When adding the bookmarklet, the respective settings page allows you to choose whether to detect title and description from the server or in the browser.\n\n## Archiving fails for certain websites\n\nWhen using the server-based archiving feature (available in the `latest-plus` Docker image), you may encounter issues with certain websites where snapshots fail to capture the web page contents correctly.\nCommon issues include the website showing a bot detection page, a login screen, or some banner that blocks the content.\nIn rare cases taking a snapshot may also time out.\n\nThere are some options to mitigate these issues:\n- To capture web page contents exactly as they appear in your browser, use the [Singlefile browser extension](/archiving#using-the-singlefile-browser-extension) or the [linkding browser extension](/archiving#using-the-linkding-browser-extension) with Singlefile integration.\n- You can pass custom options to the SingleFile CLI, which linkding uses to capture web pages on the server, using the [`LD_SINGLEFILE_OPTIONS`](/options#ld_singlefile_options) environment variable. For example, changing the user agent might help with some bot detection systems.\n- If snapshots are timing out, you can increase the timeout by setting the [`LD_SINGLEFILE_TIMEOUT_SEC`](/options#ld_singlefile_timeout_sec) environment variable to a higher value.\n\nCheck the [archiving documentation](/archiving) for more information on how to archive web pages with linkding.\n\n## URL validation fails for seemingly valid URLs\n\nWhen adding a bookmark, you may encounter URL validation errors even for URLs that seem valid and work in your browser. This is because linkding uses Django's URL validator, which has some limitations in what it considers a valid URL.\n\nCommon cases that may fail validation:\n- Domains that contain an underscore\n- URLs without a top-level domain\n- URLs with a non-standard protocol (e.g. `chrome://`)\n\nIf you need to store URLs that don't pass the default validation, you can disable URL validation completely by setting the `LD_DISABLE_URL_VALIDATION` option to `True`. See the [options documentation](/options#ld_disable_url_validation) for how to configure this setting.\n\nFurther info:\n- https://github.com/sissbruecker/linkding/issues/206\n- https://code.djangoproject.com/ticket/18517\n"
  },
  {
    "path": "docs/src/env.d.ts",
    "content": "/// <reference path=\"../.astro/types.d.ts\" />\n/// <reference types=\"astro/client\" />\n"
  },
  {
    "path": "docs/src/styles/custom.css",
    "content": ":root {\n    --sl-nav-gap: 0.8rem;\n\n    --sl-text-xs: var(--sl-text-sm);\n}\n\n/* Colors (dark mode) */\n:root,\n::backdrop {\n    --sl-color-accent: hsl(241, 75%, 64%);\n    --sl-color-text-accent: hsl(241, 90%, 82%);\n}\n\n/* Colors (light mode) */\n:root[data-theme='light'],\n[data-theme='light'] ::backdrop {\n    --sl-color-accent: hsl(241, 63%, 59%);\n    --sl-color-text-accent: hsl(241, 63%, 55%);\n}\n\n/* Align site search */\n.header .title-wrapper + div.sl-flex {\n    justify-content: flex-end;\n}\n\n/* Site title */\n.site-title img {\n    height: 36px;\n}\n\n/* Social icon size */\n.social-icons svg {\n    width: 20px;\n    height: 20px;\n}\n\n/* Index page */\n[data-has-hero] header {\n    background: transparent;\n    border-bottom: solid 1px transparent;\n    -webkit-backdrop-filter: blur(10px);\n    backdrop-filter: blur(10px);\n}\n\n[data-has-hero] .page {\n    --hero-gradient: hsla(241, 63%, 59%, 0.2);\n    background: linear-gradient(205deg, var(--hero-gradient), transparent 30%);\n}\n\n[data-has-hero][data-theme=\"dark\"] .page {\n    --hero-gradient: hsla(241, 63%, 59%, 0.25);\n}\n\n[data-has-hero] .hero {\n    padding-bottom: 1rem;\n}\n\n[data-has-hero] .hero-image img {\n    width: 100%;\n    border: solid 1px var(--sl-color-gray-5);\n    border-radius: .4rem;\n    box-shadow: var(--sl-shadow-lg);\n}\n\n[data-has-hero] .hero-image.dark img {\n    box-shadow: none;\n}\n\n[data-has-hero][data-theme=\"dark\"] .hero-image.light {\n    display: none;\n}\n\n[data-has-hero][data-theme=\"light\"] .hero-image.dark {\n    display: none;\n}\n\n[data-has-hero] h2 {\n    margin-top: 3rem;\n}\n\n[data-has-hero] .card-grid {\n    margin-top: 2rem !important;\n}\n\n@media (min-width: 50rem) {\n    [data-has-hero] .page {\n        --hero-gradient: hsla(241, 63%, 59%, 0.2);\n        background: linear-gradient(205deg, var(--hero-gradient), transparent 50%);\n    }\n\n    [data-has-hero] .hero {\n        padding-bottom: 4rem;\n    }\n\n    [data-has-hero] h2 {\n        margin-top: 6rem !important;\n    }\n\n    [data-has-hero] .card-grid {\n        margin-top: 3rem !important;\n        gap: 3rem;\n    }\n}"
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n  \"extends\": \"astro/tsconfigs/strict\"\n}"
  },
  {
    "path": "install-linkding.sh",
    "content": "#!/usr/bin/env bash\n\n# Script for creating or updating a linkding installation / Docker container\n# The script uses a number of variables that control how the container is set up\n# and where the application data is stored on the host\n# The following variables are available:\n#\n# LD_CONTAINER_NAME - name of the Docker container that should be created or updated\n# LD_HOST_PORT - port on your system that the application will use\n# LD_HOST_DATA_DIR - directory on your system where the applications database will be stored\n#\n# Variables can be from your shell like this:\n# export LD_HOST_DATA_DIR=/etc/linkding/data\n\n# Provide default variable values\nif [ -z \"${LD_CONTAINER_NAME}\" ]; then\n    LD_CONTAINER_NAME=\"linkding\"\nfi\nif [ -z \"${LD_HOST_PORT}\" ]; then\n    LD_HOST_PORT=9090\nfi\nif [ -z \"${LD_HOST_DATA_DIR}\" ]; then\n    LD_HOST_DATA_DIR=/etc/linkding/data\nfi\n\necho \"Create or update linkding container\"\necho \"Container name: ${LD_CONTAINER_NAME}\"\necho \"Host port: ${LD_HOST_PORT}\"\necho \"Host data dir: ${LD_HOST_DATA_DIR}\"\n\necho \"Stop existing container...\"\ndocker stop ${LD_CONTAINER_NAME} || true\necho \"Remove existing container...\"\ndocker rm ${LD_CONTAINER_NAME} || true\necho \"Update image...\"\ndocker pull sissbruecker/linkding:latest\necho \"Start container...\"\ndocker run -d \\\n  -p ${LD_HOST_PORT}:9090 \\\n  --name ${LD_CONTAINER_NAME} \\\n  -v ${LD_HOST_DATA_DIR}:/etc/linkding/data \\\n  sissbruecker/linkding:latest\necho \"Done!\"\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python\n\"\"\"Django's command-line utility for administrative tasks.\"\"\"\nimport os\nimport sys\n\n\ndef main():\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"bookmarks.settings\")\n    try:\n        from django.core.management import execute_from_command_line\n    except ImportError as exc:\n        raise ImportError(\n            \"Couldn't import Django. Are you sure it's installed and \"\n            \"available on your PYTHONPATH environment variable? Did you \"\n            \"forget to activate a virtual environment?\"\n        ) from exc\n    execute_from_command_line(sys.argv)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"linkding\",\n  \"scripts\": {\n    \"build\": \"npm run build-js && npm run build-theme-light && npm run build-theme-dark\",\n    \"build-js\": \"rollup -c\",\n    \"build-theme-light\": \"postcss -o bookmarks/static/theme-light.css bookmarks/styles/theme-light.css\",\n    \"build-theme-dark\": \"postcss -o bookmarks/static/theme-dark.css bookmarks/styles/theme-dark.css\",\n    \"dev\": \"rollup -c -w\"\n  },\n  \"dependencies\": {\n    \"@floating-ui/dom\": \"^1.7.4\",\n    \"@hotwired/turbo\": \"^8.0.6\",\n    \"@rollup/plugin-node-resolve\": \"^16.0.1\",\n    \"@rollup/plugin-terser\": \"^0.4.4\",\n    \"@rollup/wasm-node\": \"^4.13.0\",\n    \"cssnano\": \"^7.0.6\",\n    \"lit\": \"^3.3.1\",\n    \"postcss\": \"^8.4.45\",\n    \"postcss-cli\": \"^11.0.0\",\n    \"postcss-import\": \"^16.1.0\",\n    \"postcss-nesting\": \"^13.0.0\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.3.3\"\n  },\n  \"web-types\": \"./web-types.json\"\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "const cssnano = require(\"cssnano\");\nconst postcssImport = require(\"postcss-import\");\nconst postcssNesting = require(\"postcss-nesting\");\n\nmodule.exports = {\n  plugins: [\n    postcssImport,\n    postcssNesting,\n    cssnano({\n      preset: \"default\",\n    }),\n  ],\n};\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"linkding\"\nversion = \"1.45.0\"\ndescription = \"Self-hosted bookmark manager that is designed be to be minimal, fast, and easy to set up using Docker.\"\nreadme = \"README.md\"\nrequires-python = \">=3.13\"\ndependencies = [\n    \"beautifulsoup4>=4.14.3\",\n    \"bleach>=6.3.0\",\n    \"bleach-allowlist>=1.0.3\",\n    \"django>=6.0\",\n    \"djangorestframework>=3.16.1\",\n    \"huey>=2.5.5\",\n    \"markdown>=3.10\",\n    \"mozilla-django-oidc>=5.0.2\",\n    \"requests>=2.32.5\",\n    \"supervisor>=4.3.0\",\n    \"uwsgi>=2.0.31\",\n    \"waybackpy>=3.0.6\",\n]\n\n[dependency-groups]\ndev = [\n    \"coverage>=7.13.1\",\n    \"django-debug-toolbar>=6.1.0\",\n    \"djlint>=1.36.4\",\n    \"playwright>=1.57.0\",\n    \"psycopg[binary]>=3.2.9\",\n    \"pytest>=9.0.2\",\n    \"pytest-django>=4.11.1\",\n    \"pytest-xdist>=3.8.0\",\n    \"ruff>=0.14.10\",\n]\n# For PostgreSQL support, use the binary release for development so that not\n# everyone needs to build from source. For production, use a separate dependency\n# group that builds the driver from source. uv also needs to build it from\n# source to update the lockfile, which requires libpq. On macOS:\n# - brew install libpq\n# - export PATH=\"/opt/homebrew/opt/libpq/bin:$PATH\"\n# - uv add --group postgres psycopg[c]\npostgres = [\n    \"psycopg[c]>=3.2.9\",\n]\n\n[tool.uv]\n# Prefer system Python for now, less complications when copying the venv in the Docker build\npython-preference = \"system\"\n\n[tool.djlint]\ncustom_html=\"ld-\\\\w+,ld-\\\\w+-\\\\w+\"\ncustom_blocks=\"formhelp\"\nindent=2\nformat_js=true\nprofile=\"django\"\n\n[tool.djlint.js]\nindent_size=2\n\n[tool.ruff.lint]\nselect = [\n    # pycodestyle\n    \"E\",\n    # Pyflakes\n    \"F\",\n    # pyupgrade\n    \"UP\",\n    # flake8-bugbear\n    \"B\",\n    # flake8-simplify\n    \"SIM\",\n    # isort\n    \"I\",\n]\n# ignore line length violations\nignore = [\"E501\"]\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\nDJANGO_SETTINGS_MODULE = bookmarks.settings.dev\n# -- recommended but optional:\npython_files = tests.py test_*.py *_tests.py\n"
  },
  {
    "path": "rollup.config.mjs",
    "content": "import resolve from '@rollup/plugin-node-resolve';\nimport terser from '@rollup/plugin-terser';\n\nconst production = !process.env.ROLLUP_WATCH;\n\n// Custom plugin to exclude dev-tool.js from production builds\nconst excludeDevTool = {\n  name: 'exclude-dev-tool',\n  load(id) {\n    if (production && id.endsWith('dev-tool.js')) {\n      return '';\n    }\n    return null;\n  },\n};\n\nexport default {\n  input: 'bookmarks/frontend/index.js',\n  output: {\n    sourcemap: true,\n    format: 'iife',\n    name: 'linkding',\n    // Generate bundle in static folder to that it is picked up by Django static files finder\n    file: 'bookmarks/static/bundle.js',\n  },\n  plugins: [\n    excludeDevTool,\n    // If you have external dependencies installed from\n    // npm, you'll most likely need these plugins. In\n    // some cases you'll need additional configuration —\n    // consult the documentation for details:\n    // https://github.com/rollup/rollup-plugin-commonjs\n    resolve({\n      browser: true,\n    }),\n\n    // If we're building for production (npm run build\n    // instead of npm run dev), minify\n    production && terser(),\n  ],\n  watch: {\n    clearScreen: false,\n  },\n};\n"
  },
  {
    "path": "scripts/build-docker.sh",
    "content": "#!/usr/bin/env bash\n\nversion=$(<version.txt)\n\n# Base image\ndocker buildx build --target linkding --platform linux/amd64,linux/arm64,linux/arm/v7 \\\n  -f docker/default.Dockerfile \\\n  -t sissbruecker/linkding:latest \\\n  -t sissbruecker/linkding:$version \\\n  --push .\n\ndocker buildx build --target linkding --platform linux/amd64,linux/arm64,linux/arm/v7 \\\n  -f docker/alpine.Dockerfile \\\n  -t sissbruecker/linkding:latest-alpine \\\n  -t sissbruecker/linkding:$version-alpine \\\n  --push .\n\n# Plus image with support for single-file snapshots\n# Needs checking if this works with ARMv7, excluded for now\ndocker buildx build --target linkding-plus --platform linux/amd64,linux/arm64 \\\n  -f docker/default.Dockerfile \\\n  -t sissbruecker/linkding:latest-plus \\\n  -t sissbruecker/linkding:$version-plus \\\n  --push .\n\ndocker buildx build --target linkding-plus --platform linux/amd64,linux/arm64 \\\n  -f docker/alpine.Dockerfile \\\n  -t sissbruecker/linkding:latest-plus-alpine \\\n  -t sissbruecker/linkding:$version-plus-alpine \\\n  --push .\n"
  },
  {
    "path": "scripts/coverage.sh",
    "content": "#!/usr/bin/env bash\n\nuv run coverage erase\nuv run coverage run manage.py test\nuv run coverage report --sort=cover\n"
  },
  {
    "path": "scripts/generate-changelog.py",
    "content": "#!/usr/bin/env python3\nfrom datetime import datetime\n\nimport requests\n\n\ndef load_releases_page(page):\n    url = f'https://api.github.com/repos/sissbruecker/linkding/releases?page={page}'\n    return requests.get(url).json()\n\n\ndef load_all_releases():\n    load_next_page = True\n    page = 1\n    releases = []\n\n    while load_next_page:\n        page_result = load_releases_page(page)\n        releases = releases + page_result\n        load_next_page = len(page_result) > 0\n        page = page + 1\n\n    return releases\n\n\ndef render_release_section(release):\n    date = datetime.fromisoformat(release['published_at'].replace(\"Z\", \"+00:00\"))\n    formatted_date = date.strftime('%d/%m/%Y')\n    section = f'## {release[\"name\"]} ({formatted_date})\\n\\n'\n    body = release['body']\n    # increase heading for body content\n    body = body.replace(\"## What's Changed\", \"### What's Changed\")\n    body = body.replace(\"## New Contributors\", \"### New Contributors\")\n    section += body.strip()\n    return section\n\n\ndef generate_change_log():\n    releases = load_all_releases()\n\n    change_log = '# Changelog\\n\\n'\n    sections = [render_release_section(release) for release in releases]\n    body = '\\n\\n---\\n\\n'.join(sections)\n    change_log = change_log + body\n\n    with open(\"CHANGELOG.md\", \"w\") as file:\n        file.write(change_log)\n\n\ngenerate_change_log()\n"
  },
  {
    "path": "scripts/release.sh",
    "content": "#!/usr/bin/env bash\n\nversion=$(<version.txt)\n\ngit push origin master\ngit tag v${version}\ngit push origin v${version}\n# ./scripts/build-docker.sh\n\necho \"Done ✅\"\n"
  },
  {
    "path": "scripts/run-docker.sh",
    "content": "#!/usr/bin/env bash\n\nvariant=\"${1:-default}\"\n\ndocker build -f \"docker/$variant.Dockerfile\" --target linkding -t sissbruecker/linkding:local .\n\ndocker rm -f linkding-local || true\n\ndocker run --name linkding-local --rm -p 9090:9090  \\\n  -e LD_SUPERUSER_NAME=admin \\\n  -e LD_SUPERUSER_PASSWORD=admin \\\n  -e LD_SUPERVISOR_MANAGED=True \\\n  sissbruecker/linkding:local\n"
  },
  {
    "path": "scripts/run-postgres.sh",
    "content": "#!/usr/bin/env bash\n\n# Remove previous container if exists\ndocker rm -f linkding-postgres-test || true\n\n# Run postgres container\ndocker run -d \\\n  -e POSTGRES_DB=linkding \\\n  -e POSTGRES_USER=linkding \\\n  -e POSTGRES_PASSWORD=linkding \\\n  -p 5432:5432 \\\n  -v $(pwd)/tmp/postgres-data:/var/lib/postgresql/data \\\n  --name linkding-postgres-test \\\n  postgres\n\n# Wait until postgres has started\necho >&2 \"$(date +%Y%m%dt%H%M%S) Waiting for postgres container\"\nsleep 15\n\n# Start linkding dev server\nexport LD_DB_ENGINE=postgres\nexport LD_DB_USER=linkding\nexport LD_DB_PASSWORD=linkding\n\nexport LD_SUPERUSER_NAME=admin\nexport LD_SUPERUSER_PASSWORD=admin\n\nuv run manage.py migrate\nuv run manage.py create_initial_superuser\nuv run manage.py runserver\n"
  },
  {
    "path": "scripts/setup-ublock.sh",
    "content": "rm -rf uBOLite.chromium.mv3\n\n# Download uBlock Origin Lite\nTAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name')\nDOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/uBOLite_$TAG.chromium.zip\necho \"Downloading $DOWNLOAD_URL\"\ncurl -L -o uBOLite.zip $DOWNLOAD_URL\nunzip uBOLite.zip -d uBOLite.chromium.mv3\nrm uBOLite.zip\n\n# Enable annoyances rulesets in manifest.json\njq '.declarative_net_request.rule_resources |= map(if .id == \"annoyances-overlays\" or .id == \"annoyances-cookies\" or .id == \"annoyances-social\" or .id == \"annoyances-widgets\" or .id == \"annoyances-others\" then .enabled = true else . end)' uBOLite.chromium.mv3/manifest.json > temp.json\nmv temp.json uBOLite.chromium.mv3/manifest.json\n\nmkdir -p chromium-profile\n"
  },
  {
    "path": "scripts/test-environments/authelia-oidc/authelia/configuration.yml",
    "content": "---\n###############################################################\n#                   Authelia configuration                    #\n###############################################################\n\nserver:\n  address: 'tcp://:9091'\n\nlog:\n  level: 'debug'\n\ntotp:\n  issuer: 'authelia.com'\n\nidentity_validation:\n  reset_password:\n    jwt_secret: 'a_very_important_secret'\n\nauthentication_backend:\n  file:\n    path: '/config/users_database.yml'\n\naccess_control:\n  default_policy: 'deny'\n  rules:\n    - domain: 'traefik.example.com'\n      policy: 'one_factor'\n    - domain: 'linkding.example.com'\n      policy: 'one_factor'\n\nsession:\n  secret: 'insecure_session_secret'\n\n  cookies:\n    - name: 'authelia_session'\n      domain: 'example.com'  # Should match whatever your root protected domain is\n      authelia_url: 'https://authelia.example.com'\n      expiration: '1 hour'  # 1 hour\n      inactivity: '5 minutes'  # 5 minutes\n      default_redirection_url: 'https://linkding.example.com'\n\nregulation:\n  max_retries: 3\n  find_time: '2 minutes'\n  ban_time: '5 minutes'\n\nstorage:\n  encryption_key: 'you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this'\n  local:\n    path: '/config/db.sqlite3'\n\nnotifier:\n  filesystem:\n    filename: '/tmp/notification.txt'\n\nidentity_providers:\n  oidc:\n    ## The other portions of the mandatory OpenID Connect 1.0 configuration go here.\n    ## See: https://www.authelia.com/c/oidc\n    hmac_secret: 'this_is_a_secret_abc123abc123abc'\n    jwks:\n      - key_id: 'example'\n        algorithm: 'RS256'\n        use: 'sig'\n        key: |\n          -----BEGIN PRIVATE KEY-----\n          MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCuS2pK2VzqW+Sn\n          hBATps7vo2AdZCtF3p+FOJ4WEwQoiJarS0pAJxKn4BT9PHP1gY8XCs45Qys586xQ\n          UZwS1/9B482tQwkDQkfqXOIfTzqhTydVsi6t8Ff7ywW8K2lURcK+PnSE91Yp8tSO\n          YWlXDajoI8wKkRSpcCApkmBZ3hJiJR9DlcfwKBJSNxt+DbuobQs4SOpjSY4fnDpn\n          S5DFc72hiFOxvdx48y8c08UU+zNyHIIjYQ1995HwXysn7UwWCJaC4lI4ecaxHa01\n          4irOx3HsuXEzs/U5UBs2lBXFfKn/JHAPVJvxlER5ciUCNiHGWWh+A7hrd9BemoMQ\n          kRCIlldJAgMBAAECggEACtSWGmhTFx+Gb/fbeWMjRv4RkAX8T+NHaZN09FVya9Pf\n          ++0p5B5hcQPSPhGqeXoXYoTJ/4IqXpejEJsfngakvosJqe9pURXmatxiczRcxe8J\n          mFBCCQ4vI27wUGroqMNMeH6gRi5p4OGtXlsUfQO06BboXAw7mtNENl0ZhmAPp6BB\n          ZWyQm90Kwx4T0JgNwdlau+9ZWQ/10/7mOs1bX+8vZDFCmzbzFfoPjgEo+Mw1sE/H\n          i5kQxWErkWfeiabVp/7JlazHSYygwk21t1VgSBP4tgfkdAht68BievwguxCIrFRG\n          MPCkgzktJgIfo5k0yuo//afKUKo+OViQ3ZB4YdGKkQKBgQDPgl59fqRZpGCwnKPu\n          ymVi2c/bxjm6aK2VLX2dUFrPprzmfvUY8j/jDDcx4zOJg7jxNs2PGDooLDOSwa82\n          i+YPTnBHlS3PBUp7jLPdCcZ0I8gMT5OWxnmPwGqUS6BqVTAhDq3QsJxD88FS8eD2\n          mbFuBh5WAhj9URX+vc/FwLO/+QKBgQDXBhOXNAB64goQOM7ymUxihLQtYemO9h5N\n          /cXsxpJF8KH/PtWpw9c6nc2d/GPs4OYoCaqsuQVSyQXDcXayNC6Dn8KwUrJP2yGL\n          CHOAGg6HJbq+c5AKE1ytzvblTCyOcHZCjtlqwqJwO68xTWfSdbkvYYnfwyx+g0O9\n          SsoouvzF0QKBgHI2GBnMZVrtbUZnwJbCkVD5/zzAeq+Nw9RyqEu4mXLnG9tljzM+\n          ykkGRS7RFWGfvWAOQM98jy3jPjONJQnJsENGcegERKVIDTm5NJn5MmBj/UxBVENN\n          VET5q++ZPF6qKoZXVPWi7y87b3Fereosp4qeFX5TQzvRsGB4Sm5WZNjJAoGAbezN\n          Vx9en3OvcVuZcKyuQC9XbVwA6vUnyPdTmBhr7xV1u+eDk6ZrAaxq0bmV3COdhhpr\n          BqIP9qKOL7xx0eibXu7tuPaN8gU0wL8xTOwFQVIohfTOTlhXqQOFdPPcU3Vq/9vH\n          iqy2Hmpkxe+shAtrAK38rkg5FvRETSFO+EOftgECgYEAi7nAy4ta2X5hHqt+86Rr\n          OD1M1zdhreF73WvSBIeKiR+rffbgBvIRNFkGk4iYs6Wc6ZyoS+FEJGjO33Om+I1s\n          Emd8JSHhRcRBq6cOsDzo4PKzMVSJaWpAfmCk9wVjAz0gpJDn2MtSanTqn1749A3L\n          VU7Fiz0jxshSPqw2KIjcnBI=\n          -----END PRIVATE KEY-----\n        certificate_chain: |\n          -----BEGIN CERTIFICATE-----\n          MIIDIzCCAgugAwIBAgIQBDUsQ9wfCEtzppdn5GlKUDANBgkqhkiG9w0BAQsFADAy\n          MREwDwYDVQQKEwhBdXRoZWxpYTEdMBsGA1UEAxMUYXV0aGVsaWEuZXhhbXBsZS5j\n          b20wHhcNMjUwODIzMTEwOTM2WhcNMjYwODIzMTEwOTM2WjAyMREwDwYDVQQKEwhB\n          dXRoZWxpYTEdMBsGA1UEAxMUYXV0aGVsaWEuZXhhbXBsZS5jb20wggEiMA0GCSqG\n          SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCuS2pK2VzqW+SnhBATps7vo2AdZCtF3p+F\n          OJ4WEwQoiJarS0pAJxKn4BT9PHP1gY8XCs45Qys586xQUZwS1/9B482tQwkDQkfq\n          XOIfTzqhTydVsi6t8Ff7ywW8K2lURcK+PnSE91Yp8tSOYWlXDajoI8wKkRSpcCAp\n          kmBZ3hJiJR9DlcfwKBJSNxt+DbuobQs4SOpjSY4fnDpnS5DFc72hiFOxvdx48y8c\n          08UU+zNyHIIjYQ1995HwXysn7UwWCJaC4lI4ecaxHa014irOx3HsuXEzs/U5UBs2\n          lBXFfKn/JHAPVJvxlER5ciUCNiHGWWh+A7hrd9BemoMQkRCIlldJAgMBAAGjNTAz\n          MA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8E\n          AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAb910zH+0Yqxxq+LgJiIFC5guJAorY9WlD\n          nRHvt/1i+ZvNdc57Xq6W5/YI1g5IG/EWyDOSr5mkw6VWvGrN/HTE7cH9d2LPyWxb\n          n5dyUezUMdoXmizANJq7ixQLLSJiRFRhYGjiMK816m9zY/3KZqacpTJDsrhM2i6d\n          aaGgfkxpivMDb4PEZs4dDlR5PfFuEBFWpTDBdUeWEx/sL3t1Zfogr6lKb8PmmnEI\n          RKzofXAvAPQ69hE3jSWSldxqgE0Jofzwiw4dcLLAHmLlJDkbB+2HMJljFW9Fj7fK\n          DW7HwcVQqJ4GOW/1IjuogZuDQUlXZPMI3iujoOhYOypx6Wpf4LzO\n          -----END CERTIFICATE-----\n    clients:\n      - client_id: 'linkding'\n        client_name: 'Linkding'\n        client_secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'  # The digest of 'insecure_secret'.\n        public: false\n        authorization_policy: 'one_factor'\n        require_pkce: false\n        pkce_challenge_method: ''\n        redirect_uris:\n          - 'https://linkding.example.com/oidc/callback/'\n        scopes:\n          - 'openid'\n          - 'email'\n          - 'profile'\n        access_token_signed_response_alg: 'none'\n        userinfo_signed_response_alg: 'none'\n        token_endpoint_auth_method: 'client_secret_post'\n...\n"
  },
  {
    "path": "scripts/test-environments/authelia-oidc/authelia/users_database.yml",
    "content": "---\n###############################################################\n#                         Users Database                      #\n###############################################################\n\n# This file can be used if you do not have an LDAP set up.\n\n# List of users\nusers:\n  admin:\n    disabled: false\n    displayname: 'admin'\n    password: '$argon2id$v=19$m=65536,t=3,p=4$t6SNxOtQ6wBnGZUbPu/MVQ$+KCk9Zfc7gemFDRo5a90yfbTkkR0dI5DbeNzYkKxXLE'\n    email: 'admin@example.com'\n    groups:\n      - 'admins'\n      - 'dev'\n...\n"
  },
  {
    "path": "scripts/test-environments/authelia-oidc/compose.yml",
    "content": "# DO NOT USE THIS!\n# This is only intended for testing OIDC functionality when developing Linkding.\n# Follow the linkding and Authelia documentation to set up a proper production deployment.\n\nnetworks:\n  net:\n    driver: 'bridge'\n\nservices:\n  linkding:\n    image: 'sissbruecker/linkding:local'\n    environment:\n      LD_SUPERUSER_NAME: 'admin'\n      LD_SUPERUSER_PASSWORD: 'admin'\n      LD_ENABLE_OIDC: 'True'\n      OIDC_RP_CLIENT_ID: 'linkding'\n      OIDC_RP_CLIENT_SECRET: 'insecure_secret'\n      OIDC_OP_AUTHORIZATION_ENDPOINT: 'https://authelia.example.com/api/oidc/authorization'\n      OIDC_OP_TOKEN_ENDPOINT: 'http://authelia:9091/api/oidc/token'\n      OIDC_OP_USER_ENDPOINT: 'http://authelia:9091/api/oidc/userinfo'\n      OIDC_OP_JWKS_ENDPOINT: 'http://authelia:9091/jwks.json'\n    networks:\n      net: {}\n    labels:\n      traefik.enable: 'true'\n      traefik.http.routers.public.rule: 'Host(`linkding.example.com`)'\n      traefik.http.routers.public.entrypoints: 'https'\n      traefik.http.routers.public.tls: 'true'\n      traefik.http.routers.public.tls.options: 'default'\n  authelia:\n    image: 'authelia/authelia:latest'\n    volumes:\n      - './authelia:/config'\n    networks:\n      net: {}\n    labels:\n      traefik.enable: 'true'\n      traefik.http.routers.authelia.rule: 'Host(`authelia.example.com`)'\n      traefik.http.routers.authelia.entrypoints: 'https'\n      traefik.http.routers.authelia.tls: 'true'\n      traefik.http.routers.authelia.tls.options: 'default'\n  traefik:\n    image: 'traefik:v3.5.0'\n    volumes:\n      - './traefik:/etc/traefik'\n      - '/var/run/docker.sock:/var/run/docker.sock'\n    networks:\n      net: {}\n    labels:\n      traefik.enable: 'true'\n      traefik.http.routers.api.rule: 'Host(`traefik.example.com`)'\n      traefik.http.routers.api.entrypoints: 'https'\n      traefik.http.routers.api.service: 'api@internal'\n      traefik.http.routers.api.tls: 'true'\n      traefik.http.routers.api.tls.options: 'default'\n    ports:\n      - '80:80/tcp'\n      - '443:443/tcp'\n      - '443:443/udp'\n    command:\n      - '--api'\n      - '--providers.docker=true'\n      - '--providers.docker.exposedByDefault=false'\n      - '--providers.file.filename=/etc/traefik/certificates.yml'\n      - '--entrypoints.http=true'\n      - '--entrypoints.http.address=:80'\n      - '--entrypoints.http.http.redirections.entrypoint.to=https'\n      - '--entrypoints.http.http.redirections.entrypoint.scheme=https'\n      - '--entrypoints.https=true'\n      - '--entrypoints.https.address=:443'\n      - '--log=true'\n      - '--log.level=DEBUG'\n"
  },
  {
    "path": "scripts/test-environments/authelia-oidc/setup.sh",
    "content": "#!/usr/bin/env bash\n\nwritehosts(){\n  echo \"\\\n127.0.0.1  authelia.$DOMAIN\n127.0.0.1  linkding.$DOMAIN\n127.0.0.1  traefik.$DOMAIN\" | sudo tee -a /etc/hosts > /dev/null\n}\n\nif [ $(id -u) != 0 ]; then\n  echo \"The script requires root access to perform some functions such as modifying your /etc/hosts file\"\n  read -rp \"Would you like to elevate access with sudo? [y/N] \" confirmsudo\n  if ! [[ \"$confirmsudo\" =~ ^([yY][eE][sS]|[yY])$ ]]; then\n    echo \"Sudo elevation denied, exiting\"\n    exit 1\n  fi\nfi\n\nDOMAIN=\"example.com\"\n\nMODIFIED=$(cat /etc/hosts | grep $DOMAIN && echo true || echo false)\n\nif [[ $MODIFIED == \"false\" ]]; then\n  writehosts\nfi\n\nsudo docker run -a stdout -v $PWD/traefik/certs:/tmp/certs authelia/authelia authelia crypto certificate rsa generate --common-name=\"*.${DOMAIN}\" --directory=/tmp/certs/ > /dev/null\necho \"Generated SSL certificate for *.$DOMAIN\"\n\ncat << EOF\nSetup completed successfully.\n\nYou can start the stack with:\n  docker compose up\n\nYou can then visit the following locations:\n- https://linkding.$DOMAIN\n- https://authelia.$DOMAIN\n- https://traefik.$DOMAIN\n\nYou will need to authorize the self-signed certificate upon visiting each domain.\nEOF\n"
  },
  {
    "path": "scripts/test-environments/authelia-oidc/traefik/certificates.yml",
    "content": "---\ntls:\n  certificates:\n    - certFile: '/etc/traefik/certs/public.crt'\n      keyFile: '/etc/traefik/certs/private.pem'\n...\n"
  },
  {
    "path": "scripts/test-environments/postgres/compose.yml",
    "content": "# DO NOT USE THIS!\n# This is only intended for testing PostgreSQL functionality when developing Linkding.\n# Follow the linkding documentation to set up a proper production deployment.\n\nservices:\n  postgres:\n    image: 'postgres:16'\n    environment:\n      POSTGRES_DB: linkding\n      POSTGRES_USER: linkding\n      POSTGRES_PASSWORD: linkding\n    healthcheck:\n      test: ['CMD-SHELL', 'pg_isready -U linkding -d linkding']\n      interval: 10s\n      timeout: 5s\n      retries: 5\n\n  linkding:\n    image: 'sissbruecker/linkding:local'\n    environment:\n      LD_SUPERUSER_NAME: 'admin'\n      LD_SUPERUSER_PASSWORD: 'admin'\n      LD_DB_ENGINE: 'postgres'\n      LD_DB_HOST: 'postgres'\n      LD_DB_PORT: '5432'\n      LD_DB_DATABASE: 'linkding'\n      LD_DB_USER: 'linkding'\n      LD_DB_PASSWORD: 'linkding'\n    ports:\n      - '9090:9090'\n    depends_on:\n      postgres:\n        condition: service_healthy"
  },
  {
    "path": "scripts/test-postgres.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\n# Remove previous container if exists\ndocker rm -f linkding-postgres-test || true\n\n# Run postgres container\ndocker run -d \\\n  -e POSTGRES_DB=linkding \\\n  -e POSTGRES_USER=linkding \\\n  -e POSTGRES_PASSWORD=linkding \\\n  -p 5432:5432 \\\n  --name linkding-postgres-test \\\n  postgres\n\n# Wait until postgres has started\necho >&2 \"$(date +%Y%m%dt%H%M%S) Waiting for postgres container\"\nsleep 15\n\n# Run tests using postgres\nexport LD_DB_ENGINE=postgres\nexport LD_DB_USER=linkding\nexport LD_DB_PASSWORD=linkding\n\nmake test\nmake e2e\n\n# Remove postgres container\ndocker rm -f linkding-postgres-test || true\n"
  },
  {
    "path": "supervisord-all.conf",
    "content": "# Supervisor config that manages both uwsgi and background jobs\n# Used when LD_SUPERVISOR_MANAGED is enabled, which also enables logging background tasks output to container logs\n[supervisord]\nuser=root\nnodaemon=true\nloglevel=info\n\n[program:uwsgi]\ncommand=/bin/bash -c 'uwsgi --http ${LD_SERVER_HOST:-[::]}:${LD_SERVER_PORT:-9090} uwsgi.ini'\nstdout_logfile=/dev/fd/1\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/fd/2\nstderr_logfile_maxbytes=0\nstopasgroup=true\nautorestart=true\n\n[program:jobs]\nuser=www-data\n# setup a temp home folder for the job, required by chromium\nenvironment=HOME=/tmp/home\ncommand=python manage.py run_huey -f\nstdout_logfile=/dev/fd/1\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/fd/2\nstderr_logfile_maxbytes=0\nstopasgroup=true\nautorestart=true\n\n[unix_http_server]\nfile=/var/run/supervisor.sock\nchmod=0700\n\n[rpcinterface:supervisor]\nsupervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface\n\n[supervisorctl]\nserverurl=unix:///var/run/supervisor.sock\n"
  },
  {
    "path": "supervisord-tasks.conf",
    "content": "[supervisord]\nuser=root\nloglevel=info\n\n[program:jobs]\nuser=www-data\n# setup a temp home folder for the job, required by chromium\nenvironment=HOME=/tmp/home\ncommand=python manage.py run_huey -f\nstdout_logfile=background_tasks.log\nstdout_logfile_maxbytes=10MB\nstdout_logfile_backups=5\nredirect_stderr=true\n\n[unix_http_server]\nfile=/var/run/supervisor.sock\nchmod=0700\n\n[rpcinterface:supervisor]\nsupervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface\n\n[supervisorctl]\nserverurl=unix:///var/run/supervisor.sock\n"
  },
  {
    "path": "uwsgi.ini",
    "content": "[uwsgi]\nmodule = bookmarks.wsgi:application\nenv = DJANGO_SETTINGS_MODULE=bookmarks.settings.prod\nstatic-map = /static=static\nstatic-map = /static=data/favicons\nstatic-map = /static=data/previews\nstatic-map = /robots.txt=static/robots.txt\nprocesses = 2\nthreads = 2\npidfile = /tmp/linkding.pid\nvacuum=True\nstats = 127.0.0.1:9191\nuid = www-data\ngid = www-data\nbuffer-size = 8192\ndie-on-term = true\n\nif-env = LD_CONTEXT_PATH\nstatic-map = /%(_)static=static\nstatic-map = /%(_)static=data/favicons\nstatic-map = /%(_)static=data/previews\nstatic-map = /%(_)robots.txt=static/robots.txt\nendif =\n\nif-env = LD_REQUEST_TIMEOUT\nhttp-timeout = %(_)\nsocket-timeout = %(_)\nharakiri = %(_)\nendif =\n\nif-env = LD_REQUEST_MAX_CONTENT_LENGTH\nlimit-post = %(_)\nendif =\n\nif-env = LD_LOG_X_FORWARDED_FOR\nlog-x-forwarded-for = %(_)\nendif =\n\nif-env = LD_DISABLE_REQUEST_LOGS=true\ndisable-logging = true\nlog-4xx = true\nlog-5xx = true\nendif =\n"
  },
  {
    "path": "version.txt",
    "content": "1.45.0\n"
  }
]