Compare commits

..

45 Commits

Author SHA1 Message Date
Sascha Ißbrücker
128e1afbce Fix ublock setup 2025-09-28 10:38:56 +02:00
Sascha Ißbrücker
d33719dc7c Bump version 2025-09-28 09:22:00 +02:00
dependabot[bot]
357c2d1399 Bump vite from 6.3.5 to 6.3.6 in /docs (#1184)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 19:36:31 +02:00
Sascha Ißbrücker
9cda5a54d3 Add href parsing test 2025-08-27 08:45:20 +02:00
Sascha Ißbrücker
67d5b17450 Fix filter background in dark theme 2025-08-27 08:31:49 +02:00
Sascha Ißbrücker
3ec6c0a7f8 Hide tag menu for unauthenticated users (#1176) 2025-08-26 19:06:04 +02:00
Sascha Ißbrücker
86c2bdd138 Update test build 2025-08-26 12:14:30 +02:00
Sascha Ißbrücker
82e5b7d9d5 Add basic tag management (#1175) 2025-08-26 12:01:36 +02:00
Sascha Ißbrücker
d873342105 Replace Svelte components with Lit elements (#1174) 2025-08-24 12:28:15 +02:00
Sascha Ißbrücker
d519cb74eb Bump versions (#1173)
* Bump versions

* Bump NPM versions, update to Svelte 5

* try improve flaky test

* bump single-file-cli, remove ublock origin workaround

* bump base images

* replace libssl3
2025-08-24 12:10:17 +02:00
Sascha Ißbrücker
ff0e6f0ff6 Add test environment 2025-08-24 09:31:17 +02:00
Sascha Ißbrücker
77c45c63f3 Add authelia OIDC test setup 2025-08-23 13:50:17 +02:00
Sascha Ißbrücker
e45e63bfb1 Fix psycopg install 2025-08-23 10:50:10 +02:00
Sascha Ißbrücker
004319adae Install uv via installer 2025-08-23 07:58:26 +02:00
Sascha Ißbrücker
d8358f1b12 Add preview build 2025-08-23 07:41:34 +02:00
Sascha Ißbrücker
b90ae1b202 Switch to uv (#1172) 2025-08-23 07:37:25 +02:00
Sascha Ißbrücker
6c874afff2 Add option to mark bookmarks as shared by default (#1170)
* Add option to mark bookmarks as shared by default

* add migration
2025-08-22 20:05:56 +02:00
Sascha Ißbrücker
723b843c13 Normalize URLs when checking for duplicates (#1169)
* Normalize URLs when checking for duplicates

* Improve migration script
2025-08-22 19:37:28 +02:00
Per Mortensen
96176ba50e Fix bookmark asset admin search error (#1162) 2025-08-22 10:03:20 +02:00
dependabot[bot]
f6fb46e8ad Bump astro from 5.12.8 to 5.13.2 in /docs (#1166)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.12.8 to 5.13.2.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.13.2/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.13.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 09:57:47 +02:00
Sascha Ißbrücker
3804640574 Use modal dialog for confirming actions (#1168)
* Use modal dialog for confirming actions

* cleanup unused state
2025-08-22 09:57:31 +02:00
FireFingers21
8f61fbd04a Add alfred-linkding-bookmarks to community.md (#1160) 2025-08-16 21:45:53 +02:00
Per Mortensen
22bc713ed8 Document API bundle filter (#1161) 2025-08-16 21:40:03 +02:00
Sascha Ißbrücker
04248a7fba Bump version 2025-08-16 07:31:30 +02:00
Sascha Ißbrücker
0ff36a94fe Add alternative bookmarklet that uses browser metadata (#1159) 2025-08-16 07:29:53 +02:00
Sascha Ißbrücker
f83eb25569 Submit bookmark form with Ctrl/Cmd + Enter (#1158) 2025-08-16 06:20:07 +02:00
thR CIrcU5
c746afcf76 Bulk create HTML snapshots (#1132)
* Add option to create HTML snapshot for bulk edit

* Add the prerequisite for displaying the "Create HTML Snapshot" bulk action option

* Add test case

This test case covers the scenario where the bulk actions panel displays the corresponding options when the HTML snapshot feature is enabled.

* Use the existing `tasks.create_html_snapshots()` instead of the for loop

* Fix the exposure of `settings.LD_ENABLE_SNAPSHOTS` within `BookmarkListContext`

* add service tests

* cleanup context

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-08-12 23:06:23 +02:00
Sascha Ißbrücker
aaa0f6e119 Run formatter 2025-08-11 08:05:50 +02:00
Sascha Ißbrücker
cd215a9237 Create bundle from current search query (#1154) 2025-08-10 22:45:28 +02:00
Sascha Ißbrücker
1e56b0e6f3 Ignore tags that exceed length limit during import (#1153) 2025-08-10 15:05:10 +02:00
Sascha Ißbrücker
5cc8c9c010 Allow filtering feeds by bundle (#1152) 2025-08-10 12:59:55 +02:00
Pedro Lima
846808d870 Ignore tags with just whitespace (#1125) 2025-08-10 10:20:03 +02:00
Sascha Ißbrücker
6d9a694756 Wrap long titles in bookmark details modal (#1150) 2025-08-10 10:05:46 +02:00
Per Mortensen
de38e56b3f Add linkding-media-archiver to community.md (#1144)
Adds a new project link to the community page
2025-08-10 09:11:42 +02:00
dependabot[bot]
c6fb695af2 Bump astro from 5.7.13 to 5.12.8 in /docs (#1147)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.7.13 to 5.12.8.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.12.8/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.12.8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-10 09:09:09 +02:00
Per Mortensen
93faf70b37 Use filename when downloading asset through UI (#1146) 2025-08-10 08:38:18 +02:00
hkclark
5330252db9 Add Pocket migration to to community page (#1112)
* Add Pocket migration to to community page

* Fix order

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-07-23 03:17:45 +02:00
Ben Oakes
ef00d289f5 Add CloudBreak on Managed Hosting (#1079)
* Add CloudBreak on Managed Hosting

* Use new path
2025-07-23 03:15:26 +02:00
Sascha Ißbrücker
4e8318d0ae Improve bookmark form accessibility (#1116)
* Bump Django

* Render error messages in English

* Remove unused USE_L10N option

* Associate errors and help texts with form fields

* Make checkbox inputs clickable

* Change cancel button text

* Fix tests
2025-07-03 08:44:41 +02:00
Sascha Ißbrücker
a8623d11ef Update order when deleting bundle (#1114) 2025-07-01 07:09:02 +02:00
Sascha Ißbrücker
8cd992ca30 Show bookmark bundles in admin (#1110) 2025-06-25 19:37:34 +02:00
Sascha Ißbrücker
68c104ba54 Fix custom CSS not being used in reader mode (#1102) 2025-06-20 06:22:08 +02:00
hkclark
7a4236d179 Automatically compress uploads with gzip (#1087)
* Gzip .html upload, tests for .html & .gz uploads

* Gzip all file types that aren't already gzips

* Show filename of what user uploaded before compression

* Remove line I thought we need but we don't

* cleanup and fix tests

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-06-20 06:15:25 +02:00
Sascha Ißbrücker
e87304501f Add date and time to HTML export filename (#1101) 2025-06-20 06:01:15 +02:00
Sascha Ißbrücker
809e9e02f3 Update CHANGELOG.md 2025-06-20 00:38:18 +02:00
148 changed files with 5937 additions and 2066 deletions

View File

@@ -10,10 +10,10 @@
!/package.json !/package.json
!/package-lock.json !/package-lock.json
!/postcss.config.js !/postcss.config.js
!/requirements.dev.txt !/pyproject.toml
!/requirements.txt
!/rollup.config.mjs !/rollup.config.mjs
!/supervisord.conf !/supervisord.conf
!/uv.lock
!/uwsgi.ini !/uwsgi.ini
!/version.txt !/version.txt

73
.github/workflows/build-test.yaml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: build-test
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build latest
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/sissbruecker/linkding:test
target: linkding
push: true
- name: Build latest-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/sissbruecker/linkding:test-alpine
target: linkding
push: true
- name: Build latest-plus
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/sissbruecker/linkding:test-plus
target: linkding-plus
push: true
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/sissbruecker/linkding:test-plus-alpine
target: linkding-plus
push: true

View File

@@ -15,7 +15,9 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -25,10 +27,10 @@ jobs:
run: npm ci run: npm ci
- name: Setup Python environment - name: Setup Python environment
run: | run: |
pip install -r requirements.txt -r requirements.dev.txt uv sync
mkdir data mkdir data
- name: Run tests - name: Run tests
run: python manage.py test bookmarks.tests run: uv run manage.py test bookmarks.tests
e2e_tests: e2e_tests:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -37,7 +39,9 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -47,12 +51,12 @@ jobs:
run: npm ci run: npm ci
- name: Setup Python environment - name: Setup Python environment
run: | run: |
pip install -r requirements.txt -r requirements.dev.txt uv sync
playwright install chromium uv run playwright install chromium
mkdir data mkdir data
- name: Run build - name: Run build
run: | run: |
npm run build npm run build
python manage.py collectstatic uv run manage.py collectstatic
- name: Run tests - name: Run tests
run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py" run: uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"

4
.gitignore vendored
View File

@@ -196,3 +196,7 @@ typings/
/chromium-profile /chromium-profile
# direnv # direnv
/.direnv /.direnv
# Test setups
/scripts/unsecure-test-setups/authelia-oidc/authelia/db.sqlite3
/scripts/unsecure-test-setups/authelia-oidc/traefik/certs

View File

@@ -1,5 +1,26 @@
# Changelog # Changelog
## v1.41.0 (19/06/2025)
### What's Changed
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
### New Contributors
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
---
## v1.40.0 (17/05/2025) ## v1.40.0 (17/05/2025)
### What's Changed ### What's Changed

View File

@@ -1,15 +1,23 @@
.PHONY: serve .PHONY: serve
init:
uv sync
uv run manage.py migrate
npm install
serve: serve:
python manage.py runserver uv run manage.py runserver
tasks: tasks:
python manage.py run_huey uv run manage.py run_huey
test: test:
pytest -n auto uv run pytest -n auto
format: format:
black bookmarks uv run black bookmarks
npx prettier bookmarks/frontend --write npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write npx prettier bookmarks/styles --write
frontend:
npm run dev

View File

@@ -61,43 +61,31 @@ Small improvements, bugfixes and documentation improvements are always welcome.
The 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 🙂. The 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 🙂.
### Prerequisites ### Prerequisites
- Python 3.12 - Python 3.13
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Node.js - Node.js
### Setup ### Setup
Create a virtual environment for the application (https://docs.python.org/3/tutorial/venv.html): Initialize the development environment with:
``` ```
python3 -m venv ~/environments/linkding make init
```
Activate the environment for your shell:
```
source ~/environments/linkding/bin/activate[.csh|.fish]
```
Within the active environment install the application dependencies from the application folder:
```
pip3 install -r requirements.txt -r requirements.dev.txt
```
Install frontend dependencies:
```
npm install
```
Initialize database:
```
mkdir -p data
python3 manage.py migrate
``` ```
This sets up a virtual environment using uv, installs NPM dependencies and runs migrations to create the initial database.
Create a user for the frontend: Create a user for the frontend:
``` ```
python3 manage.py createsuperuser --username=joe --email=joe@example.com uv run manage.py createsuperuser --username=joe --email=joe@example.com
``` ```
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
Run the frontend build for bundling frontend components with:
``` ```
npm run dev make frontend
``` ```
Start the Django development server with:
Then start the Django development server with:
``` ```
python3 manage.py runserver make serve
``` ```
The frontend is now available under http://localhost:8000 The frontend is now available under http://localhost:8000
@@ -117,6 +105,11 @@ make format
### DevContainers ### DevContainers
> [!WARNING]
> The dev container setup is currently broken after switching to uv.
> Feel free to contribute a PR if you want to fix it.
> The instructions below are outdated until then.
This 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) This 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)
Once checked out, only the following commands are required to get started: Once checked out, only the following commands are required to get started:

View File

@@ -11,7 +11,15 @@ from huey.contrib.djhuey import HUEY as huey
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
Tag,
UserProfile,
Toast,
FeedToken,
)
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -206,7 +214,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("custom_display_name", "date_created", "status") list_display = ("custom_display_name", "date_created", "status")
search_fields = ( search_fields = (
"custom_display_name", "display_name",
"file", "file",
) )
list_filter = ("status",) list_filter = ("status",)
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
) )
class AdminBookmarkBundle(admin.ModelAdmin):
list_display = (
"name",
"owner",
"order",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"date_created",
)
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
list_filter = ("owner__username",)
class AdminUserProfileInline(admin.StackedInline): class AdminUserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
can_delete = False can_delete = False
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset) linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(Toast, AdminToast)

View File

@@ -26,7 +26,8 @@ from bookmarks.models import (
User, User,
BookmarkBundle, BookmarkBundle,
) )
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.utils import normalize_url
from bookmarks.type_defs import HttpRequest from bookmarks.type_defs import HttpRequest
from bookmarks.views import access from bookmarks.views import access
@@ -107,7 +108,10 @@ class BookmarkViewSet(
def check(self, request: HttpRequest): def check(self, request: HttpRequest):
url = request.GET.get("url") url = request.GET.get("url")
ignore_cache = request.GET.get("ignore_cache", False) in ["true"] ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() normalized_url = normalize_url(url)
bookmark = Bookmark.objects.filter(
owner=request.user, url_normalized=normalized_url
).first()
existing_bookmark_data = ( existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None self.get_serializer(bookmark).data if bookmark else None
) )
@@ -151,7 +155,10 @@ class BookmarkViewSet(
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() normalized_url = normalize_url(url)
bookmark = Bookmark.objects.filter(
owner=request.user, url_normalized=normalized_url
).first()
if not bookmark: if not bookmark:
bookmark = Bookmark(url=url) bookmark = Bookmark(url=url)
@@ -199,13 +206,10 @@ class BookmarkAssetViewSet(
if asset.gzip if asset.gzip
else open(file_path, "rb") else open(file_path, "rb")
) )
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
)
response = StreamingHttpResponse(file_stream, content_type=content_type) response = StreamingHttpResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{file_name}"' response["Content-Disposition"] = (
f'attachment; filename="{asset.download_name}"'
)
return response return response
except FileNotFoundError: except FileNotFoundError:
raise Http404("Asset file does not exist") raise Http404("Asset file does not exist")
@@ -290,6 +294,9 @@ class BookmarkBundleViewSet(
def get_serializer_context(self): def get_serializer_context(self):
return {"user": self.request.user} return {"user": self.request.user}
def perform_destroy(self, instance):
bundles.delete_bundle(instance)
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/ # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
# Instead create separate routers for each view set and manually register them in urls.py # Instead create separate routers for each view set and manually register them in urls.py

View File

@@ -11,7 +11,7 @@ from bookmarks.models import (
UserProfile, UserProfile,
BookmarkBundle, BookmarkBundle,
) )
from bookmarks.services import bookmarks from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version from bookmarks.utils import app_version
@@ -55,17 +55,9 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
] ]
def create(self, validated_data): def create(self, validated_data):
# Set owner to the authenticated user bundle = BookmarkBundle(**validated_data)
validated_data["owner"] = self.context["user"] bundle.order = validated_data["order"] if "order" in validated_data else None
return bundles.create_bundle(bundle, self.context["user"])
# Set order to the next available position if not provided
if "order" not in validated_data:
max_order = BookmarkBundle.objects.filter(
owner=self.context["user"]
).aggregate(Max("order", default=-1))["order__max"]
validated_data["order"] = max_order + 1
return super().create(validated_data)
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):

View File

@@ -8,6 +8,7 @@ from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
from bookmarks.views import access
@dataclass @dataclass
@@ -30,10 +31,16 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed): class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str | None): def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
bundle = None
bundle_id = request.GET.get("bundle")
if bundle_id:
bundle = access.bundle_read(request, bundle_id)
search = BookmarkSearch( search = BookmarkSearch(
q=request.GET.get("q", ""), q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""), unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""), shared=request.GET.get("shared", ""),
bundle=bundle,
) )
query_set = self.get_query_set(feed_token, search) query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set) return FeedContext(request, feed_token, query_set)

View File

@@ -1,9 +1,22 @@
from django import forms from django import forms
from django.forms.utils import ErrorList
from django.utils import timezone
from bookmarks.models import Bookmark, build_tag_string from bookmarks.models import (
from bookmarks.validators import BookmarkURLValidator Bookmark,
from bookmarks.type_defs import HttpRequest Tag,
build_tag_string,
parse_tag_string,
sanitize_tag_name,
)
from bookmarks.services.bookmarks import create_bookmark, update_bookmark from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.type_defs import HttpRequest
from bookmarks.utils import normalize_url
from bookmarks.validators import BookmarkURLValidator
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
class BookmarkForm(forms.ModelForm): class BookmarkForm(forms.ModelForm):
@@ -44,11 +57,14 @@ class BookmarkForm(forms.ModelForm):
"tag_string": request.GET.get("tags"), "tag_string": request.GET.get("tags"),
"auto_close": "auto_close" in request.GET, "auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread, "unread": request.user_profile.default_mark_unread,
"shared": request.user_profile.default_mark_shared,
} }
if instance is not None and request.method == "GET": if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")} initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None data = request.POST if request.method == "POST" else None
super().__init__(data, instance=instance, initial=initial) super().__init__(
data, instance=instance, initial=initial, error_class=CustomErrorList
)
@property @property
def is_auto_close(self): def is_auto_close(self):
@@ -78,8 +94,11 @@ class BookmarkForm(forms.ModelForm):
# raise a validation error in that case. # raise a validation error in that case.
url = self.cleaned_data["url"] url = self.cleaned_data["url"]
if self.instance.pk: if self.instance.pk:
normalized_url = normalize_url(url)
is_duplicate = ( is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url) Bookmark.objects.filter(
owner=self.instance.owner, url_normalized=normalized_url
)
.exclude(pk=self.instance.pk) .exclude(pk=self.instance.pk)
.exists() .exists()
) )
@@ -93,3 +112,88 @@ def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings # strings
return tag_string.replace(" ", ",") return tag_string.replace(" ", ",")
class TagForm(forms.ModelForm):
class Meta:
model = Tag
fields = ["name"]
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
self.user = user
def clean_name(self):
name = self.cleaned_data.get("name", "").strip()
name = sanitize_tag_name(name)
queryset = Tag.objects.filter(name__iexact=name, owner=self.user)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise forms.ValidationError(f'Tag "{name}" already exists.')
return name
def save(self, commit=True):
tag = super().save(commit=False)
if not self.instance.pk:
tag.owner = self.user
tag.date_added = timezone.now()
else:
tag.date_modified = timezone.now()
if commit:
tag.save()
return tag
class TagMergeForm(forms.Form):
target_tag = forms.CharField()
merge_tags = forms.CharField()
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
self.user = user
def clean_target_tag(self):
target_tag_name = self.cleaned_data.get("target_tag", "")
target_tag_names = parse_tag_string(target_tag_name, " ")
if len(target_tag_names) != 1:
raise forms.ValidationError(
"Please enter only one tag name for the target tag."
)
target_tag_name = target_tag_names[0]
try:
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
except Tag.DoesNotExist:
raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.')
return target_tag
def clean_merge_tags(self):
merge_tags_string = self.cleaned_data.get("merge_tags", "")
merge_tag_names = parse_tag_string(merge_tags_string, " ")
if not merge_tag_names:
raise forms.ValidationError("Please enter at least one tag to merge.")
merge_tags = []
for tag_name in merge_tag_names:
try:
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
merge_tags.append(tag)
except Tag.DoesNotExist:
raise forms.ValidationError(f'Tag "{tag_name}" does not exist.')
target_tag = self.cleaned_data.get("target_tag")
if target_tag and target_tag in merge_tags:
raise forms.ValidationError(
"The target tag cannot be selected for merging."
)
return merge_tags

View File

@@ -1,79 +1,173 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
let confirmId = 0;
function nextConfirmId() {
return `confirm-${confirmId++}`;
}
class ConfirmButtonBehavior extends Behavior { class ConfirmButtonBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick); this.element.addEventListener("click", this.onClick);
} }
destroy() { destroy() {
this.reset(); if (this.opened) {
this.close();
}
this.element.removeEventListener("click", this.onClick); this.element.removeEventListener("click", this.onClick);
} }
onClick(event) { onClick(event) {
event.preventDefault(); event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span"); if (this.opened) {
container.className = "confirmation"; this.close();
} else {
const icon = this.element.getAttribute("ld-confirm-icon"); this.open();
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
} }
const question = this.element.getAttribute("ld-confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn"))
.join(" ");
const cancelButton = document.createElement(this.element.nodeName);
cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.type;
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
container.append(cancelButton, confirmButton);
this.container = container;
this.element.before(container);
this.element.classList.add("d-none");
} }
reset() { open() {
setTimeout(() => { const dropdown = document.createElement("div");
Behavior.interacting = false; dropdown.className = "dropdown confirm-dropdown active";
if (this.container) {
this.container.remove(); const confirmId = nextConfirmId();
this.container = null; const questionId = `${confirmId}-question`;
}
this.element.classList.remove("d-none"); const menu = document.createElement("div");
menu.className = "menu with-arrow";
menu.role = "alertdialog";
menu.setAttribute("aria-modal", "true");
menu.setAttribute("aria-labelledby", questionId);
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
const question = document.createElement("span");
question.id = questionId;
question.textContent =
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
question.style.fontWeight = "bold";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.type = "button";
cancelButton.className = "btn";
cancelButton.tabIndex = 0;
cancelButton.addEventListener("click", () => this.close());
const confirmButton = document.createElement("button");
confirmButton.textContent = "Confirm";
confirmButton.type = "submit";
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.className = "btn btn-error";
confirmButton.addEventListener("click", () => this.confirm());
const arrow = document.createElement("div");
arrow.className = "menu-arrow";
menu.append(question, cancelButton, confirmButton, arrow);
dropdown.append(menu);
document.body.append(dropdown);
this.positionController = new AnchorPositionController(this.element, menu);
this.focusTrap = new FocusTrapController(menu);
this.dropdown = dropdown;
this.opened = true;
}
onMenuKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.close();
}
}
confirm() {
this.element.closest("form").requestSubmit(this.element);
this.close();
}
close() {
if (!this.opened) return;
this.positionController.destroy();
this.focusTrap.destroy();
this.dropdown.remove();
this.element.focus({ focusVisible: isKeyboardActive() });
this.opened = false;
}
}
class AnchorPositionController {
constructor(anchor, overlay) {
this.anchor = anchor;
this.overlay = overlay;
this.handleScroll = this.handleScroll.bind(this);
window.addEventListener("scroll", this.handleScroll, { capture: true });
this.updatePosition();
}
handleScroll() {
if (this.debounce) {
return;
}
this.debounce = true;
requestAnimationFrame(() => {
this.updatePosition();
this.debounce = false;
}); });
} }
updatePosition() {
const anchorRect = this.anchor.getBoundingClientRect();
const overlayRect = this.overlay.getBoundingClientRect();
const bufferX = 10;
const bufferY = 30;
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
const initialLeft = left;
const overflowLeft = left < bufferX;
const overflowRight =
left + overlayRect.width > window.innerWidth - bufferX;
if (overflowLeft) {
left = bufferX;
} else if (overflowRight) {
left = window.innerWidth - overlayRect.width - bufferX;
}
const delta = initialLeft - left;
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
let top = anchorRect.bottom;
const overflowBottom =
top + overlayRect.height > window.innerHeight - bufferY;
if (overflowBottom) {
top = anchorRect.top - overlayRect.height;
this.overlay.classList.remove("top-aligned");
this.overlay.classList.add("bottom-aligned");
} else {
this.overlay.classList.remove("bottom-aligned");
this.overlay.classList.add("top-aligned");
}
this.overlay.style.left = `${left}px`;
this.overlay.style.top = `${top}px`;
}
destroy() {
window.removeEventListener("scroll", this.handleScroll, { capture: true });
}
} }
registerBehavior("ld-confirm-button", ConfirmButtonBehavior); registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
<div class="modal-container" role="dialog" aria-modal="true"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>Filters</h2> <h2>Filters</h2>
<button class="close" aria-label="Close dialog"> <button class="btn btn-noborder close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>

View File

@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
return; return;
} }
// Ignore if there is a modal dialog, which should handle its own focus
const modal = document.querySelector("[aria-modal='true']");
if (modal) {
return;
}
// Check if there is an explicit focus target for the next page load // Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) { for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target); const element = document.querySelector(target);

View File

@@ -1,5 +1,27 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class FormSubmit extends Behavior {
constructor(element) {
super(element);
this.onKeyDown = this.onKeyDown.bind(this);
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Check for Ctrl/Cmd + Enter combination
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
this.element.requestSubmit();
}
}
}
class AutoSubmitBehavior extends Behavior { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
@@ -17,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
} }
} }
// Resets form controls to their initial values before Turbo caches the DOM.
// Useful for filter forms where navigating back would otherwise still show
// values from after the form submission, which means the filters would be out
// of sync with the URL.
class FormResetBehavior extends Behavior {
constructor(element) {
super(element);
this.controls = this.element.querySelectorAll("input, select");
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.__initialValue = control.checked;
} else {
control.__initialValue = control.value;
}
});
}
destroy() {
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.checked = control.__initialValue;
} else {
control.value = control.__initialValue;
}
delete control.__initialValue;
});
}
}
class UploadButton extends Behavior { class UploadButton extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
@@ -51,5 +103,7 @@ class UploadButton extends Behavior {
} }
} }
registerBehavior("ld-form-submit", FormSubmit);
registerBehavior("ld-auto-submit", AutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-form-reset", FormResetBehavior);
registerBehavior("ld-upload-button", UploadButton); registerBehavior("ld-upload-button", UploadButton);

View File

@@ -54,8 +54,6 @@ export class Behavior {
destroy() {} destroy() {}
} }
Behavior.interacting = false;
export function registerBehavior(name, behavior) { export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior; behaviorRegistry[name] = behavior;
} }

View File

@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
this.closeButton.removeEventListener("click", this.onClose); this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown); document.removeEventListener("keydown", this.onKeyDown);
this.clearInert(); this.removeScrollLock();
this.focusTrap.destroy(); this.focusTrap.destroy();
} }
init() { init() {
this.setupInert(); this.setupScrollLock();
this.focusTrap = new FocusTrapController( this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"), this.element.querySelector(".modal-container"),
); );
} }
setupInert() { setupScrollLock() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock"); document.body.classList.add("scroll-lock");
} }
clearInert() { removeScrollLock() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock"); document.body.classList.remove("scroll-lock");
} }
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
doClose() { doClose() {
this.element.remove(); this.element.remove();
this.clearInert(); this.removeScrollLock();
this.element.dispatchEvent(new CustomEvent("modal:close")); this.element.dispatchEvent(new CustomEvent("modal:close"));
} }
} }

View File

@@ -1,5 +1,5 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte"; import "../components/SearchAutocomplete.js";
class SearchAutocomplete extends Behavior { class SearchAutocomplete extends Behavior {
constructor(element) { constructor(element) {
@@ -10,26 +10,20 @@ class SearchAutocomplete extends Behavior {
return; return;
} }
const container = document.createElement("div"); const autocomplete = document.createElement("ld-search-autocomplete");
autocomplete.name = "q";
new SearchAutoCompleteComponent({ autocomplete.placeholder = input.getAttribute("placeholder") || "";
target: container, autocomplete.value = input.value;
props: { autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
name: "q", autocomplete.mode = input.dataset.mode || "";
placeholder: input.getAttribute("placeholder") || "", autocomplete.search = {
value: input.value, user: input.dataset.user,
linkTarget: input.dataset.linkTarget, shared: input.dataset.shared,
mode: input.dataset.mode, unread: input.dataset.unread,
search: { };
user: input.dataset.user,
shared: input.dataset.shared,
unread: input.dataset.unread,
},
},
});
this.input = input; this.input = input;
this.autocomplete = container.firstElementChild; this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete); input.replaceWith(this.autocomplete);
} }

View File

@@ -1,5 +1,5 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import "../components/TagAutocomplete.js";
class TagAutocomplete extends Behavior { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
@@ -10,21 +10,16 @@ class TagAutocomplete extends Behavior {
return; return;
} }
const container = document.createElement("div"); const autocomplete = document.createElement("ld-tag-autocomplete");
autocomplete.id = input.id;
new TagAutoCompleteComponent({ autocomplete.name = input.name;
target: container, autocomplete.value = input.value;
props: { autocomplete.placeholder = input.getAttribute("placeholder") || "";
id: input.id, autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
name: input.name, autocomplete.variant = input.getAttribute("variant") || "default";
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
variant: input.getAttribute("variant"),
},
});
this.input = input; this.input = input;
this.autocomplete = container.firstElementChild; this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete); input.replaceWith(this.autocomplete);
} }

View File

@@ -1,262 +0,0 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let mode = '';
export let search;
export let linkTarget = '_blank';
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tag => ({
type: 'tag',
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name
}))
}
// Recent search suggestions
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionSearch = {
...search,
q: value
}
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...recentSearches,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, linkTarget)
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.recentSearches.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.recentSearches as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View File

@@ -0,0 +1,304 @@
import { LitElement, html } from "lit";
import { SearchHistory } from "./SearchHistory.js";
import { api } from "../api.js";
import { cache } from "../cache.js";
import {
clampText,
debounce,
getCurrentWord,
getCurrentWordBounds,
} from "../util.js";
export class SearchAutocomplete extends LitElement {
static properties = {
name: { type: String },
placeholder: { type: String },
value: { type: String },
mode: { type: String },
search: { type: Object },
linkTarget: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.name = "";
this.placeholder = "";
this.value = "";
this.mode = "";
this.search = {};
this.linkTarget = "_blank";
this.isFocus = false;
this.isOpen = false;
this.suggestions = {
recentSearches: [],
bookmarks: [],
tags: [],
total: [],
};
this.selectedIndex = undefined;
this.input = null;
this.searchHistory = new SearchHistory();
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.style.setProperty("--menu-max-height", "400px");
this.input = this.querySelector("input");
// Track current search query after loading the page
this.searchHistory.pushCurrent();
this.updateSuggestions();
}
handleFocus() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
handleInput(e) {
this.value = e.target.value;
this.debouncedLoadSuggestions();
}
handleKeyDown(e) {
// Enter
if (
this.isOpen &&
this.selectedIndex !== undefined &&
(e.keyCode === 13 || e.keyCode === 9)
) {
const suggestion = this.suggestions.total[this.selectedIndex];
if (suggestion) this.completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
this.close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
this.updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!this.isOpen) {
this.loadSuggestions();
} else {
this.updateSelection(1);
}
e.preventDefault();
}
}
open() {
this.isOpen = true;
}
close() {
this.isOpen = false;
this.updateSuggestions();
this.selectedIndex = undefined;
}
hasSuggestions() {
return this.suggestions.total.length > 0;
}
async loadSuggestions() {
let suggestionIndex = 0;
function nextIndex() {
return suggestionIndex++;
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = [];
const currentWord = getCurrentWord(this.input);
if (currentWord && currentWord.length > 1 && currentWord[0] === "#") {
const searchTag = currentWord.substring(1, currentWord.length);
tagSuggestions = (tags || [])
.filter(
(tag) =>
tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,
)
.slice(0, 5)
.map((tag) => ({
type: "tag",
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name,
}));
}
// Recent search suggestions
const recentSearches = this.searchHistory
.getRecentSearches(this.value, 5)
.map((value) => ({
type: "search",
index: nextIndex(),
label: value,
value,
}));
// Bookmark suggestions
let bookmarks = [];
if (this.value && this.value.length >= 3) {
const path = this.mode ? `/${this.mode}` : "";
const suggestionSearch = {
...this.search,
q: this.value,
};
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
limit: 5,
offset: 0,
path,
});
bookmarks = fetchedBookmarks.map((bookmark) => {
const fullLabel = bookmark.title || bookmark.url;
const label = clampText(fullLabel, 60);
return {
type: "bookmark",
index: nextIndex(),
label,
bookmark,
};
});
}
this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);
if (this.hasSuggestions()) {
this.open();
} else {
this.close();
}
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || [];
bookmarks = bookmarks || [];
tagSuggestions = tagSuggestions || [];
this.suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [...tagSuggestions, ...recentSearches, ...bookmarks],
};
}
completeSuggestion(suggestion) {
if (suggestion.type === "search") {
this.value = suggestion.value;
this.close();
}
if (suggestion.type === "bookmark") {
window.open(suggestion.bookmark.url, this.linkTarget);
this.close();
}
if (suggestion.type === "tag") {
const bounds = getCurrentWordBounds(this.input);
const inputValue = this.input.value;
this.input.value =
inputValue.substring(0, bounds.start) +
`#${suggestion.tagName} ` +
inputValue.substring(bounds.end);
this.close();
}
}
updateSelection(dir) {
const length = this.suggestions.total.length;
if (length === 0) return;
if (this.selectedIndex === undefined) {
this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);
return;
}
let newIndex = this.selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
this.selectedIndex = newIndex;
}
renderSuggestions(suggestions, title) {
if (suggestions.length === 0) return "";
return html`
<li class="menu-item group-item">${title}</li>
${suggestions.map(
(suggestion) => html`
<li
class="menu-item ${this.selectedIndex === suggestion.index
? "selected"
: ""}"
>
<a
href="#"
@mousedown=${(e) => {
e.preventDefault();
this.completeSuggestion(suggestion);
}}
>
${suggestion.label}
</a>
</li>
`,
)}
`;
}
render() {
return html`
<div class="form-autocomplete">
<div
class="form-autocomplete-input form-input ${this.isFocus
? "is-focused"
: ""}"
>
<input
type="search"
class="form-input"
name="${this.name}"
placeholder="${this.placeholder}"
autocomplete="off"
.value="${this.value}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
</div>
<ul class="menu ${this.isOpen ? "open" : ""}">
${this.renderSuggestions(this.suggestions.tags, "Tags")}
${this.renderSuggestions(
this.suggestions.recentSearches,
"Recent Searches",
)}
${this.renderSuggestions(this.suggestions.bookmarks, "Bookmarks")}
</ul>
</div>
`;
}
}
customElements.define("ld-search-autocomplete", SearchAutocomplete);

View File

@@ -0,0 +1,194 @@
import { LitElement, html } from "lit";
import { cache } from "../cache.js";
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
export class TagAutocomplete extends LitElement {
static properties = {
id: { type: String },
name: { type: String },
value: { type: String },
placeholder: { type: String },
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
variant: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.id = "";
this.name = "";
this.value = "";
this.placeholder = "";
this.ariaDescribedBy = "";
this.variant = "default";
this.isFocus = false;
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
this.input = null;
this.suggestionList = null;
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.input = this.querySelector("input");
this.suggestionList = this.querySelector(".menu");
}
handleFocus() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
async handleInput(e) {
this.input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(this.input);
this.suggestions = word
? tags.filter(
(tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,
)
: [];
if (word && this.suggestions.length > 0) {
this.open();
} else {
this.close();
}
}
handleKeyDown(e) {
if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = this.suggestions[this.selectedIndex];
this.complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
this.close();
e.preventDefault();
}
if (e.keyCode === 38) {
this.updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
this.updateSelection(1);
e.preventDefault();
}
}
open() {
this.isOpen = true;
this.selectedIndex = 0;
}
close() {
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
}
complete(suggestion) {
const bounds = getCurrentWordBounds(this.input);
const value = this.input.value;
this.input.value =
value.substring(0, bounds.start) +
suggestion.name +
" " +
value.substring(bounds.end);
this.input.dispatchEvent(new CustomEvent("change", { bubbles: true }));
this.close();
}
updateSelection(dir) {
const length = this.suggestions.length;
let newIndex = this.selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
this.selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (this.suggestionList) {
const selectedListItem =
this.suggestionList.querySelector("li.selected");
if (selectedListItem) {
selectedListItem.scrollIntoView({ block: "center" });
}
}
}, 0);
}
render() {
return html`
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
<!-- autocomplete input container -->
<div
class="form-autocomplete-input form-input ${this.isFocus
? "is-focused"
: ""}"
>
<!-- autocomplete real input box -->
<input
id="${this.id}"
name="${this.name}"
.value="${this.value || ""}"
placeholder="${this.placeholder || " "}"
class="form-input"
type="text"
autocomplete="off"
autocapitalize="off"
aria-describedby="${this.ariaDescribedBy}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
</div>
<!-- autocomplete suggestion list -->
<ul
class="menu ${this.isOpen && this.suggestions.length > 0
? "open"
: ""}"
>
<!-- menu list items -->
${this.suggestions.map(
(tag, i) => html`
<li
class="menu-item ${this.selectedIndex === i ? "selected" : ""}"
>
<a
href="#"
@mousedown=${(e) => {
e.preventDefault();
this.complete(tag);
}}
>
${tag.name}
</a>
</li>
`,
)}
</ul>
</div>
`;
}
}
customElements.define("ld-tag-autocomplete", TagAutocomplete);

View File

@@ -1,169 +0,0 @@
<script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let placeholder;
export let variant = 'default';
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
async function handleInput(e) {
input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
{tag.name}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
box-sizing: border-box;
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
}
.form-autocomplete-input input {
width: 100%;
height: 100%;
border: none;
margin: 0;
}
.form-autocomplete.small .form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete.small .form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.form-autocomplete.small .menu .menu-item {
font-size: var(--font-size-sm);
}
</style>

View File

@@ -11,7 +11,5 @@ import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete"; import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete"; import "./behaviors/tag-autocomplete";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { api } from "./api"; export { api } from "./api";
export { cache } from "./cache"; export { cache } from "./cache";

View File

@@ -9,6 +9,13 @@ export function debounce(callback, delay = 250) {
}; };
} }
export function preventDefault(fn) {
return function (event) {
event.preventDefault();
fn.call(this, event);
};
}
export function clampText(text, maxChars = 30) { export function clampText(text, maxChars = 30) {
if (!text || text.length <= 30) return text; if (!text || text.length <= 30) return text;

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-08-22 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="url_normalized",
field=models.CharField(blank=True, db_index=True, max_length=2048),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.3 on 2025-08-22 08:28
from django.db import migrations, transaction
from bookmarks.utils import normalize_url
def populate_url_normalized(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
batch_size = 500
with transaction.atomic():
qs = Bookmark.objects.all()
for start in range(0, qs.count(), batch_size):
batch = list(qs[start : start + batch_size])
for bookmark in batch:
bookmark.url_normalized = normalize_url(bookmark.url)
Bookmark.objects.bulk_update(
batch, ["url_normalized"], batch_size=batch_size
)
def reverse_populate_url_normalized(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
Bookmark.objects.all().update(url_normalized="")
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0046_add_url_normalized_field"),
]
operations = [
migrations.RunPython(
populate_url_normalized,
reverse_populate_url_normalized,
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-08-22 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0047_populate_url_normalized_field"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_mark_shared",
field=models.BooleanField(default=False),
),
]

View File

@@ -14,7 +14,7 @@ from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import QueryDict from django.http import QueryDict
from bookmarks.utils import unique from bookmarks.utils import unique, normalize_url
from bookmarks.validators import BookmarkURLValidator from bookmarks.validators import BookmarkURLValidator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
return [] return []
names = tag_string.strip().split(delimiter) names = tag_string.strip().split(delimiter)
# remove empty names, sanitize remaining names # remove empty names, sanitize remaining names
names = [sanitize_tag_name(name) for name in names if name] names = [sanitize_tag_name(name) for name in names if name.strip()]
# remove duplicates # remove duplicates
names = unique(names, str.lower) names = unique(names, str.lower)
names.sort(key=str.lower) names.sort(key=str.lower)
@@ -54,6 +54,7 @@ def build_tag_string(tag_names: List[str], delimiter: str = ","):
class Bookmark(models.Model): class Bookmark(models.Model):
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()]) url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
url_normalized = models.CharField(max_length=2048, blank=True, db_index=True)
title = models.CharField(max_length=512, blank=True) title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
@@ -96,6 +97,10 @@ class Bookmark(models.Model):
names = [tag.name for tag in self.tags.all()] names = [tag.name for tag in self.tags.all()]
return sorted(names) return sorted(names)
def save(self, *args, **kwargs):
self.url_normalized = normalize_url(self.url)
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)" return self.resolved_title + " (" + self.url[:30] + "...)"
@@ -133,6 +138,14 @@ class BookmarkAsset(models.Model):
status = models.CharField(max_length=64, blank=False, null=False) status = models.CharField(max_length=64, blank=False, null=False)
gzip = models.BooleanField(default=False, null=False) gzip = models.BooleanField(default=False, null=False)
@property
def download_name(self):
return (
f"{self.display_name}.html"
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else self.display_name
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.file: if self.file:
try: try:
@@ -466,6 +479,7 @@ class UserProfile(models.Model):
search_preferences = models.JSONField(default=dict, null=False) search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False) default_mark_unread = models.BooleanField(default=False, null=False)
default_mark_shared = models.BooleanField(default=False, null=False)
items_per_page = models.IntegerField( items_per_page = models.IntegerField(
null=False, default=30, validators=[MinValueValidator(10)] null=False, default=30, validators=[MinValueValidator(10)]
) )
@@ -507,6 +521,7 @@ class UserProfileForm(forms.ModelForm):
"display_remove_bookmark_action", "display_remove_bookmark_action",
"permanent_notes", "permanent_notes",
"default_mark_unread", "default_mark_unread",
"default_mark_shared",
"custom_css", "custom_css",
"auto_tagging_rules", "auto_tagging_rules",
"items_per_page", "items_per_page",

View File

@@ -39,9 +39,10 @@ def create_snapshot(asset: BookmarkAsset):
# Store as gzip in asset folder # Store as gzip in asset folder
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz") filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(temp_filepath, "rb") as temp_file, gzip.open( with (
filepath, "wb" open(temp_filepath, "rb") as temp_file,
) as gz_file: gzip.open(filepath, "wb") as gz_file,
):
shutil.copyfileobj(temp_file, gz_file) shutil.copyfileobj(temp_file, gz_file)
# Remove temporary file # Remove temporary file
@@ -94,13 +95,28 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
gzip=False, gzip=False,
) )
name, extension = os.path.splitext(upload_file.name) name, extension = os.path.splitext(upload_file.name)
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) # automatically gzip the file if it is not already gzipped
with open(filepath, "wb") as f: if upload_file.content_type != "application/gzip":
for chunk in upload_file.chunks(): filename = _generate_asset_filename(
f.write(chunk) asset, name, extension.lstrip(".") + ".gz"
asset.file = filename )
asset.file_size = upload_file.size filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb", compresslevel=9) as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.gzip = True
asset.file = filename
asset.file_size = os.path.getsize(filepath)
else:
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save() asset.save()
asset.bookmark.date_modified = timezone.now() asset.bookmark.date_modified = timezone.now()

View File

@@ -4,6 +4,7 @@ from typing import Union
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, User, parse_tag_string from bookmarks.models import Bookmark, User, parse_tag_string
from bookmarks.utils import normalize_url
from bookmarks.services import auto_tagging from bookmarks.services import auto_tagging
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
@@ -19,8 +20,9 @@ def create_bookmark(
disable_html_snapshot: bool = False, disable_html_snapshot: bool = False,
): ):
# If URL is already bookmarked, then update it # If URL is already bookmarked, then update it
normalized_url = normalize_url(bookmark.url)
existing_bookmark: Bookmark = Bookmark.objects.filter( existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url=bookmark.url owner=current_user, url_normalized=normalized_url
).first() ).first()
if existing_bookmark is not None: if existing_bookmark is not None:
@@ -208,6 +210,15 @@ def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: Us
tasks.load_preview_image(current_user, bookmark) tasks.load_preview_image(current_user, bookmark)
def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
owned_bookmarks = Bookmark.objects.filter(
owner=current_user, id__in=sanitized_bookmark_ids
)
tasks.create_html_snapshots(owned_bookmarks)
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

View File

@@ -0,0 +1,37 @@
from django.db.models import Max
from bookmarks.models import BookmarkBundle, User
def create_bundle(bundle: BookmarkBundle, current_user: User):
bundle.owner = current_user
if bundle.order is None:
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
Max("order", default=-1)
)
bundle.order = max_order_result["order__max"] + 1
bundle.save()
return bundle
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
user_bundles = list(
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
)
if new_order != user_bundles.index(bundle_to_move):
user_bundles.remove(bundle_to_move)
user_bundles.insert(new_order, bundle_to_move)
for bundle_index, bundle in enumerate(user_bundles):
bundle.order = bundle_index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
def delete_bundle(bundle: BookmarkBundle):
bundle.delete()
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
for index, user_bundle in enumerate(user_bundles):
user_bundle.order = index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])

View File

@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
for netscape_bookmark in netscape_bookmarks: for netscape_bookmark in netscape_bookmarks:
for tag_name in netscape_bookmark.tag_names: for tag_name in netscape_bookmark.tag_names:
# Skip tag names that exceed the maximum allowed length
if len(tag_name) > 64:
logger.warning(
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
)
continue
tag = tag_cache.get(tag_name) tag = tag_cache.get(tag_name)
if not tag: if not tag:
tag = Tag(name=tag_name, owner=user) tag = Tag(name=tag_name, owner=user)

View File

@@ -22,9 +22,10 @@ def create_snapshot(url: str, filepath: str):
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}" command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
subprocess.run(command, check=True, shell=True) subprocess.run(command, check=True, shell=True)
with open(temp_filepath, "rb") as raw_file, gzip.open( with (
filepath, "wb" open(temp_filepath, "rb") as raw_file,
) as gz_file: gzip.open(filepath, "wb") as gz_file,
):
shutil.copyfileobj(raw_file, gz_file) shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath) os.remove(temp_filepath)

View File

@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)

View File

@@ -1,5 +1,13 @@
/* Common styles */ /* Common styles */
.bookmark-details { .bookmark-details {
.title {
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
}
& .weblinks { & .weblinks {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
.bookmark-pagination { .bookmark-pagination {
margin-top: var(--unit-4); margin-top: var(--unit-4);
/* Remove left padding from first pagination link */
& .page-item:first-child a {
padding-left: 0;
}
&.sticky { &.sticky {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -365,7 +359,8 @@ li[ld-bookmark-item] {
top: 0; top: 0;
bottom: 0; bottom: 0;
left: calc( left: calc(
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)) -1 *
calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
); );
width: calc( width: calc(
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset) var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)

View File

@@ -1,20 +1,15 @@
.bundles-page { .bundles-page {
h1 { .crud-table {
font-size: var(--font-size-lg); svg {
margin-bottom: var(--unit-6);
}
.item-list {
.list-item .list-item-icon {
cursor: grab; cursor: grab;
} }
.list-item.drag-start { tr.drag-start {
--secondary-border-color: transparent; --secondary-border-color: transparent;
} }
.list-item.dragging > * { tr.dragging > * {
visibility: hidden; opacity: 0;
} }
} }
} }

View File

@@ -25,28 +25,23 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.section-header { .section-header:not(.no-wrap) {
flex-direction: column; flex-direction: column;
} }
} }
/* Confirm button component */ /* Confirm button component */
span.confirmation { .confirm-dropdown.active {
display: flex; position: fixed;
align-items: baseline; z-index: 500;
gap: var(--unit-1);
color: var(--error-color) !important;
svg { & .menu {
align-self: center; position: fixed;
} display: flex;
flex-direction: column;
.btn.btn-link { box-sizing: border-box;
color: var(--error-color) !important; gap: var(--unit-2);
padding: var(--unit-2);
&:hover {
text-decoration: underline;
}
} }
} }

65
bookmarks/styles/crud.css Normal file
View File

@@ -0,0 +1,65 @@
.crud-page {
.crud-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--unit-6);
h1 {
font-size: var(--font-size-xl);
margin: 0;
}
}
.crud-filters {
background: var(--body-color-contrast);
border-radius: var(--border-radius);
border: solid 1px var(--secondary-border-color);
padding: var(--unit-3);
margin-bottom: var(--unit-4);
form {
display: flex;
flex-wrap: wrap;
gap: var(--unit-4);
& .form-group {
margin: 0;
}
&.form-input,
&.form-select {
width: auto;
}
& .form-group:has(.form-checkbox) {
align-self: flex-end;
}
}
}
.crud-table {
.btn.btn-link {
padding: 0;
height: unset;
}
th,
td {
max-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th.actions,
td.actions {
width: 1%;
max-width: 150px;
*:not(:last-child) {
margin-right: var(--unit-2);
}
}
}
}

View File

@@ -0,0 +1,6 @@
.tags-editor-page {
main {
max-width: 550px;
margin: 0 auto;
}
}

View File

@@ -22,6 +22,7 @@
@import "responsive.css"; @import "responsive.css";
@import "layout.css"; @import "layout.css";
@import "components.css"; @import "components.css";
@import "crud.css";
@import "bookmark-details.css"; @import "bookmark-details.css";
@import "bookmark-form.css"; @import "bookmark-form.css";
@import "bookmark-page.css"; @import "bookmark-page.css";
@@ -29,3 +30,4 @@
@import "reader-mode.css"; @import "reader-mode.css";
@import "settings.css"; @import "settings.css";
@import "bundles.css"; @import "bundles.css";
@import "tags.css";

View File

@@ -3,13 +3,14 @@
position: relative; position: relative;
& .form-autocomplete-input { & .form-autocomplete-input {
box-sizing: border-box;
align-content: flex-start; align-content: flex-start;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
height: auto;
min-height: var(--unit-8);
padding: var(--unit-h);
background: var(--input-bg-color); background: var(--input-bg-color);
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
&.is-focused { &.is-focused {
outline: var(--focus-outline); outline: var(--focus-outline);
@@ -22,10 +23,11 @@
box-shadow: none; box-shadow: none;
display: inline-block; display: inline-block;
flex: 1 0 auto; flex: 1 0 auto;
height: var(--unit-6);
line-height: var(--unit-4); line-height: var(--unit-4);
margin: var(--unit-h); width: 100%;
width: auto; height: 100%;
margin: 0;
border: none;
&:focus { &:focus {
outline: none; outline: none;
@@ -33,11 +35,30 @@
} }
} }
&.small {
.form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.menu .menu-item {
font-size: var(--font-size-sm);
}
}
& .menu { & .menu {
display: none;
left: 0; left: 0;
position: absolute; position: absolute;
top: 100%; top: 100%;
width: 100%; width: 100%;
max-height: var(--menu-max-height, 200px);
overflow: auto;
& .menu-item.selected > a, & .menu-item.selected > a,
& .menu-item > a:hover { & .menu-item > a:hover {
@@ -54,4 +75,8 @@
font-weight: bold; font-weight: bold;
} }
} }
& .menu.open {
display: block;
}
} }

View File

@@ -119,6 +119,12 @@
} }
} }
/* Button no border */
&.btn-noborder {
border-color: transparent;
box-shadow: none;
}
/* Button Link */ /* Button Link */
&.btn-link { &.btn-link {

View File

@@ -224,12 +224,13 @@ textarea.form-input {
position: relative; position: relative;
input { input {
clip: rect(0, 0, 0, 0); opacity: 0;
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute; position: absolute;
width: 1px; top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
left: 0;
height: var(--control-icon-size);
width: var(--control-icon-size);
cursor: pointer;
&:focus-visible + .form-icon { &:focus-visible + .form-icon {
outline: var(--focus-outline); outline: var(--focus-outline);
@@ -243,9 +244,9 @@ textarea.form-input {
} }
.form-icon { .form-icon {
pointer-events: none;
border: var(--border-width) solid var(--checkbox-border-color); border: var(--border-width) solid var(--checkbox-border-color);
box-shadow: var(--input-box-shadow); box-shadow: var(--input-box-shadow);
cursor: pointer;
display: inline-block; display: inline-block;
position: absolute; position: absolute;
transition: transition:
@@ -429,13 +430,21 @@ textarea.form-input {
/* Form element: Input groups */ /* Form element: Input groups */
.input-group { .input-group {
display: flex; display: flex;
border-radius: var(--border-radius);
box-shadow: var(--input-box-shadow);
> * {
box-shadow: none !important;
}
.input-group-addon { .input-group-addon {
background: var(--body-color); display: flex;
align-items: center;
background: var(--input-bg-color);
border: var(--border-width) solid var(--input-border-color); border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
line-height: var(--line-height); line-height: var(--line-height);
padding: var(--control-padding-y) var(--control-padding-x); padding: 0 var(--control-padding-x);
white-space: nowrap; white-space: nowrap;
&.addon-sm { &.addon-sm {

View File

@@ -87,4 +87,43 @@
border-bottom: solid 1px var(--secondary-border-color); border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-2) 0; margin: var(--unit-2) 0;
} }
&.with-arrow {
overflow: visible;
--arrow-size: 16px;
--arrow-offset: 0px;
.menu-arrow {
display: block;
position: absolute;
inset-inline-start: calc(50% + var(--arrow-offset));
top: 0;
width: var(--arrow-size);
height: var(--arrow-size);
translate: -50% -50%;
rotate: 45deg;
background: inherit;
border: inherit;
clip-path: polygon(0 0, 0 100%, 100% 0);
}
&.top-aligned {
transform: translateY(
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm))
);
}
&.bottom-aligned {
transform: translateY(
calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1)
);
.menu-arrow {
top: auto;
bottom: 0;
rotate: 225deg;
translate: -50% 50%;
}
}
}
} }

View File

@@ -80,17 +80,8 @@
} }
& .close { & .close {
background: none;
border: none;
padding: 0; padding: 0;
line-height: 0; height: auto;
cursor: pointer;
opacity: 0.85;
color: var(--secondary-text-color);
&:hover {
opacity: 1;
}
} }
} }
@@ -106,7 +97,6 @@
& .modal-footer { & .modal-footer {
padding: var(--unit-6); padding: var(--unit-6);
padding-top: 0; padding-top: 0;
text-align: right;
} }
} }

View File

@@ -33,6 +33,11 @@
} }
} }
&:first-child a {
/* Remove left padding from first pagination link */
padding-left: 0;
}
&.active { &.active {
& a { & a {
background: var(--primary-color); background: var(--primary-color);

View File

@@ -5,22 +5,19 @@
width: 100%; width: 100%;
text-align: left; text-align: left;
/* Scrollable tables */ td,
th {
&.table-scroll { border-bottom: var(--border-width) solid var(--secondary-border-color);
display: block; padding: var(--unit-2) var(--unit-2);
overflow-x: auto;
padding-bottom: 0.75rem;
white-space: nowrap;
} }
& td, th {
& th { font-weight: 500;
border-bottom: var(--border-width) solid var(--border-color); border-bottom-color: var(--border-color);
padding: var(--unit-3) var(--unit-2);
} }
& th { th:first-child,
border-bottom-width: var(--border-width-lg); td:first-child {
padding-left: 0;
} }
} }

View File

@@ -242,6 +242,36 @@
margin-top: var(--unit-4) !important; margin-top: var(--unit-4) !important;
} }
.m-6 {
margin: var(--unit-6) !important;
}
.mb-6 {
margin-bottom: var(--unit-6) !important;
}
.ml-6 {
margin-left: var(--unit-6) !important;
}
.mr-6 {
margin-right: var(--unit-6) !important;
}
.mt-6 {
margin-top: var(--unit-6) !important;
}
.mx-6 {
margin-left: var(--unit-6) !important;
margin-right: var(--unit-6) !important;
}
.my-6 {
margin-bottom: var(--unit-6) !important;
margin-top: var(--unit-6) !important;
}
.ml-auto { .ml-auto {
margin-left: auto; margin-left: auto;
} }
@@ -291,6 +321,10 @@
} }
/* Flex */ /* Flex */
.flex-column {
flex-direction: column;
}
.align-baseline { .align-baseline {
align-items: baseline; align-items: baseline;
} }
@@ -302,3 +336,7 @@
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
.gap-2 {
gap: var(--unit-2);
}

View File

@@ -49,20 +49,22 @@
--body-color-contrast: var(--gray-100); --body-color-contrast: var(--gray-100);
/* Fonts */ /* Fonts */
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", --base-font-family:
Roboto; -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, --mono-font-family:
monospace; "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
--fallback-font-family: "Helvetica Neue", sans-serif; --fallback-font-family: "Helvetica Neue", sans-serif;
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC", --cjk-zh-hans-font-family:
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family); var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC", "Microsoft YaHei", var(--fallback-font-family);
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family); --cjk-zh-hant-font-family:
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans", var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, "Microsoft JhengHei", var(--fallback-font-family);
var(--fallback-font-family); --cjk-jp-font-family:
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic", var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
var(--fallback-font-family); "Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
--cjk-ko-font-family:
var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
--body-font-family: var(--base-font-family), var(--fallback-font-family); --body-font-family: var(--base-font-family), var(--fallback-font-family);
/* Unit sizes */ /* Unit sizes */
@@ -145,6 +147,6 @@
/* Shadows */ /* Shadows */
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; --box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), --box-shadow-lg:
0 4px 6px -4px rgb(0 0 0 / 0.1); 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
} }

View File

@@ -120,7 +120,7 @@
{% if bookmark_item.show_mark_as_read %} {% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}" <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?"> ld-confirm-button ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
@@ -130,7 +130,7 @@
{% if bookmark_item.show_unshare %} {% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}" <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?"> ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>

View File

@@ -1,7 +1,7 @@
(function () { (function () {
var bookmarkUrl = window.location; const bookmarkUrl = window.location;
var applicationUrl = '{{ application_url }}';
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&auto_close'; applicationUrl += '&auto_close';

View File

@@ -0,0 +1,25 @@
(function () {
const bookmarkUrl = window.location;
const title =
document.querySelector('title')?.textContent ||
document
.querySelector(`meta[property='og:title']`)
?.getAttribute('content') ||
'';
const description =
document
.querySelector(`meta[name='description']`)
?.getAttribute('content') ||
document
.querySelector(`meta[property='og:description']`)
?.getAttribute(`content`) ||
'';
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&title=' + encodeURIComponent(title);
applicationUrl += '&description=' + encodeURIComponent(description);
applicationUrl += '&auto_close';
window.open(applicationUrl);
})();

View File

@@ -23,6 +23,9 @@
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
{% endif %} {% endif %}
<option value="bulk_refresh">Refresh from website</option> <option value="bulk_refresh">Refresh from website</option>
{% if bookmark_list.snapshot_feature_enabled %}
<option value="bulk_snapshot">Create HTML snapshot</option>
{% endif %}
</select> </select>
<div class="tag-autocomplete d-none" ld-tag-autocomplete> <div class="tag-autocomplete d-none" ld-tag-autocomplete>
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small"> <input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">

View File

@@ -1,16 +1,29 @@
{% if not request.user_profile.hide_bundles %} {% if not request.user_profile.hide_bundles %}
<section aria-labelledby="bundles-heading"> <section aria-labelledby="bundles-heading">
<div class="section-header"> <div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2> <h2 id="bundles-heading">Bundles</h2>
<a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles"> <div ld-dropdown class="dropdown dropdown-right ml-auto">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" <button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
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="M4 6l16 0"/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/> <path d="M4 12l16 0"/>
</svg> <path d="M4 18l16 0"/>
</a> </svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
</li>
{% if bookmark_list.search.q %}
<li class="menu-item">
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
bundle from search</a>
</li>
{% endif %}
</ul>
</div>
</div> </div>
<ul class="bundle-menu"> <ul class="bundle-menu">
{% for bundle in bundles.bundles %} {% for bundle in bundles.bundles %}

View File

@@ -3,8 +3,8 @@
<div class="modal-overlay"></div> <div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2> <h2 class="title">{{ details.bookmark.resolved_title }}</h2>
<button class="close" aria-label="Close dialog"> <button class="btn btn-noborder close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -32,7 +32,7 @@
<input type="hidden" name="disable_turbo" value="true"> <input type="hidden" name="disable_turbo" value="true">
<button ld-confirm-button class="btn btn-error btn-wide" <button ld-confirm-button class="btn btn-error btn-wide"
type="submit" name="remove" value="{{ details.bookmark.id }}"> type="submit" name="remove" value="{{ details.bookmark.id }}">
Delete... Delete
</button> </button>
</form> </form>
</div> </div>

View File

@@ -13,7 +13,7 @@
<h1 id="main-heading">Edit bookmark</h1> <h1 id="main-heading">Edit bookmark</h1>
</div> </div>
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
novalidate> novalidate ld-form-submit>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>
</main> </main>

View File

@@ -1,5 +1,6 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load static %} {% load static %}
{% load shared %}
<div class="bookmarks-form"> <div class="bookmarks-form">
{% csrf_token %} {% csrf_token %}
@@ -7,7 +8,7 @@
<div class="form-group {% if form.url.errors %}has-error{% endif %}"> <div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label> <label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
<div class="has-icon-right"> <div class="has-icon-right">
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }} {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
<i class="form-icon loading"></i> <i class="form-icon loading"></i>
</div> </div>
{% if form.url.errors %} {% if form.url.errors %}
@@ -22,8 +23,8 @@
</div> </div>
<div class="form-group" ld-tag-autocomplete> <div class="form-group" ld-tag-autocomplete>
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} {{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint"> <div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). Enter any number of tags separated by space and <strong>without</strong> the hash (#).
If a tag does not exist it will be automatically created. If a tag does not exist it will be automatically created.
</div> </div>
@@ -35,7 +36,8 @@
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label> <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="flex"> <div class="flex">
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button> <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button" <button ld-clear-button data-for="{{ form.title.id_for_label }}"
class="ml-2 btn btn-link suffix-button clear-button"
type="button">Clear type="button">Clear
</button> </button>
</div> </div>
@@ -60,31 +62,31 @@
<span class="form-label d-inline-block">Notes</span> <span class="form-label d-inline-block">Notes</span>
</summary> </summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label> <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }} {{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint"> <div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
Additional notes, supports Markdown. Additional notes, supports Markdown.
</div> </div>
</details> </details>
{{ form.notes.errors }} {{ form.notes.errors }}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox"> <div class="form-checkbox">
{{ form.unread }} {{ form.unread|form_field:"help" }}
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Mark as unread</span> <label for="{{ form.unread.id_for_label }}">Mark as unread</label>
</label> </div>
<div class="form-input-hint"> <div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them. Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div> </div>
</div> </div>
{% if request.user_profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox"> <div class="form-checkbox">
{{ form.shared }} {{ form.shared|form_field:"help" }}
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="{{ form.shared.id_for_label }}">Share</label>
</label> </div>
<div class="form-input-hint"> <div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
{% if request.user_profile.enable_public_sharing %} {% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users. Share this bookmark with other registered users and anonymous users.
{% else %} {% else %}
@@ -100,7 +102,7 @@
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide"> <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn">Nevermind</a> <a href="{{ return_url }}" class="btn">Cancel</a>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
/** /**
@@ -227,6 +229,7 @@
} }
}); });
} }
refreshButton.addEventListener('click', refreshMetadata); refreshButton.addEventListener('click', refreshMetadata);
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark

View File

@@ -18,18 +18,6 @@
<path d="M21 6l0 13"></path> <path d="M21 6l0 13"></path>
</symbol> </symbol>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
<path d="M3 6v13"></path>
<path d="M12 6v2m0 4v7"></path>
<path d="M21 6v11"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" <symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -41,18 +29,6 @@
<path d="M8.7 13.3l6.6 3.4"></path> <path d="M8.7 13.3l6.6 3.4"></path>
</symbol> </symbol>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unshare" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" <symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">

View File

@@ -12,7 +12,7 @@
<div class="section-header"> <div class="section-header">
<h1 id="main-heading">New bookmark</h1> <h1 id="main-heading">New bookmark</h1>
</div> </div>
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate> <form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>
</main> </main>

View File

@@ -21,6 +21,9 @@
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822"> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %} {% endif %}
{% if request.user_profile.custom_css %}
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %}
</head> </head>
<body> <body>
<template id="content">{{ content|safe }}</template> <template id="content">{{ content|safe }}</template>

View File

@@ -1,6 +1,24 @@
<section aria-labelledby="tags-heading"> <section aria-labelledby="tags-heading">
<div class="section-header"> <div class="section-header no-wrap">
<h2 id="tags-heading">Tags</h2> <h2 id="tags-heading">Tags</h2>
{% if user.is_authenticated %}
<div ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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 6l16 0"/>
<path d="M4 12l16 0"/>
<path d="M4 18l16 0"/>
</svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
</li>
</ul>
</div>
{% endif %}
</div> </div>
<div id="tag-cloud-container"> <div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}

View File

@@ -7,41 +7,55 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="bundles-page" aria-labelledby="main-heading"> <main class="bundles-page crud-page" aria-labelledby="main-heading">
<h1 id="main-heading">Bundles</h1> <div class="crud-header">
<h1 id="main-heading">Bundles</h1>
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
</div>
{% include 'shared/messages.html' %} {% include 'shared/messages.html' %}
{% if bundles %} {% if bundles %}
<form action="{% url 'linkding:bundles.action' %}" method="post"> <form action="{% url 'linkding:bundles.action' %}" method="post">
{% csrf_token %} {% csrf_token %}
<div class="item-list bundles"> <table class="table crud-table">
<thead>
<tr>
<th>Name</th>
<th class="actions">
<span class="text-assistive">Actions</span>
</th>
</tr>
</thead>
<tbody>
{% for bundle in bundles %} {% for bundle in bundles %}
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true"> <tr data-bundle-id="{{ bundle.id }}" draggable="true">
<div class="list-item-icon text-secondary"> <td>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" <div class="d-flex align-center">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</svg> <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</div> </svg>
<div class="list-item-text"> <span>{{ bundle.name }}</span>
<span class="truncate">{{ bundle.name }}</span> </div>
</div> </td>
<div class="list-item-actions"> <td class="actions">
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a> <a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}" <button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
class="btn btn-link">Remove class="btn btn-link">Remove
</button> </button>
</div> </td>
</div> </tr>
{% endfor %} {% endfor %}
</div> </tbody>
</table>
<input type="submit" name="move_bundle" value="" class="d-none"> <input type="submit" name="move_bundle" value="" class="d-none">
<input type="hidden" name="move_position" value=""> <input type="hidden" name="move_position" value="">
</form> </form>
@@ -51,21 +65,17 @@
<p class="empty-subtitle">Create your first bundle to get started</p> <p class="empty-subtitle">Create your first bundle to get started</p>
</div> </div>
{% endif %} {% endif %}
<div class="mt-4">
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
</div>
</main> </main>
<script> <script>
(function init() { (function init() {
const bundlesList = document.querySelector(".item-list.bundles"); const tableBody = document.querySelector(".crud-table tbody");
if (!bundlesList) return; if (!tableBody) return;
let draggedElement = null; let draggedElement = null;
const listItems = bundlesList.querySelectorAll('.list-item'); const rows = tableBody.querySelectorAll('tr');
listItems.forEach((item) => { rows.forEach((item) => {
item.addEventListener('dragstart', handleDragStart); item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd); item.addEventListener('dragend', handleDragEnd);
item.addEventListener('dragover', handleDragOver); item.addEventListener('dragover', handleDragOver);
@@ -91,7 +101,7 @@
const moveBundleInput = document.querySelector('input[name="move_bundle"]'); const moveBundleInput = document.querySelector('input[name="move_bundle"]');
const movePositionInput = document.querySelector('input[name="move_position"]'); const movePositionInput = document.querySelector('input[name="move_position"]');
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id'); moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement); movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
const form = this.closest('form'); const form = this.closest('form');
form.requestSubmit(moveBundleInput); form.requestSubmit(moveBundleInput);
@@ -108,7 +118,7 @@
function handleDragEnter() { function handleDragEnter() {
if (this !== draggedElement) { if (this !== draggedElement) {
const listItems = Array.from(bundlesList.children); const listItems = Array.from(tableBody.children);
const draggedIndex = listItems.indexOf(draggedElement); const draggedIndex = listItems.indexOf(draggedElement);
const currentIndex = listItems.indexOf(this); const currentIndex = listItems.indexOf(this);

View File

@@ -270,6 +270,17 @@ reddit.com/r/Music music reddit</pre>
This can be overridden when creating each new bookmark. This can be overridden when creating each new bookmark.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.default_mark_shared.id_for_label }}" class="form-checkbox">
{{ form.default_mark_shared }}
<i class="form-icon"></i> Create bookmarks as shared by default
</label>
<div class="form-input-hint">
Sets the default state for the "Share" option when creating a new bookmark.
Setting this option will make all new bookmarks default to shared.
This can be overridden when creating each new bookmark.
</div>
</div>
<div class="form-group"> <div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}> <details {% if form.custom_css.value %}open{% endif %}>
<summary> <summary>
@@ -383,17 +394,17 @@ reddit.com/r/Music music reddit</pre>
<td>{{ version_info }}</td> <td>{{ version_info }}</td>
</tr> </tr>
<tr> <tr>
<td rowspan="3" style="vertical-align: top">Links</td> <td style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/" <td>
target="_blank">GitHub</a></td> <div class="d-flex flex-column gap-2">
</tr> <a href="https://github.com/sissbruecker/linkding/"
<tr> target="_blank">GitHub</a>
<td><a href="https://linkding.link/" <a href="https://linkding.link/"
target="_blank">Documentation</a></td> target="_blank">Documentation</a>
</tr> <a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
<tr> target="_blank">Changelog</a>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md" </div>
target="_blank">Changelog</a></td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -404,21 +415,25 @@ reddit.com/r/Music music reddit</pre>
(function init() { (function init() {
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}"); const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}"); const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
const defaultMarkShared = document.getElementById("{{ form.default_mark_shared.id_for_label }}");
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}"); const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}"); const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
// Automatically disable public bookmark sharing if bookmark sharing is disabled // Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled
function updatePublicSharing() { function updateSharingOptions() {
if (enableSharing.checked) { if (enableSharing.checked) {
enablePublicSharing.disabled = false; enablePublicSharing.disabled = false;
defaultMarkShared.disabled = false;
} else { } else {
enablePublicSharing.disabled = true; enablePublicSharing.disabled = true;
enablePublicSharing.checked = false; enablePublicSharing.checked = false;
defaultMarkShared.disabled = true;
defaultMarkShared.checked = false;
} }
} }
updatePublicSharing(); updateSharingOptions();
enableSharing.addEventListener("change", updatePublicSharing); enableSharing.addEventListener("change", updateSharingOptions);
// Automatically hide the bookmark description max lines input if the description display is set to inline // Automatically hide the bookmark description max lines input if the description display is set to inline
function updateBookmarkDescriptionMaxLines() { function updateBookmarkDescriptionMaxLines() {

View File

@@ -25,15 +25,33 @@
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
application first. Here's how it works:</p> application first. Here's how it works:</p>
<ul> <ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li> <li>Choose your preferred method for detecting website titles and descriptions below (<a href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect" target="_blank">Help</a>)</li>
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li> <li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li> <li>Click the bookmarklet in your browser's toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li> <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li> <li>After saving the bookmark, the linkding window closes, and you are back on your website</li>
</ul> </ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false" <div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
class="btn btn-primary">📎 Add bookmark</a> <p id="detection-method-label">Choose your preferred bookmarklet:</p>
<label for="detection-method-server" class="form-radio">
<input id="detection-method-server" type="radio" name="bookmarklet-type" value="server" checked>
<i class="form-icon"></i>
Detect title and description on the server
</label>
<label for="detection-method-client" class="form-radio">
<input id="detection-method-client" type="radio" name="bookmarklet-type" value="client">
<i class="form-icon"></i>
Detect title and description in the browser
</label>
</div>
<div class="bookmarklet-container">
<a id="bookmarklet-server" href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a>
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}" data-turbo="false"
class="btn btn-primary" style="display: none;">📎 Add bookmark</a>
</div>
</section> </section>
<section aria-labelledby="rest-api-heading"> <section aria-labelledby="rest-api-heading">
@@ -90,4 +108,28 @@
</p> </p>
</section> </section>
</main> </main>
<script>
(function init() {
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
const serverBookmarklet = document.getElementById('bookmarklet-server');
const clientBookmarklet = document.getElementById('bookmarklet-client');
function toggleBookmarklet() {
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
if (selectedValue === 'server') {
serverBookmarklet.style.display = 'inline-block';
clientBookmarklet.style.display = 'none';
} else {
serverBookmarklet.style.display = 'none';
clientBookmarklet.style.display = 'inline-block';
}
}
toggleBookmarklet();
radioButtons.forEach(function(radio) {
radio.addEventListener('change', toggleBookmarklet);
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,6 @@
{% load i18n %}
{# Force rendering validation errors in English language to align with the rest of the app #}
{% language 'en-us' %}
{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
{% endlanguage %}

View File

@@ -0,0 +1,23 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% block head %}
{% with page_title="Edit tag - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-editor-page">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Edit tag</h1>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% include 'tags/form.html' %}
</form>
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
{% if form.name.errors %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="divider"></div>
<div class="form-group d-flex justify-between">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
</div>

View File

@@ -0,0 +1,125 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% load pagination %}
{% block head %}
{% with page_title="Tags - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-page crud-page">
<main aria-labelledby="main-heading">
<div class="crud-header">
<h1 id="main-heading">Tags</h1>
<div class="d-flex gap-2 ml-auto">
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
</div>
</div>
{% include 'shared/messages.html' %}
{# Filters #}
<div class="crud-filters">
<form method="get" class="mb-2" ld-form-reset>
<div class="form-group">
<label class="form-label text-assistive" for="search">Search tags</label>
<div class="input-group">
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
class="form-input">
<button type="submit" class="btn input-group-btn">Search</button>
</div>
</div>
<div class="form-group">
<label class="form-label text-assistive" for="sort">Sort by</label>
<div class="input-group">
<span class="input-group-addon text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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 9l4 -4l4 4m-4 -4v14"/><path
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
</span>
<select id="sort" name="sort" class="form-select" ld-auto-submit>
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
<i class="form-icon"></i> Show only unused tags
</label>
</div>
</form>
{# Tags count #}
<p class="text-secondary text-small m-0">
{% if search or unused_only %}
Showing {{ page.paginator.count }} of {{ total_tags }} tags
{% else %}
{{ total_tags }} tags total
{% endif %}
</p>
</div>
{# Tags List #}
{% if page.object_list %}
<form method="post">
{% csrf_token %}
<table class="table crud-table">
<thead>
<tr>
<th>Name</th>
<th style="width: 25%">Bookmarks</th>
<th class="actions">
<span class="text-assistive">Actions</span>
</th>
</tr>
</thead>
<tbody>
{% for tag in page.object_list %}
<tr>
<td>
{{ tag.name }}
</td>
<td style="width: 25%">
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
{{ tag.bookmark_count }}
</a>
</td>
<td class="actions">
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
ld-confirm-button>
Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% pagination page %}
{% else %}
<div class="empty">
{% if search or unused_only %}
<p class="empty-title h5">No tags found</p>
<p class="empty-subtitle">Try adjusting your search or filters</p>
{% else %}
<p class="empty-title h5">You have no tags yet</p>
<p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
{% endif %}
</div>
{% endif %}
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Merge tags - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-editor-page">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Merge tags</h1>
</div>
<details class="mb-4">
<summary>
<span class="text-bold mb-1">How to merge tags</span>
</summary>
<ol>
<li>Enter the name of the tag you want to keep</li>
<li>Enter the names of tags to merge into the target tag</li>
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
<li>The merged tags are deleted</li>
</ol>
</details>
<form method="post">
{% csrf_token %}
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
<div class="form-input-hint">
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
</div>
{% if form.target_tag.errors %}
<div class="form-input-hint">
{{ form.target_tag.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
tags will be deleted after merging.
</div>
{% if form.merge_tags.errors %}
<div class="form-input-hint">
{{ form.merge_tags.errors }}
</div>
{% endif %}
</div>
<div class="divider"></div>
<div class="form-group d-flex justify-between">
<button type="submit" class="btn btn-primary">Merge Tags</button>
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
</div>
</form>
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% block head %}
{% with page_title="Add tag - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-editor-page">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">New tag</h1>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% include 'tags/form.html' %}
</form>
</main>
</div>
{% endblock %}

View File

@@ -145,3 +145,30 @@ def render_markdown(context, markdown_text):
linkified_html = bleach.linkify(sanitized_html) linkified_html = bleach.linkify(sanitized_html)
return mark_safe(linkified_html) return mark_safe(linkified_html)
def append_attr(widget, attr, value):
attrs = widget.attrs
if attrs.get(attr):
attrs[attr] += " " + value
else:
attrs[attr] = value
@register.filter("form_field")
def form_field(field, modifier_string):
modifiers = modifier_string.split(",")
has_errors = hasattr(field, "errors") and field.errors
if "validation" in modifiers and has_errors:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
if "help" in modifiers:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
# Some assistive technologies announce a field as invalid when it has the
# required attribute, even if the user has not interacted with the field
# yet. Set aria-invalid false to prevent this behavior.
if field.field.required and not has_errors:
append_attr(field.field.widget, "aria-invalid", "false")
return field

View File

@@ -236,8 +236,17 @@ class BookmarkFactoryMixin:
def read_asset_file(self, asset: BookmarkAsset): def read_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
with open(filepath, "rb") as f:
return f.read() if asset.gzip:
with gzip.open(filepath, "rb") as f:
return f.read()
else:
with open(filepath, "rb") as f:
return f.read()
def get_asset_filesize(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
return os.path.getsize(filepath) if os.path.exists(filepath) else 0
def has_asset_file(self, asset: BookmarkAsset): def has_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)

View File

@@ -207,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
# verify file name # verify file name
self.assertTrue(saved_file_name.startswith("upload_")) self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.txt")) self.assertTrue(saved_file_name.endswith("_test_file.txt.gz"))
# file should contain the correct content # file should contain the correct content
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file: self.assertEqual(self.read_asset_file(asset), file_content)
self.assertEqual(file.read(), file_content)
# should create asset # should create asset
self.assertIsNotNone(asset.id) self.assertIsNotNone(asset.id)
@@ -221,6 +220,45 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.display_name, upload_file.name) self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE) self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name) self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
@disable_logging
def test_upload_gzip_asset(self):
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(modified=initial_modified)
file_content = gzip.compress(b"<html>test content</html>")
upload_file = SimpleUploadedFile(
"test_file.html.gz", file_content, content_type="application/gzip"
)
asset = assets.upload_asset(bookmark, upload_file)
# should create file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(upload_file)
# verify file name
self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.html.gz"))
# file should contain the correct content
self.assertEqual(self.read_asset_file(asset), file_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "application/gzip")
self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, len(file_content)) self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip) self.assertFalse(asset.gzip)
@@ -245,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(192, len(saved_file)) self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_")) self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt")) self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
@disable_logging @disable_logging
def test_upload_asset_failure(self): def test_upload_asset_failure(self):

View File

@@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from bookmarks.models import BookmarkSearch, UserProfile from bookmarks.models import BookmarkSearch, UserProfile
@@ -319,6 +319,28 @@ class BookmarkArchivedViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
url = reverse("linkding:bookmarks.archived")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_allowed_bulk_actions_with_sharing_enabled(self): def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile user_profile = self.user.profile
user_profile.enable_sharing = True user_profile.enable_sharing = True
@@ -345,6 +367,34 @@ class BookmarkArchivedViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse("linkding:bookmarks.archived")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("linkding:bookmarks.archived")) response = self.client.post(reverse("linkding:bookmarks.archived"))

View File

@@ -4,9 +4,8 @@ from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.tests.helpers import ( from bookmarks.models import BookmarkAsset
BookmarkFactoryMixin, from bookmarks.tests.helpers import BookmarkFactoryMixin
)
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -23,7 +22,21 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setup_asset_with_file(self, bookmark): def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip" filename = f"temp_{bookmark.id}.html.gzip"
self.setup_asset_file(filename) self.setup_asset_file(filename)
asset = self.setup_asset(bookmark=bookmark, file=filename) asset = self.setup_asset(
bookmark=bookmark, file=filename, display_name=f"Snapshot {bookmark.id}"
)
return asset
def setup_asset_with_uploaded_file(self, bookmark):
filename = f"temp_{bookmark.id}.png.gzip"
self.setup_asset_file(filename)
asset = self.setup_asset(
bookmark=bookmark,
file=filename,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type="image/png",
display_name=f"Uploaded file {bookmark.id}.png",
)
return asset return asset
def view_access_test(self, view_name: str): def view_access_test(self, view_name: str):
@@ -127,3 +140,25 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def test_reader_view_access_guest_user(self): def test_reader_view_access_guest_user(self):
self.view_access_guest_user_test("linkding:assets.read") self.view_access_guest_user_test("linkding:assets.read")
def test_snapshot_download_name(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
self.assertEqual(response["Content-Type"], asset.content_type)
self.assertEqual(
response["Content-Disposition"],
f'inline; filename="{asset.display_name}.html"',
)
def test_uploaded_file_download_name(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_uploaded_file(bookmark)
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
self.assertEqual(response["Content-Type"], asset.content_type)
self.assertEqual(
response["Content-Disposition"],
f'inline; filename="{asset.display_name}"',
)

View File

@@ -253,8 +253,8 @@ class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(asset.display_name, file_name) self.assertEqual(asset.display_name, file_name)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD) self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "text/plain") self.assertEqual(asset.content_type, "text/plain")
self.assertEqual(asset.file_size, len(file_content)) self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
self.assertFalse(asset.gzip) self.assertTrue(asset.gzip)
content = self.read_asset_file(asset) content = self.read_asset_file(asset)
self.assertEqual(content, file_content) self.assertEqual(content, file_content)

View File

@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = self.get_index_details_modal(bookmark) modal = self.get_index_details_modal(bookmark)
delete_button = modal.find("button", {"type": "submit", "name": "remove"}) delete_button = modal.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(delete_button) self.assertIsNotNone(delete_button)
self.assertEqual("Delete...", delete_button.text.strip()) self.assertEqual("Delete", delete_button.text.strip())
self.assertEqual(str(bookmark.id), delete_button["value"]) self.assertEqual(str(bookmark.id), delete_button["value"])
form = delete_button.find_parent("form") form = delete_button.find_parent("form")

View File

@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<input type="text" name="url" value="{bookmark.url}" placeholder=" " <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
autofocus class="form-input" required id="id_url"> """,
""",
html, html,
) )
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<input type="text" name="tag_string" value="{tag_string}" <input type="text" name="tag_string" value="{tag_string}"
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string"> autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string" aria-describedby="id_tag_string_help">
""", """,
html, html,
) )
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes"> <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">
{bookmark.notes} {bookmark.notes}
</textarea> </textarea>
""", """,
@@ -189,6 +188,25 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
edited_bookmark.refresh_from_db() edited_bookmark.refresh_from_db()
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url) self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
def test_should_prevent_duplicate_normalized_urls(self):
self.setup_bookmark(url="https://EXAMPLE.COM/path/?z=1&a=2")
edited_bookmark = self.setup_bookmark(url="http://different.com")
form_data = self.create_form_data({"url": "https://example.com/path?a=2&z=1"})
response = self.client.post(
reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 422)
self.assertInHTML(
"<li>A bookmark with this URL already exists.</li>",
response.content.decode(),
)
edited_bookmark.refresh_from_db()
self.assertEqual(edited_bookmark.url, "http://different.com")
def test_should_redirect_to_return_url(self): def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
form_data = self.create_form_data() form_data = self.create_form_data()
@@ -259,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=0, count=0,
) )
@@ -278,12 +296,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=1, count=1,
) )

View File

@@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from bookmarks.models import BookmarkSearch, UserProfile from bookmarks.models import BookmarkSearch, UserProfile
@@ -313,6 +313,28 @@ class BookmarkIndexViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
url = reverse("linkding:bookmarks.index")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_allowed_bulk_actions_with_sharing_enabled(self): def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile user_profile = self.user.profile
user_profile.enable_sharing = True user_profile.enable_sharing = True
@@ -339,6 +361,34 @@ class BookmarkIndexViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse("linkding:bookmarks.index")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("linkding:bookmarks.index")) response = self.client.post(reverse("linkding:bookmarks.index"))

View File

@@ -78,9 +78,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="text" name="url" value="http://example.com" ' """
'placeholder=" " autofocus class="form-input" required ' <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
'id="id_url">', """,
html, html,
) )
@@ -117,9 +117,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="text" name="tag_string" value="tag1 tag2 tag3" ' """
'class="form-input" autocomplete="off" autocapitalize="off" ' <input type="text" name="tag_string" value="tag1 tag2 tag3"
'id="id_tag_string">', aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
""",
html, html,
) )
@@ -137,8 +138,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
<span class="form-label d-inline-block">Notes</span> <span class="form-label d-inline-block">Notes</span>
</summary> </summary>
<label for="id_notes" class="text-assistive">Notes</label> <label for="id_notes" class="text-assistive">Notes</label>
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea> <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>
<div class="form-input-hint"> <div id="id_notes_help" class="form-input-hint">
Additional notes, supports Markdown. Additional notes, supports Markdown.
</div> </div>
</details> </details>
@@ -196,12 +197,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=0, count=0,
) )
@@ -213,12 +214,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=1, count=1,
) )
@@ -231,10 +232,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
""" """
<div class="form-input-hint"> <div id="id_shared_help" class="form-input-hint">
Share this bookmark with other registered users. Share this bookmark with other registered users.
</div> </div>
""", """,
html, html,
) )
@@ -245,10 +246,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
""" """
<div class="form-input-hint"> <div id="id_shared_help" class="form-input-hint">
Share this bookmark with other registered users and anonymous users. Share this bookmark with other registered users and anonymous users.
</div> </div>
""", """,
html, html,
) )
@@ -265,7 +266,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="checkbox" name="unread" id="id_unread">', '<input type="checkbox" name="unread" id="id_unread" aria-describedby="id_unread_help">',
html, html,
) )
@@ -277,6 +278,31 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="checkbox" name="unread" id="id_unread" checked="">', '<input type="checkbox" name="unread" id="id_unread" checked="" aria-describedby="id_unread_help">',
html,
)
def test_should_not_check_shared_by_default(self):
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode()
self.assertInHTML(
'<input type="checkbox" name="shared" id="id_shared" aria-describedby="id_shared_help">',
html,
)
def test_should_check_shared_when_configured_in_profile(self):
self.user.profile.enable_sharing = True
self.user.profile.default_mark_shared = True
self.user.profile.save()
response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode()
self.assertInHTML(
'<input type="checkbox" name="shared" id="id_shared" checked="" aria-describedby="id_shared_help">',
html, html,
) )

View File

@@ -660,3 +660,21 @@ class BookmarkSharedViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]') feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNotNone(feed) self.assertIsNotNone(feed)
self.assertEqual(feed.attrs["href"], reverse("linkding:feeds.public_shared")) self.assertEqual(feed.attrs["href"], reverse("linkding:feeds.public_shared"))
def test_tag_menu_visible_for_authenticated_user(self):
self.authenticate()
response = self.client.get(reverse("linkding:bookmarks.shared"))
html = response.content.decode()
soup = self.make_soup(html)
tag_menu = soup.find(attrs={"aria-label": "Tags menu"})
self.assertIsNotNone(tag_menu)
def test_tag_menu_not_visible_for_unauthenticated_user(self):
response = self.client.get(reverse("linkding:bookmarks.shared"))
html = response.content.decode()
soup = self.make_soup(html)
tag_menu = soup.find(attrs={"aria-label": "Tags menu"})
self.assertIsNone(tag_menu)

View File

@@ -1047,6 +1047,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(expected_metadata.description, metadata["description"]) self.assertEqual(expected_metadata.description, metadata["description"])
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"]) self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
def test_check_returns_bookmark_using_normalized_url(self):
self.authenticate()
# Create bookmark with one URL variant
bookmark = self.setup_bookmark(
url="https://EXAMPLE.COM/path/?z=1&a=2",
title="Example title",
description="Example description",
)
# Check with different URL variant that should normalize to the same URL
url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com/path?a=2&z=1")
response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
)
bookmark_data = response.data["bookmark"]
# Should find the existing bookmark despite URL differences
self.assertIsNotNone(bookmark_data)
self.assertEqual(bookmark.id, bookmark_data["id"])
self.assertEqual(bookmark.title, bookmark_data["title"])
def test_check_returns_no_auto_tags_if_none_configured(self): def test_check_returns_no_auto_tags_if_none_configured(self):
self.authenticate() self.authenticate()

View File

@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="unshare" value="{bookmark.id}" <button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?"> ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}" <button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?"> ld-confirm-button ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>

View File

@@ -22,6 +22,7 @@ from bookmarks.services.bookmarks import (
unshare_bookmarks, unshare_bookmarks,
enhance_with_website_metadata, enhance_with_website_metadata,
refresh_bookmarks_metadata, refresh_bookmarks_metadata,
create_html_snapshots,
) )
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -94,6 +95,41 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
# Saving a duplicate bookmark should not modify archive flag - right? # Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(updated_bookmark.is_archived) self.assertFalse(updated_bookmark.is_archived)
def test_create_should_update_existing_bookmark_with_normalized_url(
self,
):
original_bookmark = self.setup_bookmark(
url="https://EXAMPLE.com/path/?a=1&z=2", unread=False, shared=False
)
bookmark_data = Bookmark(
url="HTTPS://example.com/path?z=2&a=1",
title="Updated Title",
description="Updated description",
)
updated_bookmark = create_bookmark(
bookmark_data, "", self.get_or_create_test_user()
)
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title)
def test_create_should_populate_url_normalized_field(self):
bookmark_data = Bookmark(
url="https://EXAMPLE.COM/path/?z=1&a=2",
title="Test Title",
description="Test description",
)
created_bookmark = create_bookmark(
bookmark_data, "", self.get_or_create_test_user()
)
created_bookmark.refresh_from_db()
self.assertEqual(created_bookmark.url, "https://EXAMPLE.COM/path/?z=1&a=2")
self.assertEqual(
created_bookmark.url_normalized, "https://example.com/path?a=2&z=1"
)
def test_create_should_create_web_archive_snapshot(self): def test_create_should_create_web_archive_snapshot(self):
with patch.object( with patch.object(
tasks, "create_web_archive_snapshot" tasks, "create_web_archive_snapshot"
@@ -974,3 +1010,73 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 3) self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 3)
self.assertEqual(self.mock_load_preview_image.call_count, 3) self.assertEqual(self.mock_load_preview_image.call_count, 3)
def test_create_html_snapshots(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
create_html_snapshots(
[bookmark1.id, bookmark2.id, bookmark3.id],
self.get_or_create_test_user(),
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(
bookmark_ids, [bookmark1.id, bookmark2.id, bookmark3.id]
)
def test_create_html_snapshots_should_only_create_for_specified_bookmarks(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
create_html_snapshots(
[bookmark1.id, bookmark3.id], self.get_or_create_test_user()
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(bookmark_ids, [bookmark1.id, bookmark3.id])
self.assertNotIn(bookmark2.id, bookmark_ids)
def test_create_html_snapshots_should_only_create_for_user_owned_bookmarks(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
other_user = self.setup_user()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
create_html_snapshots(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(bookmark_ids, [bookmark1.id, bookmark2.id])
self.assertNotIn(inaccessible_bookmark.id, bookmark_ids)
def test_create_html_snapshots_should_accept_mix_of_int_and_string_ids(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
create_html_snapshots(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(
bookmark_ids, [bookmark1.id, bookmark2.id, bookmark3.id]
)

View File

@@ -269,6 +269,24 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists()) self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
def test_delete_bundle_updates_order(self):
self.authenticate()
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
bundle2 = self.setup_bundle(name="Bundle 2", order=1)
bundle3 = self.setup_bundle(name="Bundle 3", order=2)
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle2.id})
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertFalse(BookmarkBundle.objects.filter(id=bundle2.id).exists())
# Check that the remaining bundles have updated orders
bundle1.refresh_from_db()
bundle3.refresh_from_db()
self.assertEqual(bundle1.order, 0)
self.assertEqual(bundle3.order, 1)
def test_delete_bundle_only_allows_own_bundles(self): def test_delete_bundle_only_allows_own_bundles(self):
self.authenticate() self.authenticate()

View File

@@ -120,3 +120,41 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_should_show_correct_preview(self):
bundle_tag = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[bundle_tag])
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
bundle = self.setup_bundle(name="Test Bundle", all_tags=bundle_tag.name)
response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)
def test_should_show_correct_preview_after_posting_invalid_data(self):
initial_tag = self.setup_tag(name="initial-tag")
updated_tag = self.setup_tag(name="updated-tag")
bookmark1 = self.setup_bookmark(tags=[initial_tag])
bookmark2 = self.setup_bookmark(tags=[updated_tag])
bookmark3 = self.setup_bookmark()
bundle = self.setup_bundle(name="Test Bundle", all_tags=initial_tag.name)
form_data = {
"name": "",
"search": "",
"any_tags": "",
"all_tags": updated_tag.name,
"excluded_tags": "",
}
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), form_data
)
self.assertIn(
"Found 1 bookmarks matching this bundle", response.content.decode()
)
self.assertNotIn(bookmark1.title, response.content.decode())
self.assertIn(bookmark2.title, response.content.decode())
self.assertNotIn(bookmark3.title, response.content.decode())

View File

@@ -25,27 +25,27 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
for bundle in bundles: for bundle in bundles:
expected_list_item = f""" expected_list_item = f"""
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true"> <tr data-bundle-id="{bundle.id}" draggable="true">
<div class="list-item-icon text-secondary"> <td>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" <div class="d-flex align-center">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</svg> <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</div> </svg>
<div class="list-item-text"> <span>{ bundle.name }</span>
<span class="truncate">{bundle.name}</span> </div>
</div> </td>
<div class="list-item-actions"> <td class="actions">
<a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a> <a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button> <button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
</div> </td>
</div> </tr>
""" """
self.assertInHTML(expected_list_item, html) self.assertInHTML(expected_list_item, html)
@@ -61,7 +61,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
html = response.content.decode() html = response.content.decode()
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html) self.assertInHTML(f"<span>{user_bundle.name}</span>", html)
self.assertNotIn(other_user_bundle.name, html) self.assertNotIn(other_user_bundle.name, html)
def test_empty_state(self): def test_empty_state(self):
@@ -83,7 +83,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>', f'<a href="{reverse("linkding:bundles.new")}" class="btn">Add bundle</a>',
html, html,
) )
@@ -100,6 +100,18 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists()) self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
def test_remove_bundle_updates_order(self):
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
bundle2 = self.setup_bundle(name="Bundle 2", order=1)
bundle3 = self.setup_bundle(name="Bundle 3", order=2)
self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(bundle2.id)},
)
self.assertBundleOrder([bundle1, bundle3])
def test_remove_other_user_bundle(self): def test_remove_other_user_bundle(self):
other_user = self.setup_user(name="otheruser") other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user) other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)

View File

@@ -1,11 +1,12 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from urllib.parse import urlencode
from bookmarks.models import BookmarkBundle from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin): class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -75,3 +76,72 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = self.create_form_data({"name": ""}) form_data = self.create_form_data({"name": ""})
response = self.client.post(reverse("linkding:bundles.new"), form_data) response = self.client.post(reverse("linkding:bundles.new"), form_data)
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
def test_should_prefill_form_from_search_query_parameters(self):
query = "machine learning #python #ai"
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "machine learning")
self.assertEqual(all_tags_field.get("value"), "python ai")
def test_should_ignore_special_search_commands(self):
query = "python tutorial !untagged !unread"
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "python tutorial")
self.assertIsNone(all_tags_field.get("value"))
def test_should_not_prefill_when_no_query_parameter(self):
response = self.client.get(reverse("linkding:bundles.new"))
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertIsNone(search_field.get("value"))
self.assertIsNone(all_tags_field.get("value"))
def test_should_not_prefill_when_editing_existing_bundle(self):
bundle = self.setup_bundle(
name="Existing Bundle", search="Tutorial", all_tags="java spring"
)
query = "machine learning #python #ai"
url = (
reverse("linkding:bundles.edit", args=[bundle.id])
+ "?"
+ urlencode({"q": query})
)
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "Tutorial")
self.assertEqual(all_tags_field.get("value"), "java spring")
def test_should_show_correct_preview_with_prefilled_values(self):
bundle_tag = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[bundle_tag])
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
query = "#" + bundle_tag.name
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)

View File

@@ -357,3 +357,50 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
def test_sanitize_with_none_text(self): def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None)) self.assertEqual("", sanitize(None))
def test_with_bundle(self):
tag1 = self.setup_tag()
visible_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(tags=[tag1]),
]
self.setup_bookmark(),
self.setup_bookmark(),
bundle = self.setup_bundle(all_tags=tag1.name)
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key])
+ f"?bundle={bundle.id}"
)
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, visible_bookmarks)
def test_with_bundle_not_owned_by_user(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
other_bundle = self.setup_bundle(user=other_user, search="test")
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key])
+ f"?bundle={other_bundle.id}"
)
self.assertEqual(response.status_code, 404)
def test_with_invalid_bundle_id(self):
self.setup_bookmark(title="test bookmark")
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key]) + "?bundle=999999"
)
self.assertEqual(response.status_code, 404)
def test_with_non_numeric_bundle_id(self):
self.setup_bookmark(title="test bookmark")
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key]) + "?bundle=invalid"
)
self.assertEqual(response.status_code, 404)

View File

@@ -366,6 +366,32 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertListEqual(tag_names, ["tag-1", "tag-2", "tag-3"]) self.assertListEqual(tag_names, ["tag-1", "tag-2", "tag-3"])
def test_ignore_long_tag_names(self):
long_tag = "a" * 65
valid_tag = "valid-tag"
test_html = self.render_html(
tags_html=f"""
<DT><A HREF="https://example.com" TAGS="{long_tag}, {valid_tag}">Example.com</A>
<DD>Example.com
"""
)
result = import_netscape_html(test_html, self.get_or_create_test_user())
# Import should succeed
self.assertEqual(result.success, 1)
self.assertEqual(result.failed, 0)
# Only the valid tag should be created
tags = Tag.objects.all()
self.assertEqual(len(tags), 1)
self.assertEqual(tags[0].name, valid_tag)
# Bookmark should only have the valid tag assigned
bookmark = Bookmark.objects.get(url="https://example.com")
bookmark_tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertEqual(bookmark_tag_names, [valid_tag])
@disable_logging @disable_logging
def test_validate_empty_or_missing_bookmark_url(self): def test_validate_empty_or_missing_bookmark_url(self):
test_html = self.render_html( test_html = self.render_html(

View File

@@ -324,3 +324,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual( self.assertEqual(
bookmarks[0].notes, "Interesting notes about the <style> HTML element." bookmarks[0].notes, "Interesting notes about the <style> HTML element."
) )
def test_unescape_href_attribute(self):
html = self.render_html(
tags_html="""
<DT><A HREF="https://example.com&center=123" ADD_DATE="1">Imported bookmark</A>
<DD>Imported bookmark description
"""
)
bookmarks = parse(html)
self.assertEqual(bookmarks[0].href, "https://example.com&center=123")

View File

@@ -1,3 +1,4 @@
import datetime
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
@@ -29,9 +30,6 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response["content-type"], "text/plain; charset=UTF-8") self.assertEqual(response["content-type"], "text/plain; charset=UTF-8")
self.assertEqual(
response["Content-Disposition"], 'attachment; filename="bookmarks.html"'
)
for bookmark in Bookmark.objects.all(): for bookmark in Bookmark.objects.all():
self.assertContains(response, bookmark.url) self.assertContains(response, bookmark.url)
@@ -78,3 +76,18 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertFormErrorHint( self.assertFormErrorHint(
response, "An error occurred during bookmark export." response, "An error occurred during bookmark export."
) )
def test_filename_includes_date_and_time(self):
self.setup_bookmark()
# Mock timezone.now to return a fixed datetime for predictable filename
fixed_time = datetime.datetime(
2023, 5, 15, 14, 30, 45, tzinfo=datetime.timezone.utc
)
with patch("bookmarks.views.settings.timezone.now", return_value=fixed_time):
response = self.client.get(reverse("linkding:settings.export"), follow=True)
self.assertEqual(response.status_code, 200)
expected_filename = 'attachment; filename="bookmarks_2023-05-15_14-30-45.html"'
self.assertEqual(response["Content-Disposition"], expected_filename)

View File

@@ -115,6 +115,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"display_remove_bookmark_action": False, "display_remove_bookmark_action": False,
"permanent_notes": True, "permanent_notes": True,
"default_mark_unread": True, "default_mark_unread": True,
"default_mark_shared": True,
"custom_css": "body { background-color: #000; }", "custom_css": "body { background-color: #000; }",
"auto_tagging_rules": "example.com tag", "auto_tagging_rules": "example.com tag",
"items_per_page": "10", "items_per_page": "10",
@@ -188,6 +189,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
self.user.profile.default_mark_unread, form_data["default_mark_unread"] self.user.profile.default_mark_unread, form_data["default_mark_unread"]
) )
self.assertEqual(
self.user.profile.default_mark_shared, form_data["default_mark_shared"]
)
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"]) self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertEqual( self.assertEqual(
self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"] self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]

View File

@@ -0,0 +1,113 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.user = self.get_or_create_test_user()
self.client.force_login(self.user)
def test_update_tag(self):
tag = self.setup_tag(name="old_name")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
)
self.assertRedirects(response, reverse("linkding:tags.index"))
tag.refresh_from_db()
self.assertEqual(tag.name, "new_name")
def test_allow_case_changes(self):
tag = self.setup_tag(name="tag")
self.client.post(reverse("linkding:tags.edit", args=[tag.id]), {"name": "TAG"})
tag.refresh_from_db()
self.assertEqual(tag.name, "TAG")
def test_can_only_edit_own_tags(self):
other_user = self.setup_user()
tag = self.setup_tag(user=other_user)
response = self.client.post(
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
)
self.assertEqual(response.status_code, 404)
tag.refresh_from_db()
self.assertNotEqual(tag.name, "new_name")
def test_show_error_for_empty_name(self):
tag = self.setup_tag(name="tag1")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag.id]), {"name": ""}
)
self.assertContains(response, "This field is required", status_code=422)
tag.refresh_from_db()
self.assertEqual(tag.name, "tag1")
def test_show_error_for_duplicate_name(self):
tag1 = self.setup_tag(name="tag1")
self.setup_tag(name="tag2")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"}
)
self.assertContains(
response, "Tag &quot;tag2&quot; already exists", status_code=422
)
tag1.refresh_from_db()
self.assertEqual(tag1.name, "tag1")
def test_show_error_for_duplicate_name_different_casing(self):
tag1 = self.setup_tag(name="tag1")
self.setup_tag(name="tag2")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"}
)
self.assertContains(
response, "Tag &quot;TAG2&quot; already exists", status_code=422
)
tag1.refresh_from_db()
self.assertEqual(tag1.name, "tag1")
def test_no_error_for_duplicate_name_different_user(self):
other_user = self.setup_user()
self.setup_tag(name="tag1", user=other_user)
tag2 = self.setup_tag(name="tag2")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag2.id]), {"name": "tag1"}
)
self.assertRedirects(response, reverse("linkding:tags.index"))
tag2.refresh_from_db()
self.assertEqual(tag2.name, "tag1")
def test_update_shows_success_message(self):
tag = self.setup_tag(name="old_name")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag.id]),
{"name": "new_name"},
follow=True,
)
self.assertInHTML(
"""
<div class="toast toast-success" role="alert">
Tag "new_name" updated successfully.
</div>
""",
response.content.decode(),
)

View File

@@ -0,0 +1,281 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
self.user = self.get_or_create_test_user()
self.client.force_login(self.user)
def get_rows(self, response):
html = response.content.decode()
soup = self.make_soup(html)
return soup.select(".crud-table tbody tr")
def find_row(self, rows, tag):
for row in rows:
if tag.name in row.get_text():
return row
return None
def assertRows(self, response, tags):
rows = self.get_rows(response)
self.assertEqual(len(rows), len(tags))
for tag in tags:
row = self.find_row(rows, tag)
self.assertIsNotNone(row, f"Tag '{tag.name}' not found in table")
def assertOrderedRows(self, response, tags):
rows = self.get_rows(response)
self.assertEqual(len(rows), len(tags))
for index, tag in enumerate(tags):
row = rows[index]
self.assertIn(
tag.name,
row.get_text(),
f"Tag '{tag.name}' not found at index {index}",
)
def test_list_tags(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag3 = self.setup_tag()
response = self.client.get(reverse("linkding:tags.index"))
self.assertEqual(response.status_code, 200)
self.assertRows(response, [tag1, tag2, tag3])
def test_show_user_owned_tags(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag3 = self.setup_tag()
other_user = self.setup_user()
self.setup_tag(user=other_user)
self.setup_tag(user=other_user)
self.setup_tag(user=other_user)
response = self.client.get(reverse("linkding:tags.index"))
self.assertRows(response, [tag1, tag2, tag3])
def test_search_tags(self):
tag1 = self.setup_tag(name="programming")
self.setup_tag(name="python")
self.setup_tag(name="django")
self.setup_tag(name="design")
response = self.client.get(reverse("linkding:tags.index") + "?search=prog")
self.assertRows(response, [tag1])
def test_filter_unused_tags(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag3 = self.setup_tag()
self.setup_bookmark(tags=[tag1])
self.setup_bookmark(tags=[tag3])
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
self.assertRows(response, [tag2])
def test_rows_have_links_to_filtered_bookmarks(self):
tag1 = self.setup_tag(name="python")
tag2 = self.setup_tag(name="django-framework")
self.setup_bookmark(tags=[tag1])
self.setup_bookmark(tags=[tag1, tag2])
response = self.client.get(reverse("linkding:tags.index"))
rows = self.get_rows(response)
tag1_row = self.find_row(rows, tag1)
view_link = tag1_row.find("a", string=lambda s: s and s.strip() == "2")
expected_url = reverse("linkding:bookmarks.index") + "?q=%23python"
self.assertEqual(view_link["href"], expected_url)
tag2_row = self.find_row(rows, tag2)
view_link = tag2_row.find("a", string=lambda s: s and s.strip() == "1")
expected_url = reverse("linkding:bookmarks.index") + "?q=%23django-framework"
self.assertEqual(view_link["href"], expected_url)
def test_shows_tag_total(self):
tag1 = self.setup_tag(name="python")
tag2 = self.setup_tag(name="javascript")
tag3 = self.setup_tag(name="design")
self.setup_tag(name="unused-tag")
self.setup_bookmark(tags=[tag1])
self.setup_bookmark(tags=[tag2])
self.setup_bookmark(tags=[tag3])
response = self.client.get(reverse("linkding:tags.index"))
self.assertContains(response, "4 tags total")
response = self.client.get(reverse("linkding:tags.index") + "?search=python")
self.assertContains(response, "Showing 1 of 4 tags")
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
self.assertContains(response, "Showing 1 of 4 tags")
response = self.client.get(
reverse("linkding:tags.index") + "?search=nonexistent"
)
self.assertContains(response, "Showing 0 of 4 tags")
def test_pagination(self):
tags = []
for i in range(75):
tags.append(self.setup_tag())
response = self.client.get(reverse("linkding:tags.index"))
rows = self.get_rows(response)
self.assertEqual(len(rows), 50)
response = self.client.get(reverse("linkding:tags.index") + "?page=2")
rows = self.get_rows(response)
self.assertEqual(len(rows), 25)
def test_delete_action(self):
tag = self.setup_tag(name="tag_to_delete")
response = self.client.post(
reverse("linkding:tags.index"), {"delete_tag": tag.id}
)
self.assertRedirects(response, reverse("linkding:tags.index"))
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
def test_tag_delete_action_shows_success_message(self):
tag = self.setup_tag(name="tag_to_delete")
response = self.client.post(
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertInHTML(
"""
<div class="toast toast-success" role="alert">
Tag "tag_to_delete" deleted successfully.
</div>
""",
response.content.decode(),
)
def test_tag_delete_action_preserves_query_parameters(self):
tag = self.setup_tag(name="search_tag")
url = (
reverse("linkding:tags.index")
+ "?search=search&unused=true&page=2&sort=name-desc"
)
response = self.client.post(url, {"delete_tag": tag.id})
self.assertRedirects(response, url)
def test_tag_delete_action_only_deletes_own_tags(self):
other_user = self.setup_user()
other_tag = self.setup_tag(user=other_user, name="other_user_tag")
response = self.client.post(
reverse("linkding:tags.index"), {"delete_tag": other_tag.id}, follow=True
)
self.assertEqual(response.status_code, 404)
def test_sort_by_name_ascending(self):
tag_c = self.setup_tag(name="c_tag")
tag_a = self.setup_tag(name="a_tag")
tag_b = self.setup_tag(name="b_tag")
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-asc")
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
def test_sort_by_name_descending(self):
tag_c = self.setup_tag(name="c_tag")
tag_a = self.setup_tag(name="a_tag")
tag_b = self.setup_tag(name="b_tag")
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
self.assertOrderedRows(response, [tag_c, tag_b, tag_a])
def test_sort_by_bookmark_count_ascending(self):
tag_few = self.setup_tag(name="few_bookmarks")
tag_many = self.setup_tag(name="many_bookmarks")
tag_none = self.setup_tag(name="no_bookmarks")
self.setup_bookmark(tags=[tag_few])
self.setup_bookmark(tags=[tag_many])
self.setup_bookmark(tags=[tag_many])
self.setup_bookmark(tags=[tag_many])
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-asc")
self.assertOrderedRows(response, [tag_none, tag_few, tag_many])
def test_sort_by_bookmark_count_descending(self):
tag_few = self.setup_tag(name="few_bookmarks")
tag_many = self.setup_tag(name="many_bookmarks")
tag_none = self.setup_tag(name="no_bookmarks")
self.setup_bookmark(tags=[tag_few])
self.setup_bookmark(tags=[tag_many])
self.setup_bookmark(tags=[tag_many])
self.setup_bookmark(tags=[tag_many])
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-desc")
self.assertOrderedRows(response, [tag_many, tag_few, tag_none])
def test_default_sort_is_name_ascending(self):
tag_c = self.setup_tag(name="c_tag")
tag_a = self.setup_tag(name="a_tag")
tag_b = self.setup_tag(name="b_tag")
response = self.client.get(reverse("linkding:tags.index"))
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
def test_sort_select_has_correct_options_and_selection(self):
self.setup_tag()
response = self.client.get(reverse("linkding:tags.index"))
html = response.content.decode()
self.assertInHTML(
"""
<select id="sort" name="sort" class="form-select" ld-auto-submit>
<option value="name-asc" selected>Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="count-asc">Fewest bookmarks</option>
<option value="count-desc">Most bookmarks</option>
</select>
""",
html,
)
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
html = response.content.decode()
self.assertInHTML(
"""
<select id="sort" name="sort" class="form-select" ld-auto-submit>
<option value="name-asc">Name A-Z</option>
<option value="name-desc" selected>Name Z-A</option>
<option value="count-asc">Fewest bookmarks</option>
<option value="count-desc">Most bookmarks</option>
</select>
""",
html,
)

View File

@@ -0,0 +1,219 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
self.user = self.get_or_create_test_user()
self.client.force_login(self.user)
def get_form_group(self, response, input_name):
soup = self.make_soup(response.content.decode())
input_element = soup.find("input", {"name": input_name})
if input_element:
return input_element.find_parent("div", class_="form-group")
return None
def test_merge_tags(self):
target_tag = self.setup_tag(name="target_tag")
merge_tag1 = self.setup_tag(name="merge_tag1")
merge_tag2 = self.setup_tag(name="merge_tag2")
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
bookmark3 = self.setup_bookmark(tags=[target_tag])
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
)
self.assertRedirects(response, reverse("linkding:tags.index"))
self.assertEqual(Tag.objects.count(), 1)
self.assertFalse(Tag.objects.filter(id=merge_tag1.id).exists())
self.assertFalse(Tag.objects.filter(id=merge_tag2.id).exists())
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
def test_merge_tags_complex(self):
target_tag = self.setup_tag(name="target_tag")
merge_tag1 = self.setup_tag(name="merge_tag1")
merge_tag2 = self.setup_tag(name="merge_tag2")
other_tag = self.setup_tag(name="other_tag")
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
bookmark3 = self.setup_bookmark(tags=[target_tag])
bookmark4 = self.setup_bookmark(
tags=[merge_tag1, merge_tag2]
) # both merge tags
bookmark5 = self.setup_bookmark(
tags=[merge_tag2, target_tag]
) # already has target tag
bookmark6 = self.setup_bookmark(
tags=[merge_tag1, merge_tag2, target_tag]
) # both merge tags and target
bookmark7 = self.setup_bookmark(tags=[other_tag]) # unrelated tag
bookmark8 = self.setup_bookmark(
tags=[other_tag, merge_tag1]
) # merge and unrelated tag
bookmark9 = self.setup_bookmark(
tags=[other_tag, target_tag]
) # merge and target tag
bookmark10 = self.setup_bookmark(tags=[]) # no tags
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
)
self.assertRedirects(response, reverse("linkding:tags.index"))
self.assertEqual(Bookmark.objects.count(), 10)
self.assertEqual(Tag.objects.count(), 2)
self.assertEqual(Bookmark.tags.through.objects.count(), 11)
self.assertCountEqual(list(Tag.objects.all()), [target_tag, other_tag])
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark4.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark5.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark6.tags.all()), [target_tag])
self.assertCountEqual(list(bookmark7.tags.all()), [other_tag])
self.assertCountEqual(list(bookmark8.tags.all()), [other_tag, target_tag])
self.assertCountEqual(list(bookmark9.tags.all()), [other_tag, target_tag])
self.assertCountEqual(list(bookmark10.tags.all()), [])
def test_can_only_merge_own_tags(self):
other_user = self.setup_user()
self.setup_tag(name="target_tag", user=other_user)
self.setup_tag(name="merge_tag", user=other_user)
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn('Tag "target_tag" does not exist', target_tag_group.get_text())
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn('Tag "merge_tag" does not exist', merge_tags_group.get_text())
def test_validate_missing_target_tag(self):
merge_tag = self.setup_tag(name="merge_tag")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "", "merge_tags": "merge_tag"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn("This field is required", target_tag_group.get_text())
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
def test_validate_missing_merge_tags(self):
self.setup_tag(name="target_tag")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": ""},
)
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn("This field is required", merge_tags_group.get_text())
def test_validate_nonexistent_target_tag(self):
merge_tag = self.setup_tag(name="merge_tag")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
'Tag "nonexistent_tag" does not exist', target_tag_group.get_text()
)
def test_validate_nonexistent_merge_tag(self):
self.setup_tag(name="target_tag")
self.setup_tag(name="merge_tag1")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"},
)
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn(
'Tag "nonexistent_tag" does not exist', merge_tags_group.get_text()
)
def test_validate_multiple_target_tags(self):
self.setup_tag(name="target_tag1")
self.setup_tag(name="target_tag2")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
"Please enter only one tag name for the target tag",
target_tag_group.get_text(),
)
def test_validate_target_tag_in_merge_list(self):
self.setup_tag(name="target_tag")
self.setup_tag(name="merge_tag")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"},
)
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn(
"The target tag cannot be selected for merging", merge_tags_group.get_text()
)
def test_merge_shows_success_message(self):
self.setup_tag(name="target_tag")
self.setup_tag(name="merge_tag1")
self.setup_tag(name="merge_tag2")
response = self.client.post(
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
follow=True,
)
self.assertInHTML(
"""
<div class="toast toast-success" role="alert">
Successfully merged 2 tags (merge_tag1, merge_tag2) into "target_tag".
</div>
""",
response.content.decode(),
)

View File

@@ -41,6 +41,11 @@ class TagTestCase(TestCase):
parse_tag_string("book,,movie,,,album"), ["album", "book", "movie"] parse_tag_string("book,,movie,,,album"), ["album", "book", "movie"]
) )
def test_parse_tag_string_handles_duplicate_separators_with_spaces(self):
self.assertCountEqual(
parse_tag_string("book, ,movie, , ,album"), ["album", "book", "movie"]
)
def test_parse_tag_string_replaces_whitespace_within_names(self): def test_parse_tag_string_replaces_whitespace_within_names(self):
self.assertCountEqual( self.assertCountEqual(
parse_tag_string("travel guide, book recommendations"), parse_tag_string("travel guide, book recommendations"),

Some files were not shown because too many files have changed in this diff Show More