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-lock.json
!/postcss.config.js
!/requirements.dev.txt
!/requirements.txt
!/pyproject.toml
!/rollup.config.mjs
!/supervisord.conf
!/uv.lock
!/uwsgi.ini
!/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
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Node
uses: actions/setup-node@v4
with:
@@ -25,10 +27,10 @@ jobs:
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt
uv sync
mkdir data
- name: Run tests
run: python manage.py test bookmarks.tests
run: uv run manage.py test bookmarks.tests
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
@@ -37,7 +39,9 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Node
uses: actions/setup-node@v4
with:
@@ -47,12 +51,12 @@ jobs:
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt
playwright install chromium
uv sync
uv run playwright install chromium
mkdir data
- name: Run build
run: |
npm run build
python manage.py collectstatic
uv run manage.py collectstatic
- 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
# 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
## 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)
### What's Changed

View File

@@ -1,15 +1,23 @@
.PHONY: serve
init:
uv sync
uv run manage.py migrate
npm install
serve:
python manage.py runserver
uv run manage.py runserver
tasks:
python manage.py run_huey
uv run manage.py run_huey
test:
pytest -n auto
uv run pytest -n auto
format:
black bookmarks
uv run black bookmarks
npx prettier bookmarks/frontend --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 🙂.
### Prerequisites
- Python 3.12
- Python 3.13
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Node.js
### 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
```
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
make init
```
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:
```
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
@@ -117,6 +105,11 @@ make format
### 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)
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.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
@@ -206,7 +214,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("custom_display_name", "date_created", "status")
search_fields = (
"custom_display_name",
"display_name",
"file",
)
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):
model = UserProfile
can_delete = False
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast)

View File

@@ -26,7 +26,8 @@ from bookmarks.models import (
User,
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.views import access
@@ -107,7 +108,10 @@ class BookmarkViewSet(
def check(self, request: HttpRequest):
url = request.GET.get("url")
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 = (
self.get_serializer(bookmark).data if bookmark else None
)
@@ -151,7 +155,10 @@ class BookmarkViewSet(
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:
bookmark = Bookmark(url=url)
@@ -199,13 +206,10 @@ class BookmarkAssetViewSet(
if asset.gzip
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["Content-Disposition"] = f'attachment; filename="{file_name}"'
response["Content-Disposition"] = (
f'attachment; filename="{asset.download_name}"'
)
return response
except FileNotFoundError:
raise Http404("Asset file does not exist")
@@ -290,6 +294,9 @@ class BookmarkBundleViewSet(
def get_serializer_context(self):
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>/
# 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,
BookmarkBundle,
)
from bookmarks.services import bookmarks
from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version
@@ -55,17 +55,9 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
]
def create(self, validated_data):
# Set owner to the authenticated user
validated_data["owner"] = 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)
bundle = BookmarkBundle(**validated_data)
bundle.order = validated_data["order"] if "order" in validated_data else None
return bundles.create_bundle(bundle, self.context["user"])
class BookmarkSerializer(serializers.ModelSerializer):

View File

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

View File

@@ -1,9 +1,22 @@
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.validators import BookmarkURLValidator
from bookmarks.type_defs import HttpRequest
from bookmarks.models import (
Bookmark,
Tag,
build_tag_string,
parse_tag_string,
sanitize_tag_name,
)
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):
@@ -44,11 +57,14 @@ class BookmarkForm(forms.ModelForm):
"tag_string": request.GET.get("tags"),
"auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread,
"shared": request.user_profile.default_mark_shared,
}
if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
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
def is_auto_close(self):
@@ -78,8 +94,11 @@ class BookmarkForm(forms.ModelForm):
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
normalized_url = normalize_url(url)
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)
.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
# strings
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 { FocusTrapController, isKeyboardActive } from "./focus-utils";
let confirmId = 0;
function nextConfirmId() {
return `confirm-${confirmId++}`;
}
class ConfirmButtonBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
this.element.addEventListener("click", this.onClick);
}
destroy() {
this.reset();
if (this.opened) {
this.close();
}
this.element.removeEventListener("click", this.onClick);
}
onClick(event) {
event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.element.getAttribute("ld-confirm-icon");
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);
if (this.opened) {
this.close();
} else {
this.open();
}
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() {
setTimeout(() => {
Behavior.interacting = false;
if (this.container) {
this.container.remove();
this.container = null;
}
this.element.classList.remove("d-none");
open() {
const dropdown = document.createElement("div");
dropdown.className = "dropdown confirm-dropdown active";
const confirmId = nextConfirmId();
const questionId = `${confirmId}-question`;
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);

View File

@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<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"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>

View File

@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
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
for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target);

View File

@@ -1,5 +1,27 @@
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 {
constructor(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 {
constructor(element) {
super(element);
@@ -51,5 +103,7 @@ class UploadButton extends Behavior {
}
}
registerBehavior("ld-form-submit", FormSubmit);
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-form-reset", FormResetBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import "../components/TagAutocomplete.js";
class TagAutocomplete extends Behavior {
constructor(element) {
@@ -10,21 +10,16 @@ class TagAutocomplete extends Behavior {
return;
}
const container = document.createElement("div");
new TagAutoCompleteComponent({
target: container,
props: {
id: input.id,
name: input.name,
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
variant: input.getAttribute("variant"),
},
});
const autocomplete = document.createElement("ld-tag-autocomplete");
autocomplete.id = input.id;
autocomplete.name = input.name;
autocomplete.value = input.value;
autocomplete.placeholder = input.getAttribute("placeholder") || "";
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
autocomplete.variant = input.getAttribute("variant") || "default";
this.input = input;
this.autocomplete = container.firstElementChild;
this.autocomplete = 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/tag-autocomplete";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { api } from "./api";
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) {
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.http import QueryDict
from bookmarks.utils import unique
from bookmarks.utils import unique, normalize_url
from bookmarks.validators import BookmarkURLValidator
logger = logging.getLogger(__name__)
@@ -40,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
return []
names = tag_string.strip().split(delimiter)
# 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
names = unique(names, 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):
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)
description = 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()]
return sorted(names)
def save(self, *args, **kwargs):
self.url_normalized = normalize_url(self.url)
super().save(*args, **kwargs)
def __str__(self):
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)
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):
if self.file:
try:
@@ -466,6 +479,7 @@ class UserProfile(models.Model):
search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, 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(
null=False, default=30, validators=[MinValueValidator(10)]
)
@@ -507,6 +521,7 @@ class UserProfileForm(forms.ModelForm):
"display_remove_bookmark_action",
"permanent_notes",
"default_mark_unread",
"default_mark_shared",
"custom_css",
"auto_tagging_rules",
"items_per_page",

View File

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

View File

@@ -4,6 +4,7 @@ from typing import Union
from django.utils import timezone
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 tasks
from bookmarks.services import website_loader
@@ -19,8 +20,9 @@ def create_bookmark(
disable_html_snapshot: bool = False,
):
# If URL is already bookmarked, then update it
normalized_url = normalize_url(bookmark.url)
existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url=bookmark.url
owner=current_user, url_normalized=normalized_url
).first()
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)
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):
to_bookmark.title = from_bookmark.title
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 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)
if not tag:
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}"
subprocess.run(command, check=True, shell=True)
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
with (
open(temp_filepath, "rb") as raw_file,
gzip.open(filepath, "wb") as gz_file,
):
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)

View File

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

View File

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

View File

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

View File

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

View File

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

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 "layout.css";
@import "components.css";
@import "crud.css";
@import "bookmark-details.css";
@import "bookmark-form.css";
@import "bookmark-page.css";
@@ -29,3 +30,4 @@
@import "reader-mode.css";
@import "settings.css";
@import "bundles.css";
@import "tags.css";

View File

@@ -3,13 +3,14 @@
position: relative;
& .form-autocomplete-input {
box-sizing: border-box;
align-content: flex-start;
display: flex;
flex-wrap: wrap;
height: auto;
min-height: var(--unit-8);
padding: var(--unit-h);
background: var(--input-bg-color);
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
&.is-focused {
outline: var(--focus-outline);
@@ -22,10 +23,11 @@
box-shadow: none;
display: inline-block;
flex: 1 0 auto;
height: var(--unit-6);
line-height: var(--unit-4);
margin: var(--unit-h);
width: auto;
width: 100%;
height: 100%;
margin: 0;
border: none;
&:focus {
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 {
display: none;
left: 0;
position: absolute;
top: 100%;
width: 100%;
max-height: var(--menu-max-height, 200px);
overflow: auto;
& .menu-item.selected > a,
& .menu-item > a:hover {
@@ -54,4 +75,8 @@
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 */
&.btn-link {

View File

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

View File

@@ -87,4 +87,43 @@
border-bottom: solid 1px var(--secondary-border-color);
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 {
background: none;
border: none;
padding: 0;
line-height: 0;
cursor: pointer;
opacity: 0.85;
color: var(--secondary-text-color);
&:hover {
opacity: 1;
}
height: auto;
}
}
@@ -106,7 +97,6 @@
& .modal-footer {
padding: var(--unit-6);
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 {
& a {
background: var(--primary-color);

View File

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

View File

@@ -242,6 +242,36 @@
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 {
margin-left: auto;
}
@@ -291,6 +321,10 @@
}
/* Flex */
.flex-column {
flex-direction: column;
}
.align-baseline {
align-items: baseline;
}
@@ -302,3 +336,7 @@
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: var(--unit-2);
}

View File

@@ -49,20 +49,22 @@
--body-color-contrast: var(--gray-100);
/* Fonts */
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
Roboto;
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
monospace;
--base-font-family:
-apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
--mono-font-family:
"SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
--fallback-font-family: "Helvetica Neue", sans-serif;
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
var(--fallback-font-family);
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
var(--fallback-font-family);
--cjk-zh-hans-font-family:
var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", var(--fallback-font-family);
--cjk-zh-hant-font-family:
var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
"Microsoft JhengHei", var(--fallback-font-family);
--cjk-jp-font-family:
var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
"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);
/* Unit sizes */
@@ -145,6 +147,6 @@
/* Shadows */
--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-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
--box-shadow-lg:
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 %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
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">
<use xlink:href="#ld-icon-unread"></use>
</svg>
@@ -130,7 +130,7 @@
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
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">
<use xlink:href="#ld-icon-share"></use>
</svg>

View File

@@ -1,7 +1,7 @@
(function () {
var bookmarkUrl = window.location;
var applicationUrl = '{{ application_url }}';
const bookmarkUrl = window.location;
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
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>
{% endif %}
<option value="bulk_refresh">Refresh from website</option>
{% if bookmark_list.snapshot_feature_enabled %}
<option value="bulk_snapshot">Create HTML snapshot</option>
{% endif %}
</select>
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
<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 %}
<section aria-labelledby="bundles-heading">
<div class="section-header">
<div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2>
<a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles">
<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="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
</svg>
</a>
<div ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles 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: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>
<ul class="bundle-menu">
{% for bundle in bundles.bundles %}

View File

@@ -3,8 +3,8 @@
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2>
<button class="close" aria-label="Close dialog">
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
<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"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -32,7 +32,7 @@
<input type="hidden" name="disable_turbo" value="true">
<button ld-confirm-button class="btn btn-error btn-wide"
type="submit" name="remove" value="{{ details.bookmark.id }}">
Delete...
Delete
</button>
</form>
</div>

View File

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

View File

@@ -1,5 +1,6 @@
{% load widget_tweaks %}
{% load static %}
{% load shared %}
<div class="bookmarks-form">
{% csrf_token %}
@@ -7,7 +8,7 @@
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
<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>
</div>
{% if form.url.errors %}
@@ -22,8 +23,8 @@
</div>
<div class="form-group" ld-tag-autocomplete>
<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" }}
<div class="form-input-hint">
{{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<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 (#).
If a tag does not exist it will be automatically created.
</div>
@@ -35,7 +36,8 @@
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="flex">
<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
</button>
</div>
@@ -60,31 +62,31 @@
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint">
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
Additional notes, supports Markdown.
</div>
</details>
{{ form.notes.errors }}
</div>
<div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }}
<div class="form-checkbox">
{{ form.unread|form_field:"help" }}
<i class="form-icon"></i>
<span>Mark as unread</span>
</label>
<div class="form-input-hint">
<label for="{{ form.unread.id_for_label }}">Mark as unread</label>
</div>
<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.
</div>
</div>
{% if request.user_profile.enable_sharing %}
<div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }}
<div class="form-checkbox">
{{ form.shared|form_field:"help" }}
<i class="form-icon"></i>
<span>Share</span>
</label>
<div class="form-input-hint">
<label for="{{ form.shared.id_for_label }}">Share</label>
</div>
<div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
{% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users.
{% else %}
@@ -100,7 +102,7 @@
{% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %}
<a href="{{ return_url }}" class="btn">Nevermind</a>
<a href="{{ return_url }}" class="btn">Cancel</a>
</div>
<script type="application/javascript">
/**
@@ -227,6 +229,7 @@
}
});
}
refreshButton.addEventListener('click', refreshMetadata);
// 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>
</symbol>
</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">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
@@ -41,18 +29,6 @@
<path d="M8.7 13.3l6.6 3.4"></path>
</symbol>
</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">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">

View File

@@ -12,7 +12,7 @@
<div class="section-header">
<h1 id="main-heading">New bookmark</h1>
</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' %}
</form>
</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: light)" content="#5856e0">
{% 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>
<body>
<template id="content">{{ content|safe }}</template>

View File

@@ -1,6 +1,24 @@
<section aria-labelledby="tags-heading">
<div class="section-header">
<div class="section-header no-wrap">
<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 id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}

View File

@@ -7,41 +7,55 @@
{% endblock %}
{% block content %}
<main class="bundles-page" aria-labelledby="main-heading">
<h1 id="main-heading">Bundles</h1>
<main class="bundles-page crud-page" aria-labelledby="main-heading">
<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' %}
{% if bundles %}
<form action="{% url 'linkding:bundles.action' %}" method="post">
{% 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 %}
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
<div class="list-item-icon text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-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="M9 19m-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 12m-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"/>
</svg>
</div>
<div class="list-item-text">
<span class="truncate">{{ bundle.name }}</span>
</div>
<div class="list-item-actions">
<tr data-bundle-id="{{ bundle.id }}" draggable="true">
<td>
<div class="d-flex align-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-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="M9 19m-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 12m-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"/>
</svg>
<span>{{ bundle.name }}</span>
</div>
</td>
<td class="actions">
<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 }}"
class="btn btn-link">Remove
</button>
</div>
</div>
</td>
</tr>
{% endfor %}
</div>
</tbody>
</table>
<input type="submit" name="move_bundle" value="" class="d-none">
<input type="hidden" name="move_position" value="">
</form>
@@ -51,21 +65,17 @@
<p class="empty-subtitle">Create your first bundle to get started</p>
</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
</div>
</main>
<script>
(function init() {
const bundlesList = document.querySelector(".item-list.bundles");
if (!bundlesList) return;
const tableBody = document.querySelector(".crud-table tbody");
if (!tableBody) return;
let draggedElement = null;
const listItems = bundlesList.querySelectorAll('.list-item');
listItems.forEach((item) => {
const rows = tableBody.querySelectorAll('tr');
rows.forEach((item) => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd);
item.addEventListener('dragover', handleDragOver);
@@ -91,7 +101,7 @@
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
const movePositionInput = document.querySelector('input[name="move_position"]');
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');
form.requestSubmit(moveBundleInput);
@@ -108,7 +118,7 @@
function handleDragEnter() {
if (this !== draggedElement) {
const listItems = Array.from(bundlesList.children);
const listItems = Array.from(tableBody.children);
const draggedIndex = listItems.indexOf(draggedElement);
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.
</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">
<details {% if form.custom_css.value %}open{% endif %}>
<summary>
@@ -383,17 +394,17 @@ reddit.com/r/Music music reddit</pre>
<td>{{ version_info }}</td>
</tr>
<tr>
<td rowspan="3" style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://linkding.link/"
target="_blank">Documentation</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a></td>
<td style="vertical-align: top">Links</td>
<td>
<div class="d-flex flex-column gap-2">
<a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a>
<a href="https://linkding.link/"
target="_blank">Documentation</a>
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a>
</div>
</td>
</tr>
</tbody>
</table>
@@ -404,21 +415,25 @@ reddit.com/r/Music music reddit</pre>
(function init() {
const enableSharing = document.getElementById("{{ form.enable_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 bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
// Automatically disable public bookmark sharing if bookmark sharing is disabled
function updatePublicSharing() {
// Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled
function updateSharingOptions() {
if (enableSharing.checked) {
enablePublicSharing.disabled = false;
defaultMarkShared.disabled = false;
} else {
enablePublicSharing.disabled = true;
enablePublicSharing.checked = false;
defaultMarkShared.disabled = true;
defaultMarkShared.checked = false;
}
}
updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing);
updateSharingOptions();
enableSharing.addEventListener("change", updateSharingOptions);
// Automatically hide the bookmark description max lines input if the description display is set to inline
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
application first. Here's how it works:</p>
<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>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>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>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
<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 aria-labelledby="rest-api-heading">
@@ -90,4 +108,28 @@
</p>
</section>
</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 %}

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)
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):
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):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)

View File

@@ -207,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
# verify file name
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
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
self.assertEqual(file.read(), file_content)
self.assertEqual(self.read_asset_file(asset), file_content)
# should create asset
self.assertIsNotNone(asset.id)
@@ -221,6 +220,45 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
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, 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.assertFalse(asset.gzip)
@@ -245,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt"))
self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
@disable_logging
def test_upload_asset_failure(self):

View File

@@ -1,7 +1,7 @@
import urllib.parse
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 bookmarks.models import BookmarkSearch, UserProfile
@@ -319,6 +319,28 @@ class BookmarkArchivedViewTestCase(
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):
user_profile = self.user.profile
user_profile.enable_sharing = True
@@ -345,6 +367,34 @@ class BookmarkArchivedViewTestCase(
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):
# no params
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.urls import reverse
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.models import BookmarkAsset
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -23,7 +22,21 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip"
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
def view_access_test(self, view_name: str):
@@ -127,3 +140,25 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def test_reader_view_access_guest_user(self):
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.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "text/plain")
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
self.assertTrue(asset.gzip)
content = self.read_asset_file(asset)
self.assertEqual(content, file_content)

View File

@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = self.get_index_details_modal(bookmark)
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
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"])
form = delete_button.find_parent("form")

View File

@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
autofocus class="form-input" required id="id_url">
""",
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
""",
html,
)
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<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,
)
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
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}
</textarea>
""",
@@ -189,6 +188,25 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
edited_bookmark.refresh_from_db()
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):
bookmark = self.setup_bookmark()
form_data = self.create_form_data()
@@ -259,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
"""
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
""",
<div class="form-checkbox">
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i>
<label for="id_shared">Share</label>
</div>
""",
html,
count=0,
)
@@ -278,12 +296,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
"""
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
""",
<div class="form-checkbox">
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i>
<label for="id_shared">Share</label>
</div>
""",
html,
count=1,
)

View File

@@ -1,7 +1,7 @@
import urllib.parse
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 bookmarks.models import BookmarkSearch, UserProfile
@@ -313,6 +313,28 @@ class BookmarkIndexViewTestCase(
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):
user_profile = self.user.profile
user_profile.enable_sharing = True
@@ -339,6 +361,34 @@ class BookmarkIndexViewTestCase(
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):
# no params
response = self.client.post(reverse("linkding:bookmarks.index"))

View File

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

View File

@@ -660,3 +660,21 @@ class BookmarkSharedViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNotNone(feed)
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.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):
self.authenticate()

View File

@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f"""
<button type="submit" name="unshare" value="{bookmark.id}"
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">
<use xlink:href="#ld-icon-share"></use>
</svg>
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}"
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">
<use xlink:href="#ld-icon-unread"></use>
</svg>

View File

@@ -22,6 +22,7 @@ from bookmarks.services.bookmarks import (
unshare_bookmarks,
enhance_with_website_metadata,
refresh_bookmarks_metadata,
create_html_snapshots,
)
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -94,6 +95,41 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
# Saving a duplicate bookmark should not modify archive flag - right?
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):
with patch.object(
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_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())
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):
self.authenticate()

View File

@@ -120,3 +120,41 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
)
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:
expected_list_item = f"""
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
<div class="list-item-icon text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-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="M9 19m-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 12m-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"/>
</svg>
</div>
<div class="list-item-text">
<span class="truncate">{bundle.name}</span>
</div>
<div class="list-item-actions">
<tr data-bundle-id="{bundle.id}" draggable="true">
<td>
<div class="d-flex align-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-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="M9 19m-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 12m-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"/>
</svg>
<span>{ bundle.name }</span>
</div>
</td>
<td class="actions">
<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>
</div>
</div>
</td>
</tr>
"""
self.assertInHTML(expected_list_item, html)
@@ -61,7 +61,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200)
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)
def test_empty_state(self):
@@ -83,7 +83,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode()
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,
)
@@ -100,6 +100,18 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
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):
other_user = self.setup_user(name="otheruser")
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.urls import reverse
from urllib.parse import urlencode
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:
user = self.get_or_create_test_user()
@@ -75,3 +76,72 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = self.create_form_data({"name": ""})
response = self.client.post(reverse("linkding:bundles.new"), form_data)
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):
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"])
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
def test_validate_empty_or_missing_bookmark_url(self):
test_html = self.render_html(

View File

@@ -324,3 +324,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual(
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 django.test import TestCase
@@ -29,9 +30,6 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200)
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():
self.assertContains(response, bookmark.url)
@@ -78,3 +76,18 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertFormErrorHint(
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,
"permanent_notes": True,
"default_mark_unread": True,
"default_mark_shared": True,
"custom_css": "body { background-color: #000; }",
"auto_tagging_rules": "example.com tag",
"items_per_page": "10",
@@ -188,6 +189,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
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.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"]
)
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):
self.assertCountEqual(
parse_tag_string("travel guide, book recommendations"),

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