Compare commits

...

36 Commits

Author SHA1 Message Date
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
Sascha Ißbrücker
2bb33ff96d Bump version 2025-06-19 22:23:34 +02:00
Sascha Ißbrücker
549554cc17 Add REST API for bookmark bundles (#1100)
* Add bundles API

* Add docs
2025-06-19 22:19:29 +02:00
Peter
20e31397cc Add LinkBuddy to community section (#1088)
* Updates community resources to add LinkBuddy, an open-source React Native android and iOS app

* Fix ordering

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-06-19 17:40:29 +02:00
Sascha Ißbrücker
94ae5fb41c Fix assets not using correct icon (#1098) 2025-06-19 17:37:16 +02:00
dependabot[bot]
2a550e2315 Bump urllib3 from 2.2.3 to 2.5.0 (#1096)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.3 to 2.5.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.2.3...2.5.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 17:21:32 +02:00
dependabot[bot]
a79e8bcd59 Bump requests from 2.32.3 to 2.32.4 (#1090)
Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  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-06-19 17:21:05 +02:00
dependabot[bot]
1710d44df7 Bump django from 5.1.9 to 5.1.10 (#1086)
Bumps [django](https://github.com/django/django) from 5.1.9 to 5.1.10.
- [Commits](https://github.com/django/django/compare/5.1.9...5.1.10)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.1.10
  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-06-19 17:20:54 +02:00
dependabot[bot]
9967b3e27b Bump tar-fs in /docs (#1084)
Bumps  and [tar-fs](https://github.com/mafintosh/tar-fs). These dependencies needed to be updated together.

Updates `tar-fs` from 2.1.2 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

Updates `tar-fs` from 3.0.8 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 17:20:40 +02:00
Sascha Ißbrücker
1672dc0152 Add bundles for organizing bookmarks (#1097)
* add bundle model and query logic

* cleanup tests

* add basic form

* add success message

* Add form tests

* Add bundle list view

* fix edit view

* Add remove button

* Add basic preview logic

* Make pagination use absolute URLs

* Hide bookmark edits when rendering preview

* Render bookmark list in preview

* Reorder bundles

* Show bundles in bookmark view

* Make bookmark search respect selected bundle

* UI tweaks

* Fix bookmark scope

* Improve bundle preview

* Skip preview if form is submitted

* Show correct preview after invalid form submission

* Add option to hide bundles

* Merge new migrations

* Add tests for bundle menu

* Improve check for preview being removed
2025-06-19 16:47:29 +02:00
Sascha Ißbrücker
8be72a5d1f Fix side panel not being hidden on smaller viewports (#1089) 2025-06-10 09:24:37 +02:00
Sascha Ißbrücker
bb796c9bdb Add date filters for REST API (#1080)
* Add modified_since query parameter

* Add added_since parameter

* update date_modified when assets change
2025-05-30 10:24:19 +02:00
Sascha Ißbrücker
578680c3c1 Fix docs build 2025-05-17 13:37:00 +02:00
Sascha Ißbrücker
8debb5c5aa Add install instructions for GHCR 2025-05-17 13:18:40 +02:00
Sascha Ißbrücker
be752f8146 Update CHANGELOG.md 2025-05-17 12:56:10 +02:00
111 changed files with 4106 additions and 500 deletions

View File

@@ -1,5 +1,93 @@
# 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
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
### New Contributors
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
---
## v1.39.1 (06/03/2025)
> [!WARNING]
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
---
## v1.39.0 (06/03/2025)
### What's Changed
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
### New Contributors
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
---
## v1.38.1 (22/02/2025)
### What's Changed

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
@@ -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

@@ -16,9 +16,17 @@ from bookmarks.api.serializers import (
BookmarkAssetSerializer,
TagSerializer,
UserProfileSerializer,
BookmarkBundleSerializer,
)
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkSearch,
Tag,
User,
BookmarkBundle,
)
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.type_defs import HttpRequest
from bookmarks.views import access
@@ -50,7 +58,7 @@ class BookmarkViewSet(
def get_queryset(self):
# Provide filtered queryset for list actions
user = self.request.user
search = BookmarkSearch.from_request(self.request.GET)
search = BookmarkSearch.from_request(self.request, self.request.GET)
if self.action == "list":
return queries.query_bookmarks(user, user.profile, search)
elif self.action == "archived":
@@ -191,13 +199,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")
@@ -264,6 +269,28 @@ class UserViewSet(viewsets.GenericViewSet):
return Response(UserProfileSerializer(request.user.profile).data)
class BookmarkBundleViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
request: HttpRequest
serializer_class = BookmarkBundleSerializer
def get_queryset(self):
user = self.request.user
return BookmarkBundle.objects.filter(owner=user).order_by("order")
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
# The default router is only used to allow reversing a URL for the API root
@@ -278,5 +305,8 @@ tag_router.register("", TagViewSet, basename="tag")
user_router = SimpleRouter()
user_router.register("", UserViewSet, basename="user")
bundle_router = SimpleRouter()
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
bookmark_asset_router = SimpleRouter()
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")

View File

@@ -1,10 +1,17 @@
from django.db.models import prefetch_related_objects
from django.db.models import Max, prefetch_related_objects
from django.templatetags.static import static
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
from bookmarks.services import bookmarks
from bookmarks.models import (
Bookmark,
BookmarkAsset,
Tag,
build_tag_string,
UserProfile,
BookmarkBundle,
)
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
@@ -27,6 +34,32 @@ class EmtpyField(serializers.ReadOnlyField):
return None
class BookmarkBundleSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkBundle
fields = [
"id",
"name",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"order",
"date_created",
"date_modified",
]
read_only_fields = [
"id",
"date_created",
"date_modified",
]
def create(self, 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):
class Meta:
model = Bookmark

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,4 +1,5 @@
from django import forms
from django.forms.utils import ErrorList
from bookmarks.models import Bookmark, build_tag_string
from bookmarks.validators import BookmarkURLValidator
@@ -6,6 +7,10 @@ from bookmarks.type_defs import HttpRequest
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
@@ -48,7 +53,9 @@ class BookmarkForm(forms.ModelForm):
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):

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);
@@ -51,5 +73,6 @@ class UploadButton extends Behavior {
}
}
registerBehavior("ld-form-submit", FormSubmit);
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
name: input.name,
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
variant: input.getAttribute("variant"),
},
});

View File

@@ -6,6 +6,7 @@
export let name;
export let value;
export let placeholder;
export let ariaDescribedBy;
export let variant = 'default';
let isFocus = false;
@@ -77,6 +78,7 @@
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();
}
@@ -109,6 +111,7 @@
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
aria-describedby="{ariaDescribedBy}"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
@@ -128,41 +131,41 @@
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.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 {
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-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 {
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 .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);
}
.form-autocomplete.small .menu .menu-item {
font-size: var(--font-size-sm);
}
</style>

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.1.9 on 2025-06-19 08:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0044_bookmark_latest_snapshot"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="hide_bundles",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="BookmarkBundle",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=256)),
("search", models.CharField(blank=True, max_length=256)),
("any_tags", models.CharField(blank=True, max_length=1024)),
("all_tags", models.CharField(blank=True, max_length=1024)),
("excluded_tags", models.CharField(blank=True, max_length=1024)),
("order", models.IntegerField(default=0)),
("date_created", models.DateTimeField(auto_now_add=True)),
("date_modified", models.DateTimeField(auto_now=True)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -2,6 +2,7 @@ import binascii
import hashlib
import logging
import os
from functools import cached_property
from typing import List
from django import forms
@@ -39,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)
@@ -132,6 +133,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:
@@ -157,6 +166,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
class BookmarkBundle(models.Model):
name = models.CharField(max_length=256, blank=False)
search = models.CharField(max_length=256, blank=True)
any_tags = models.CharField(max_length=1024, blank=True)
all_tags = models.CharField(max_length=1024, blank=True)
excluded_tags = models.CharField(max_length=1024, blank=True)
order = models.IntegerField(null=False, default=0)
date_created = models.DateTimeField(auto_now_add=True, null=False)
date_modified = models.DateTimeField(auto_now=True, null=False)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.name
class BookmarkBundleForm(forms.ModelForm):
class Meta:
model = BookmarkBundle
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
class BookmarkSearch:
SORT_ADDED_ASC = "added_asc"
SORT_ADDED_DESC = "added_desc"
@@ -171,34 +201,54 @@ class BookmarkSearch:
FILTER_UNREAD_YES = "yes"
FILTER_UNREAD_NO = "no"
params = ["q", "user", "sort", "shared", "unread"]
params = [
"q",
"user",
"bundle",
"sort",
"shared",
"unread",
"modified_since",
"added_since",
]
preferences = ["sort", "shared", "unread"]
defaults = {
"q": "",
"user": "",
"bundle": None,
"sort": SORT_ADDED_DESC,
"shared": FILTER_SHARED_OFF,
"unread": FILTER_UNREAD_OFF,
"modified_since": None,
"added_since": None,
}
def __init__(
self,
q: str = None,
user: str = None,
bundle: BookmarkBundle = None,
sort: str = None,
shared: str = None,
unread: str = None,
modified_since: str = None,
added_since: str = None,
preferences: dict = None,
request: any = None,
):
if not preferences:
preferences = {}
self.defaults = {**BookmarkSearch.defaults, **preferences}
self.request = request
self.q = q or self.defaults["q"]
self.user = user or self.defaults["user"]
self.bundle = bundle or self.defaults["bundle"]
self.sort = sort or self.defaults["sort"]
self.shared = shared or self.defaults["shared"]
self.unread = unread or self.defaults["unread"]
self.modified_since = modified_since or self.defaults["modified_since"]
self.added_since = added_since or self.defaults["added_since"]
def is_modified(self, param):
value = self.__dict__[param]
@@ -226,7 +276,14 @@ class BookmarkSearch:
@property
def query_params(self):
return {param: self.__dict__[param] for param in self.modified_params}
query_params = {}
for param in self.modified_params:
value = self.__dict__[param]
if isinstance(value, models.Model):
query_params[param] = value.id
else:
query_params[param] = value
return query_params
@property
def preferences_dict(self):
@@ -235,14 +292,21 @@ class BookmarkSearch:
}
@staticmethod
def from_request(query_dict: QueryDict, preferences: dict = None):
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
initial_values = {}
for param in BookmarkSearch.params:
value = query_dict.get(param)
if value:
initial_values[param] = value
if param == "bundle":
initial_values[param] = BookmarkBundle.objects.filter(
owner=request.user, pk=value
).first()
else:
initial_values[param] = value
return BookmarkSearch(**initial_values, preferences=preferences)
return BookmarkSearch(
**initial_values, preferences=preferences, request=request
)
class BookmarkSearchForm(forms.Form):
@@ -265,9 +329,12 @@ class BookmarkSearchForm(forms.Form):
q = forms.CharField()
user = forms.ChoiceField(required=False)
bundle = forms.CharField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
modified_since = forms.CharField(required=False)
added_since = forms.CharField(required=False)
def __init__(
self,
@@ -287,7 +354,11 @@ class BookmarkSearchForm(forms.Form):
for param in search.params:
# set initial values for modified params
self.fields[param].initial = search.__dict__[param]
value = search.__dict__.get(param)
if isinstance(value, models.Model):
self.fields[param].initial = value.id
else:
self.fields[param].initial = value
# Mark non-editable modified fields as hidden. That way, templates
# rendering a form can just loop over hidden_fields to ensure that
@@ -408,6 +479,7 @@ class UserProfile(models.Model):
)
sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
hide_bundles = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
@@ -448,6 +520,7 @@ class UserProfileForm(forms.ModelForm):
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
"hide_bundles",
]

View File

@@ -2,16 +2,26 @@ from typing import Optional
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
from django.db.models.expressions import RawSQL
from django.db.models.functions import Lower
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.models import (
Bookmark,
BookmarkBundle,
BookmarkSearch,
Tag,
UserProfile,
parse_tag_string,
)
from bookmarks.utils import unique
def query_bookmarks(
user: User, profile: UserProfile, search: BookmarkSearch
user: User,
profile: UserProfile,
search: BookmarkSearch,
) -> QuerySet:
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
return _base_bookmarks_query(user, profile, search).filter(conditions)
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
# Search terms
search_terms = parse_query_string(bundle.search)["search_terms"]
for term in search_terms:
conditions = (
Q(title__icontains=term)
| Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(url__icontains=term)
)
query_set = query_set.filter(conditions)
# Any tags - at least one tag must match
any_tags = parse_tag_string(bundle.any_tags, " ")
if len(any_tags) > 0:
tag_conditions = Q()
for tag in any_tags:
tag_conditions |= Q(tags__name__iexact=tag)
query_set = query_set.filter(
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)
# All tags - all tags must match
all_tags = parse_tag_string(bundle.all_tags, " ")
for tag in all_tags:
query_set = query_set.filter(tags__name__iexact=tag)
# Excluded tags - no tags must match
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
if len(exclude_tags) > 0:
tag_conditions = Q()
for tag in exclude_tags:
tag_conditions |= Q(tags__name__iexact=tag)
query_set = query_set.exclude(
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)
return query_set
def _base_bookmarks_query(
user: Optional[User], profile: UserProfile, search: BookmarkSearch
user: Optional[User],
profile: UserProfile,
search: BookmarkSearch,
) -> QuerySet:
query_set = Bookmark.objects
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
if user:
query_set = query_set.filter(owner=user)
# Filter by modified_since if provided
if search.modified_since:
try:
query_set = query_set.filter(date_modified__gt=search.modified_since)
except ValidationError:
# If the date format is invalid, ignore the filter
pass
# Filter by added_since if provided
if search.added_since:
try:
query_set = query_set.filter(date_added__gt=search.added_since)
except ValidationError:
# If the date format is invalid, ignore the filter
pass
# Split query into search terms and tags
query = parse_query_string(search.q)
@@ -85,6 +154,10 @@ def _base_bookmarks_query(
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
query_set = query_set.filter(shared=False)
# Filter by bundle
if search.bundle:
query_set = _filter_bundle(query_set, search.bundle)
# Sort
if (
search.sort == BookmarkSearch.SORT_TITLE_ASC

View File

@@ -53,6 +53,7 @@ def create_snapshot(asset: BookmarkAsset):
asset.save()
asset.bookmark.latest_snapshot = asset
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save()
except Exception as error:
asset.status = BookmarkAsset.STATUS_FAILURE
@@ -75,6 +76,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
asset.save()
asset.bookmark.latest_snapshot = asset
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save()
return asset
@@ -92,14 +94,33 @@ 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()
asset.bookmark.save()
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
@@ -128,9 +149,10 @@ def remove_asset(asset: BookmarkAsset):
)
bookmark.latest_snapshot = latest
bookmark.save()
asset.delete()
bookmark.date_modified = timezone.now()
bookmark.save()
def _generate_asset_filename(

View File

@@ -208,6 +208,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

@@ -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;
@@ -49,50 +57,9 @@
& .assets {
margin-top: var(--unit-2);
& .asset {
display: flex;
align-items: center;
gap: var(--unit-2);
padding: var(--unit-2) 0;
border-top: var(--unit-o) solid var(--secondary-border-color);
}
& .asset:last-child {
border-bottom: var(--unit-o) solid var(--secondary-border-color);
}
& .asset-icon {
display: flex;
align-items: center;
justify-content: center;
}
& .asset-text {
flex: 1 1 0;
gap: var(--unit-2);
min-width: 0;
display: flex;
}
& .asset-text .truncate {
flex-shrink: 1;
}
& .asset-text .filesize {
& .filesize {
color: var(--tertiary-text-color);
}
& .asset-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
}
& .assets-actions {

View File

@@ -379,6 +379,26 @@ li[ld-bookmark-item] {
}
}
.bundle-menu {
list-style-type: none;
margin: 0 0 var(--unit-6);
.bundle-menu-item {
margin: 0;
margin-bottom: var(--unit-2);
}
.bundle-menu-item a {
padding: var(--unit-1) var(--unit-2);
border-radius: var(--border-radius);
}
.bundle-menu-item.selected a {
background: var(--primary-color);
color: var(--contrast-text-color);
}
}
.tag-cloud {
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;

View File

@@ -0,0 +1,34 @@
.bundles-page {
h1 {
font-size: var(--font-size-lg);
margin-bottom: var(--unit-6);
}
.item-list {
.list-item .list-item-icon {
cursor: grab;
}
.list-item.drag-start {
--secondary-border-color: transparent;
}
.list-item.dragging > * {
visibility: hidden;
}
}
}
.bundles-editor-page {
&.grid {
gap: var(--unit-9);
}
.form-footer {
position: sticky;
bottom: 0;
border-top: solid 1px var(--secondary-border-color);
background: var(--body-color);
padding: var(--unit-3) 0;
}
}

View File

@@ -25,7 +25,7 @@
}
@media (max-width: 600px) {
.section-header {
.section-header:not(.no-wrap) {
flex-direction: column;
}
}
@@ -60,3 +60,60 @@ span.confirmation {
.turbo-progress-bar {
background-color: var(--primary-color);
}
/* Messages */
.message-list {
margin: var(--unit-4) 0;
.toast {
margin-bottom: var(--unit-2);
}
.toast a.btn-clear:visited {
color: currentColor;
}
}
/* Item list */
.item-list {
& .list-item {
display: flex;
align-items: center;
gap: var(--unit-2);
padding: var(--unit-2) 0;
border-top: var(--unit-o) solid var(--secondary-border-color);
}
& .list-item:last-child {
border-bottom: var(--unit-o) solid var(--secondary-border-color);
}
& .list-item-icon {
display: flex;
align-items: center;
justify-content: center;
}
& .list-item-text {
flex: 1 1 0;
gap: var(--unit-2);
min-width: 0;
display: flex;
}
& .list-item-text .truncate {
flex-shrink: 1;
}
& .list-item-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
}

View File

@@ -27,15 +27,3 @@ header {
line-height: 1.2;
}
}
header .toasts {
margin-bottom: 20px;
.toast {
margin-bottom: 0.4rem;
}
.toast a.btn-clear:visited {
color: currentColor;
}
}

View File

@@ -28,3 +28,4 @@
@import "markdown.css";
@import "reader-mode.css";
@import "settings.css";
@import "bundles.css";

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:

View File

@@ -242,6 +242,14 @@
margin-top: var(--unit-4) !important;
}
.ml-auto {
margin-left: auto;
}
.mr-auto {
margin-right: auto;
}
.mx-auto {
margin-left: auto;
margin-right: auto;

View File

@@ -30,16 +30,10 @@
</form>
</main>
{# Tag cloud #}
<div class="side-panel col-1">
<section aria-labelledby="tags-heading">
<div class="section-header">
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Filters #}
<div class="side-panel col-1 hide-md">
{% include 'bookmarks/bundle_section.html' %}
{% include 'bookmarks/tag_section.html' %}
</div>
</div>
{% endblock %}

View File

@@ -77,72 +77,76 @@
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% if not bookmark_list.is_preview %}
<span>|</span>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span>
{% endif %}
{% if bookmark_item.has_extra_actions %}
<div class="extra-actions">
<span class="hide-sm">|</span>
{% 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?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
{% if not bookmark_list.is_preview %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% 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?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span>
{% endif %}
{% if bookmark_item.has_extra_actions %}
<div class="extra-actions">
<span class="hide-sm">|</span>
{% 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?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
{% endif %}
{% 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?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>

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

@@ -0,0 +1,36 @@
{% if not request.user_profile.hide_bundles %}
<section aria-labelledby="bundles-heading">
<div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2>
<div ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn 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 %}
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -1,12 +1,12 @@
<div>
{% if details.assets %}
<div class="assets">
<div class="item-list assets">
{% for asset in details.assets %}
<div class="asset" data-asset-id="{{ asset.id }}">
<div class="asset-icon {{ asset.icon_classes }}">
<div class="list-item" data-asset-id="{{ asset.id }}">
<div class="list-item-icon {{ asset.icon_classes }}">
{% include 'bookmarks/details/asset_icon.html' %}
</div>
<div class="asset-text {{ asset.text_classes }}">
<div class="list-item-text {{ asset.text_classes }}">
<span class="truncate">
{{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %}
@@ -16,7 +16,7 @@
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %}
</div>
<div class="asset-actions">
<div class="list-item-actions">
{% if asset.file %}
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
{% endif %}

View File

@@ -3,7 +3,7 @@
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2>
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
<button class="close" aria-label="Close dialog">
<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">

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

@@ -32,16 +32,10 @@
</form>
</main>
{# Tag cloud #}
<div class="side-panel col-1">
<section aria-labelledby="tags-heading">
<div class="section-header">
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Filters #}
<div class="side-panel col-1 hide-md">
{% include 'bookmarks/bundle_section.html' %}
{% include 'bookmarks/tag_section.html' %}
</div>
</div>
{% endblock %}

View File

@@ -67,7 +67,7 @@
<header class="container">
{% if has_toasts %}
<div class="toasts">
<div class="message-list">
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %}
{% for toast in toast_messages %}

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

@@ -3,7 +3,7 @@
<ul class="pagination">
{% if prev_link %}
<li class="page-item">
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
@@ -14,7 +14,7 @@
{% for page_link in page_links %}
{% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}">
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
</li>
{% else %}
<li class="page-item">
@@ -25,7 +25,7 @@
{% if next_link %}
<li class="page-item">
<a href="?{{ next_link }}" tabindex="-1">Next</a>
<a href="{{ next_link }}" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">

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

@@ -28,7 +28,7 @@
</main>
{# Filters #}
<div class="side-panel col-1">
<div class="side-panel col-1 hide-md">
<section aria-labelledby="user-heading">
<div class="section-header">
<h2 id="user-heading">User</h2>
@@ -38,14 +38,7 @@
<br>
</div>
</section>
<section aria-labelledby="tags-heading">
<div class="section-header">
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{% include 'bookmarks/tag_section.html' %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
<section aria-labelledby="tags-heading">
<div class="section-header">
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>

View File

@@ -0,0 +1,33 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Edit bundle - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="bundles-editor-page grid columns-md-1">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Edit bundle</h1>
</div>
{% include 'shared/messages.html' %}
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
{% csrf_token %}
{% include 'bundles/form.html' %}
</form>
</main>
<aside class="col-2" aria-labelledby="preview-heading">
<div class="section-header">
<h2 id="preview-heading">Preview</h2>
</div>
{% include 'bundles/preview.html' %}
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.name.errors %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.search.errors %}
<div class="form-input-hint">
{{ form.search.errors }}
</div>
{% endif %}
<div class="form-input-hint">
Search terms to match bookmarks in this bundle.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">
At least one of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">
All of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">
None of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-footer d-flex mt-4">
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
</div>
<script>
(function init() {
const bundleForm = document.getElementById('bundle-form');
const previewLink = document.getElementById('preview-link');
let pendingUpdate;
function scheduleUpdate() {
if (pendingUpdate) {
clearTimeout(pendingUpdate);
}
pendingUpdate = setTimeout(() => {
// Ignore if link has been removed (e.g. form submit or navigation)
if (!previewLink.isConnected) {
return;
}
const baseUrl = previewLink.href.split('?')[0];
const params = new URLSearchParams();
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
inputs.forEach(input => {
if (input.name && input.value.trim()) {
params.set(input.name, input.value.trim());
}
});
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
previewLink.click();
}, 500)
}
bundleForm.addEventListener('input', scheduleUpdate);
bundleForm.addEventListener('change', scheduleUpdate);
})();
</script>

View File

@@ -0,0 +1,124 @@
{% extends "bookmarks/layout.html" %}
{% block head %}
{% with page_title="Bundles - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<main class="bundles-page" aria-labelledby="main-heading">
<h1 id="main-heading">Bundles</h1>
{% include 'shared/messages.html' %}
{% if bundles %}
<form action="{% url 'linkding:bundles.action' %}" method="post">
{% csrf_token %}
<div class="item-list bundles">
{% 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">
<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>
{% endfor %}
</div>
<input type="submit" name="move_bundle" value="" class="d-none">
<input type="hidden" name="move_position" value="">
</form>
{% else %}
<div class="empty">
<p class="empty-title h5">You have no bundles yet</p>
<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;
let draggedElement = null;
const listItems = bundlesList.querySelectorAll('.list-item');
listItems.forEach((item) => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('dragenter', handleDragEnter);
});
function handleDragStart(e) {
draggedElement = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
this.classList.add('drag-start');
setTimeout(() => {
this.classList.remove('drag-start');
this.classList.add('dragging');
}, 0);
}
function handleDragEnd() {
this.classList.remove('dragging');
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);
const form = this.closest('form');
form.requestSubmit(moveBundleInput);
draggedElement = null;
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDragEnter() {
if (this !== draggedElement) {
const listItems = Array.from(bundlesList.children);
const draggedIndex = listItems.indexOf(draggedElement);
const currentIndex = listItems.indexOf(this);
if (draggedIndex < currentIndex) {
this.insertAdjacentElement('afterend', draggedElement);
} else {
this.insertAdjacentElement('beforebegin', draggedElement);
}
}
}
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="New bundle - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="bundles-editor-page grid columns-md-1">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">New bundle</h1>
</div>
{% include 'shared/messages.html' %}
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
{% csrf_token %}
{% include 'bundles/form.html' %}
</form>
</main>
<aside class="col-2" aria-labelledby="preview-heading">
<div class="section-header">
<h2 id="preview-heading">Preview</h2>
</div>
{% include 'bundles/preview.html' %}
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
<turbo-frame id="preview">
{% if bookmark_list.is_empty %}
<div>
No bookmarks match the current bundle.
</div>
{% else %}
<div class="mb-4">
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
</div>
{% include 'bookmarks/bookmark_list.html' %}
{% endif %}
</turbo-frame>

View File

@@ -139,6 +139,15 @@
Instead, the tags are shown in an expandable drawer.
</div>
</div>
<div class="form-group">
<label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
{{ form.hide_bundles }}
<i class="form-icon"></i> Hide bundles
</label>
<div class="form-input-hint">
Allows to hide the bundles in the side panel if you don't intend to use them.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}

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,9 @@
{% if messages %}
<div class="message-list">
{% for message in messages %}
<div class="toast toast-{{ message.tags }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -13,18 +13,21 @@ register = template.Library()
"bookmarks/pagination.html", name="pagination", takes_context=True
)
def pagination(context, page: Page):
request = context["request"]
base_url = request.build_absolute_uri(request.path)
# remove page number and details from query parameters
query_params = context["request"].GET.copy()
query_params = request.GET.copy()
query_params.pop("page", None)
query_params.pop("details", None)
prev_link = (
_generate_link(query_params, page.previous_page_number())
_generate_link(base_url, query_params, page.previous_page_number())
if page.has_previous()
else None
)
next_link = (
_generate_link(query_params, page.next_page_number())
_generate_link(base_url, query_params, page.next_page_number())
if page.has_next()
else None
)
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
if page_number == -1:
page_links.append(None)
else:
link = _generate_link(query_params, page_number)
link = _generate_link(base_url, query_params, page_number)
page_links.append(
{
"active": page_number == page.number,
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
return reduce(append_page, visible_pages, [])
def _generate_link(query_params: QueryDict, page_number: int) -> str:
def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
query_params = query_params.copy()
query_params["page"] = page_number
return query_params.urlencode()
return f"{base_url}?{query_params.urlencode()}"

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

@@ -17,7 +17,7 @@ from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
class BookmarkFactoryMixin:
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
def setup_bundle(
self,
user: User = None,
name: str = None,
search: str = "",
any_tags: str = "",
all_tags: str = "",
excluded_tags: str = "",
order: int = 0,
):
if user is None:
user = self.get_or_create_test_user()
if not name:
name = get_random_string(length=32)
bundle = BookmarkBundle(
name=name,
owner=user,
date_created=timezone.now(),
search=search,
any_tags=any_tags,
all_tags=all_tags,
excluded_tags=excluded_tags,
order=order,
)
bundle.save()
return bundle
def setup_asset(
self,
bookmark: Bookmark,
@@ -209,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)
@@ -239,7 +275,7 @@ class BookmarkFactoryMixin:
user.profile.save()
return user
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())

View File

@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertIsNone(asset.id)
def test_create_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(
url="https://example.com", modified=initial_modified
)
asset = assets.create_snapshot_asset(bookmark)
asset.save()
asset.date_created = timezone.datetime(
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
def test_create_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
def test_upload_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(
url="https://example.com", modified=initial_modified
)
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
# should create gzip file in asset folder
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.file, saved_file_name)
self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
def test_upload_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
@disable_logging
def test_upload_asset(self):
bookmark = self.setup_bookmark()
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(modified=initial_modified)
file_content = b"test content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
@@ -187,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)
@@ -201,9 +220,52 @@ 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)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
@disable_logging
def test_upload_asset_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
@@ -221,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):
@@ -409,3 +471,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
# Verify that latest_snapshot hasn't changed
self.assertEqual(bookmark.latest_snapshot, latest_asset)
@disable_logging
def test_remove_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 = b"test content for removal"
upload_file = SimpleUploadedFile(
"test_remove_file.txt", file_content, content_type="text/plain"
)
asset = assets.upload_asset(bookmark, upload_file)
asset_filepath = os.path.join(self.assets_dir, asset.file)
# Verify asset and file exist
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
self.assertTrue(os.path.exists(asset_filepath))
bookmark.date_modified = initial_modified
bookmark.save()
# Remove the asset
assets.remove_asset(asset)
# Verify asset is removed from DB
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
# Verify file is removed from disk
self.assertFalse(os.path.exists(asset_filepath))
# Verify bookmark modified date is updated
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)

View File

@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_index_action_bulk_select_across_respects_bundle(self):
self.setup_numbered_bookmarks(3, prefix="foo")
self.setup_numbered_bookmarks(3, prefix="bar")
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
bundle = self.setup_bundle(search="foo")
self.client.post(
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
{
"bulk_action": ["bulk_delete"],
"bulk_execute": [""],
"bulk_select_across": ["on"],
},
)
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_archived_action_bulk_select_across_respects_bundle(self):
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
bundle = self.setup_bundle(search="foo")
self.client.post(
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
{
"bulk_action": ["bulk_delete"],
"bulk_execute": [""],
"bulk_select_across": ["on"],
},
)
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data()

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
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
collapse_whitespace,
)
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
)
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
html = collapse_whitespace(response.content.decode())
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_bundle(self):
visible_bookmarks = self.setup_numbered_bookmarks(
3, prefix="foo", archived=True
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, prefix="bar", archived=True
)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_bundle(self):
visible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
)
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
@@ -284,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
@@ -310,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"))
@@ -515,3 +600,20 @@ class BookmarkArchivedViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)
def test_hide_bundles_when_enabled_in_profile(self):
# visible by default
response = self.client.get(reverse("linkding:bookmarks.archived"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
# hidden when disabled in profile
user_profile = self.get_or_create_test_user().profile
user_profile.hide_bundles = True
user_profile.save()
response = self.client.get(reverse("linkding:bookmarks.archived"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)

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

@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_item = self.find_asset(asset_list, asset)
self.assertIsNotNone(asset_item)
asset_icon = asset_item.select_one(".asset-icon svg")
asset_icon = asset_item.select_one(".list-item-icon svg")
self.assertIsNotNone(asset_icon)
asset_text = asset_item.select_one(".asset-text span")
asset_text = asset_item.select_one(".list-item-text span")
self.assertIsNotNone(asset_text)
self.assertIn(asset.display_name, asset_text.text)
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, pending_asset)
asset_text = asset_item.select_one(".asset-text span")
asset_text = asset_item.select_one(".list-item-text span")
self.assertIn("(queued)", asset_text.text)
asset_item = self.find_asset(soup, failed_asset)
asset_text = asset_item.select_one(".asset-text span")
asset_text = asset_item.select_one(".list-item-text span")
self.assertIn("(failed)", asset_text.text)
def test_asset_file_size(self):
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset1)
asset_text = asset_item.select_one(".asset-text")
asset_text = asset_item.select_one(".list-item-text")
self.assertEqual(asset_text.text.strip(), asset1.display_name)
asset_item = self.find_asset(soup, asset2)
asset_text = asset_item.select_one(".asset-text")
asset_text = asset_item.select_one(".list-item-text")
self.assertIn("53.4\xa0KB", asset_text.text)
asset_item = self.find_asset(soup, asset3)
asset_text = asset_item.select_one(".asset-text")
asset_text = asset_item.select_one(".list-item-text")
self.assertIn("11.0\xa0MB", asset_text.text)
def test_asset_actions_visibility(self):

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>
""",
@@ -259,12 +258,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 +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=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
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
self.assertIsNotNone(form)
self.assertEqual(form.attrs["action"], url)
def assertVisibleBundles(self, soup, bundles):
bundle_list = soup.select_one("ul.bundle-menu")
self.assertIsNotNone(bundle_list)
list_items = bundle_list.select("li.bundle-menu-item")
self.assertEqual(len(list_items), len(bundles))
for index, list_item in enumerate(list_items):
bundle = bundles[index]
link = list_item.select_one("a")
href = link.attrs["href"]
self.assertEqual(bundle.name, list_item.text.strip())
self.assertEqual(f"?bundle={bundle.id}", href)
def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_bundle(self):
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_bundle(self):
visible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, prefix="foo", tag_prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, prefix="bar", tag_prefix="bar"
)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
)
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
@@ -265,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
@@ -291,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"))
@@ -494,3 +592,43 @@ class BookmarkIndexViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)
def test_list_bundles(self):
books = self.setup_bundle(name="Books bundle", order=3)
music = self.setup_bundle(name="Music bundle", order=1)
tools = self.setup_bundle(name="Tools bundle", order=2)
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
soup = self.make_soup(html)
self.assertVisibleBundles(soup, [music, tools, books])
def test_list_bundles_only_shows_user_owned_bundles(self):
user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
other_user = self.setup_user()
self.setup_bundle(user=other_user)
self.setup_bundle(user=other_user)
self.setup_bundle(user=other_user)
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
soup = self.make_soup(html)
self.assertVisibleBundles(soup, user_bundles)
def test_hide_bundles_when_enabled_in_profile(self):
# visible by default
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
# hidden when disabled in profile
user_profile = self.get_or_create_test_user().profile
user_profile.hide_bundles = True
user_profile.save()
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)

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,6 @@ 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,
)

View File

@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
form = BookmarkSearchForm(search)
self.assertEqual(form["q"].initial, "")
self.assertEqual(form["user"].initial, "")
self.assertEqual(form["bundle"].initial, None)
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
# with params
bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertEqual(form["q"].initial, "search query")
self.assertEqual(form["user"].initial, "user123")
self.assertEqual(form["bundle"].initial, bundle.id)
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
# all modified params
bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertCountEqual(
form.hidden_fields(),
[form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
[
form["q"],
form["sort"],
form["user"],
form["bundle"],
form["shared"],
form["unread"],
],
)
# some modified params are editable fields

View File

@@ -2,16 +2,23 @@ from django.http import QueryDict
from django.test import TestCase
from bookmarks.models import BookmarkSearch
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSearchModelTest(TestCase):
class MockRequest:
def __init__(self, user):
self.user = user
class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
def test_from_request(self):
# no params
query_dict = QueryDict()
search = BookmarkSearch.from_request(query_dict)
search = BookmarkSearch.from_request(None, query_dict)
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.bundle, None)
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
# some params
query_dict = QueryDict("q=search query&user=user123")
bookmark_search = BookmarkSearch.from_request(query_dict)
bookmark_search = BookmarkSearch.from_request(None, query_dict)
self.assertEqual(bookmark_search.q, "search query")
self.assertEqual(bookmark_search.user, "user123")
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# all params
bundle = self.setup_bundle()
request = MockRequest(self.get_or_create_test_user())
query_dict = QueryDict(
"q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
)
search = BookmarkSearch.from_request(query_dict)
search = BookmarkSearch.from_request(request, query_dict)
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "user123")
self.assertEqual(search.bundle, bundle)
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
}
query_dict = QueryDict("q=search query")
search = BookmarkSearch.from_request(query_dict, preferences)
search = BookmarkSearch.from_request(None, query_dict, preferences)
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
}
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
search = BookmarkSearch.from_request(query_dict, preferences)
search = BookmarkSearch.from_request(None, query_dict, preferences)
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
def test_from_request_ignores_invalid_bundle_param(self):
self.setup_bundle()
# bundle does not exist
request = MockRequest(self.get_or_create_test_user())
query_dict = QueryDict("bundle=99999")
search = BookmarkSearch.from_request(request, query_dict)
self.assertIsNone(search.bundle)
# bundle belongs to another user
other_user = self.setup_user()
bundle = self.setup_bundle(user=other_user)
query_dict = QueryDict(f"bundle={bundle.id}")
search = BookmarkSearch.from_request(request, query_dict)
self.assertIsNone(search.bundle)
def test_query_params(self):
# no params
search = BookmarkSearch()
self.assertEqual(search.query_params, {})
# params are default values
search = BookmarkSearch(
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
)
self.assertEqual(search.query_params, {})
# some modified params
search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(
search.query_params,
{"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
)
# all modified params
bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
self.assertEqual(
search.query_params,
{
"q": "search query",
"sort": BookmarkSearch.SORT_ADDED_ASC,
"user": "user123",
"bundle": bundle.id,
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
# preferences are not query params if they match default
preferences = {
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
search = BookmarkSearch(preferences=preferences)
self.assertEqual(search.query_params, {})
# param is not a query param if it matches the preference
preferences = {
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_ASC,
unread=BookmarkSearch.FILTER_UNREAD_YES,
preferences=preferences,
)
self.assertEqual(search.query_params, {})
# overriding preferences is a query param
preferences = {
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_DESC,
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
unread=BookmarkSearch.FILTER_UNREAD_OFF,
preferences=preferences,
)
self.assertEqual(
search.query_params,
{
"sort": BookmarkSearch.SORT_TITLE_DESC,
"shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)
def test_modified_params(self):
# no params
bookmark_search = BookmarkSearch()
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
self.assertCountEqual(modified_params, ["q", "sort"])
# all modified params
bundle = self.setup_bundle()
bookmark_search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
modified_params = bookmark_search.modified_params
self.assertCountEqual(
modified_params, ["q", "sort", "user", "shared", "unread"]
modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
)
# preferences are not modified params
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
)
# only returns preferences
bookmark_search = BookmarkSearch(q="search query", user="user123")
bundle = self.setup_bundle()
bookmark_search = BookmarkSearch(
q="search query", user="user123", bundle=bundle
)
self.assertEqual(
bookmark_search.preferences_dict,
{

View File

@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
search = BookmarkSearch.from_request(request.GET)
search = BookmarkSearch.from_request(request, request.GET)
context = RequestContext(
request,
{

View File

@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_bundle(self):
self.authenticate()
user = self.setup_user(enable_sharing=True)
visible_bookmarks = self.setup_numbered_bookmarks(
3, shared=True, user=user, prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_bundle(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
visible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
invisible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
self.setup_bookmark(
shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
)
self.setup_bookmark(
shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
)
self.setup_bookmark(
shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
)
self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
bundle = self.setup_bundle(search="searchvalue")
response = self.client.get(
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
)
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)

View File

@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_should_filter_by_bundle(self):
self.authenticate()
search_value = self.get_random_string()
bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
self.setup_numbered_bookmarks(5)
bundle = self.setup_bundle(search=search_value)
response = self.get(
reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
expected_status_code=status.HTTP_200_OK,
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_filter_unread(self):
self.authenticate()
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
def test_list_archived_bookmarks_should_filter_by_bundle(self):
self.authenticate()
search_value = self.get_random_string()
archived_bookmarks = self.setup_numbered_bookmarks(
5, archived=True, prefix=search_value
)
self.setup_numbered_bookmarks(5, archived=True)
bundle = self.setup_bundle(search=search_value)
response = self.get(
reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
expected_status_code=status.HTTP_200_OK,
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
def test_list_archived_bookmarks_should_respect_sort(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5, archived=True)

View File

@@ -10,7 +10,7 @@ from django.urls import reverse
from django.utils import timezone, formats
from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views import contexts
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content}
</a>
<span>|</span>
""",
html,
)
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
contexts.BookmarkListContext
] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None,
is_preview: bool = False,
) -> str:
rf = RequestFactory()
request = rf.get(url)
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request)
bookmark_list_context = context_type(request)
search = BookmarkSearch.from_request(request, request.GET)
bookmark_list_context = context_type(request, search)
if is_preview:
bookmark_list_context.is_preview = True
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html)
bookmarks = soup.select("li[ld-bookmark-item]")
self.assertEqual(10, len(bookmarks))
def test_no_actions_rendered_when_is_preview(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = "https://example.com"
bookmark.save()
html = self.render_template(is_preview=True)
# Verify no actions are rendered
self.assertNoViewLink(html, bookmark)
self.assertNoBookmarkActions(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
self.assertUnshareButton(html, bookmark, count=0)
self.assertNotesToggle(html, count=0)
# But date should still be rendered
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)

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
@@ -974,3 +975,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

@@ -0,0 +1,321 @@
from django.urls import reverse
from rest_framework import status
from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def assertBundle(self, bundle: BookmarkBundle, data: dict):
self.assertEqual(bundle.id, data["id"])
self.assertEqual(bundle.name, data["name"])
self.assertEqual(bundle.search, data["search"])
self.assertEqual(bundle.any_tags, data["any_tags"])
self.assertEqual(bundle.all_tags, data["all_tags"])
self.assertEqual(bundle.excluded_tags, data["excluded_tags"])
self.assertEqual(bundle.order, data["order"])
self.assertEqual(
bundle.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
)
self.assertEqual(
bundle.date_modified.isoformat().replace("+00:00", "Z"),
data["date_modified"],
)
def test_bundle_list(self):
self.authenticate()
bundles = [
self.setup_bundle(name="Bundle 1", order=0),
self.setup_bundle(name="Bundle 2", order=1),
self.setup_bundle(name="Bundle 3", order=2),
]
url = reverse("linkding:bundle-list")
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertBundle(bundles[0], response.data["results"][0])
self.assertBundle(bundles[1], response.data["results"][1])
self.assertBundle(bundles[2], response.data["results"][2])
def test_bundle_list_only_returns_own_bundles(self):
self.authenticate()
user_bundles = [
self.setup_bundle(name="User Bundle 1"),
self.setup_bundle(name="User Bundle 2"),
]
other_user = self.setup_user()
self.setup_bundle(name="Other User Bundle 1", user=other_user)
self.setup_bundle(name="Other User Bundle 2", user=other_user)
url = reverse("linkding:bundle-list")
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 2)
self.assertBundle(user_bundles[0], response.data["results"][0])
self.assertBundle(user_bundles[1], response.data["results"][1])
def test_bundle_list_requires_authentication(self):
url = reverse("linkding:bundle-list")
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_bundle_detail(self):
self.authenticate()
bundle = self.setup_bundle(
name="Test Bundle",
search="test search",
any_tags="tag1 tag2",
all_tags="required-tag",
excluded_tags="excluded-tag",
order=5,
)
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBundle(bundle, response.data)
def test_bundle_detail_only_returns_own_bundles(self):
self.authenticate()
other_user = self.setup_user()
other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_bundle_detail_requires_authentication(self):
bundle = self.setup_bundle()
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_create_bundle(self):
self.authenticate()
bundle_data = {
"name": "New Bundle",
"search": "test search",
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
}
url = reverse("linkding:bundle-list")
response = self.post(
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
)
bundle = BookmarkBundle.objects.get(id=response.data["id"])
self.assertEqual(bundle.name, bundle_data["name"])
self.assertEqual(bundle.search, bundle_data["search"])
self.assertEqual(bundle.any_tags, bundle_data["any_tags"])
self.assertEqual(bundle.all_tags, bundle_data["all_tags"])
self.assertEqual(bundle.excluded_tags, bundle_data["excluded_tags"])
self.assertEqual(bundle.owner, self.user)
self.assertEqual(bundle.order, 0)
self.assertBundle(bundle, response.data)
def test_create_bundle_auto_increments_order(self):
self.authenticate()
self.setup_bundle(name="Existing Bundle", order=2)
bundle_data = {"name": "New Bundle", "search": "test search"}
url = reverse("linkding:bundle-list")
response = self.post(
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
)
bundle = BookmarkBundle.objects.get(id=response.data["id"])
self.assertEqual(bundle.order, 3)
def test_create_bundle_with_custom_order(self):
self.authenticate()
bundle_data = {"name": "New Bundle", "order": 10}
url = reverse("linkding:bundle-list")
response = self.post(
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
)
bundle = BookmarkBundle.objects.get(id=response.data["id"])
self.assertEqual(bundle.order, 10)
def test_create_bundle_requires_name(self):
self.authenticate()
bundle_data = {"search": "test search"}
url = reverse("linkding:bundle-list")
self.post(url, bundle_data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_create_bundle_fields_can_be_empty(self):
self.authenticate()
bundle_data = {
"name": "Minimal Bundle",
"search": "",
"any_tags": "",
"all_tags": "",
"excluded_tags": "",
}
url = reverse("linkding:bundle-list")
response = self.post(
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
)
bundle = BookmarkBundle.objects.get(id=response.data["id"])
self.assertEqual(bundle.name, "Minimal Bundle")
self.assertEqual(bundle.search, "")
self.assertEqual(bundle.any_tags, "")
self.assertEqual(bundle.all_tags, "")
self.assertEqual(bundle.excluded_tags, "")
def test_create_bundle_requires_authentication(self):
bundle_data = {"name": "New Bundle"}
url = reverse("linkding:bundle-list")
self.post(url, bundle_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_update_bundle_put(self):
self.authenticate()
bundle = self.setup_bundle(
name="Original Bundle",
search="original search",
any_tags="original-tag",
order=1,
)
updated_data = {
"name": "Updated Bundle",
"search": "updated search",
"any_tags": "updated-tag1 updated-tag2",
"all_tags": "required-updated-tag",
"excluded_tags": "excluded-updated-tag",
"order": 5,
}
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
response = self.put(url, updated_data, expected_status_code=status.HTTP_200_OK)
bundle.refresh_from_db()
self.assertEqual(bundle.name, updated_data["name"])
self.assertEqual(bundle.search, updated_data["search"])
self.assertEqual(bundle.any_tags, updated_data["any_tags"])
self.assertEqual(bundle.all_tags, updated_data["all_tags"])
self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
self.assertEqual(bundle.order, updated_data["order"])
self.assertBundle(bundle, response.data)
def test_update_bundle_patch(self):
self.authenticate()
bundle = self.setup_bundle(
name="Original Bundle", search="original search", any_tags="original-tag"
)
updated_data = {
"name": "Partially Updated Bundle",
"search": "partially updated search",
}
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
response = self.patch(
url, updated_data, expected_status_code=status.HTTP_200_OK
)
bundle.refresh_from_db()
self.assertEqual(bundle.name, updated_data["name"])
self.assertEqual(bundle.search, updated_data["search"])
self.assertEqual(bundle.any_tags, "original-tag") # Should remain unchanged
self.assertBundle(bundle, response.data)
def test_update_bundle_only_allows_own_bundles(self):
self.authenticate()
other_user = self.setup_user()
other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
updated_data = {"name": "Updated Bundle"}
url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
self.put(url, updated_data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_update_bundle_requires_authentication(self):
bundle = self.setup_bundle()
updated_data = {"name": "Updated Bundle"}
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
self.put(url, updated_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_delete_bundle(self):
self.authenticate()
bundle = self.setup_bundle(name="Bundle to Delete")
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
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()
other_user = self.setup_user()
other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
self.assertTrue(BookmarkBundle.objects.filter(id=other_bundle.id).exists())
def test_delete_bundle_requires_authentication(self):
bundle = self.setup_bundle()
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
def test_bundles_ordered_by_order_field(self):
self.authenticate()
self.setup_bundle(name="Third Bundle", order=2)
self.setup_bundle(name="First Bundle", order=0)
self.setup_bundle(name="Second Bundle", order=1)
url = reverse("linkding:bundle-list")
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertEqual(response.data["results"][0]["name"], "First Bundle")
self.assertEqual(response.data["results"][1]["name"], "Second Bundle")
self.assertEqual(response.data["results"][2]["name"], "Third Bundle")

View File

@@ -0,0 +1,160 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
"name": "Test Bundle",
"search": "test search",
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
}
return {**form_data, **overrides}
def test_should_edit_bundle(self):
bundle = self.setup_bundle()
updated_data = self.create_form_data()
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), updated_data
)
self.assertRedirects(response, reverse("linkding:bundles.index"))
bundle.refresh_from_db()
self.assertEqual(bundle.name, updated_data["name"])
self.assertEqual(bundle.search, updated_data["search"])
self.assertEqual(bundle.any_tags, updated_data["any_tags"])
self.assertEqual(bundle.all_tags, updated_data["all_tags"])
self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
def test_should_render_edit_form_with_prefilled_fields(self):
bundle = self.setup_bundle(
name="Test Bundle",
search="test search terms",
any_tags="tag1 tag2 tag3",
all_tags="required-tag all-tag",
excluded_tags="excluded-tag banned-tag",
)
response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML(
f'<input type="text" name="name" value="{bundle.name}" '
'autocomplete="off" placeholder=" " class="form-input" '
'maxlength="256" required id="id_name">',
html,
)
self.assertInHTML(
f'<input type="text" name="search" value="{bundle.search}" '
'autocomplete="off" placeholder=" " class="form-input" '
'maxlength="256" id="id_search">',
html,
)
self.assertInHTML(
f'<input type="text" name="any_tags" value="{bundle.any_tags}" '
'autocomplete="off" autocapitalize="off" class="form-input" '
'maxlength="1024" id="id_any_tags">',
html,
)
self.assertInHTML(
f'<input type="text" name="all_tags" value="{bundle.all_tags}" '
'autocomplete="off" autocapitalize="off" class="form-input" '
'maxlength="1024" id="id_all_tags">',
html,
)
self.assertInHTML(
f'<input type="text" name="excluded_tags" value="{bundle.excluded_tags}" '
'autocomplete="off" autocapitalize="off" class="form-input" '
'maxlength="1024" id="id_excluded_tags">',
html,
)
def test_should_return_422_with_invalid_form(self):
bundle = self.setup_bundle(
name="Test Bundle",
search="test search",
any_tags="tag1 tag2",
all_tags="required-tag",
excluded_tags="excluded-tag",
)
invalid_data = self.create_form_data({"name": ""})
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), invalid_data
)
self.assertEqual(response.status_code, 422)
def test_should_not_allow_editing_other_users_bundles(self):
other_user = self.setup_user(name="otheruser")
other_users_bundle = self.setup_bundle(user=other_user)
response = self.client.get(
reverse("linkding:bundles.edit", args=[other_users_bundle.id])
)
self.assertEqual(response.status_code, 404)
updated_data = self.create_form_data()
response = self.client.post(
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

@@ -0,0 +1,210 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_render_bundle_list(self):
bundles = [
self.setup_bundle(name="Bundle 1"),
self.setup_bundle(name="Bundle 2"),
self.setup_bundle(name="Bundle 3"),
]
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
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">
<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>
"""
self.assertInHTML(expected_list_item, html)
def test_renders_user_owned_bundles_only(self):
user_bundle = self.setup_bundle(name="User Bundle")
other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
self.assertNotIn(other_user_bundle.name, html)
def test_empty_state(self):
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML('<p class="empty-title h5">You have no bundles yet</p>', html)
self.assertInHTML(
'<p class="empty-subtitle">Create your first bundle to get started</p>',
html,
)
def test_add_new_button(self):
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML(
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
html,
)
def test_remove_bundle(self):
bundle = self.setup_bundle(name="Test Bundle")
response = self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(bundle.id)},
)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("linkding:bundles.index"))
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)
response = self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(other_user_bundle.id)},
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())
def assertBundleOrder(self, expected_bundles, user=None):
if user is None:
user = self.user
actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by("order")
self.assertEqual(len(actual_bundles), len(expected_bundles))
for i, bundle in enumerate(expected_bundles):
self.assertEqual(actual_bundles[i].id, bundle.id)
self.assertEqual(actual_bundles[i].order, i)
def move_bundle(self, bundle: BookmarkBundle, position: int):
return self.client.post(
reverse("linkding:bundles.action"),
{"move_bundle": str(bundle.id), "move_position": position},
)
def test_move_bundle(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.move_bundle(bundle1, 1)
self.assertBundleOrder([bundle2, bundle1, bundle3])
self.move_bundle(bundle1, 0)
self.assertBundleOrder([bundle1, bundle2, bundle3])
self.move_bundle(bundle1, 2)
self.assertBundleOrder([bundle2, bundle3, bundle1])
self.move_bundle(bundle1, 2)
self.assertBundleOrder([bundle2, bundle3, bundle1])
def test_move_bundle_response(self):
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
self.setup_bundle(name="Bundle 2", order=1)
response = self.move_bundle(bundle1, 1)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("linkding:bundles.index"))
def test_can_only_move_user_owned_bundles(self):
other_user = self.setup_user()
other_user_bundle1 = self.setup_bundle(user=other_user)
self.setup_bundle(user=other_user)
response = self.move_bundle(other_user_bundle1, 1)
self.assertEqual(response.status_code, 404)
def test_move_bundle_only_affects_own_bundles(self):
user_bundle1 = self.setup_bundle(name="User Bundle 1", order=0)
user_bundle2 = self.setup_bundle(name="User Bundle 2", order=1)
other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(
name="Other User Bundle", user=other_user, order=0
)
# Move user bundle
self.move_bundle(user_bundle1, 1)
self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)
# Check that other user's bundle is unaffected
self.assertBundleOrder([other_user_bundle], user=other_user)
def test_remove_non_existing_bundle(self):
non_existent_id = 99999
response = self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(non_existent_id)},
)
self.assertEqual(response.status_code, 404)
def test_post_without_action(self):
bundle = self.setup_bundle(name="Test Bundle")
response = self.client.post(reverse("linkding:bundles.action"), {})
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("linkding:bundles.index"))
self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())

View File

@@ -0,0 +1,147 @@
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, HtmlTestMixin
class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
"name": "Test Bundle",
"search": "test search",
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
}
return {**form_data, **overrides}
def test_should_create_new_bundle(self):
form_data = self.create_form_data()
response = self.client.post(reverse("linkding:bundles.new"), form_data)
self.assertEqual(BookmarkBundle.objects.count(), 1)
bundle = BookmarkBundle.objects.first()
self.assertEqual(bundle.owner, self.user)
self.assertEqual(bundle.name, form_data["name"])
self.assertEqual(bundle.search, form_data["search"])
self.assertEqual(bundle.any_tags, form_data["any_tags"])
self.assertEqual(bundle.all_tags, form_data["all_tags"])
self.assertEqual(bundle.excluded_tags, form_data["excluded_tags"])
self.assertRedirects(response, reverse("linkding:bundles.index"))
def test_should_increment_order_for_subsequent_bundles(self):
# Create first bundle
form_data_1 = self.create_form_data({"name": "Bundle 1"})
self.client.post(reverse("linkding:bundles.new"), form_data_1)
bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
self.assertEqual(bundle1.order, 0)
# Create second bundle
form_data_2 = self.create_form_data({"name": "Bundle 2"})
self.client.post(reverse("linkding:bundles.new"), form_data_2)
bundle2 = BookmarkBundle.objects.get(name="Bundle 2")
self.assertEqual(bundle2.order, 1)
# Create another bundle with a higher order
self.setup_bundle(order=5)
# Create third bundle
form_data_3 = self.create_form_data({"name": "Bundle 3"})
self.client.post(reverse("linkding:bundles.new"), form_data_3)
bundle3 = BookmarkBundle.objects.get(name="Bundle 3")
self.assertEqual(bundle3.order, 6)
def test_incrementing_order_ignores_other_user_bookmark(self):
other_user = self.setup_user()
self.setup_bundle(user=other_user, order=10)
form_data = self.create_form_data({"name": "Bundle 1"})
self.client.post(reverse("linkding:bundles.new"), form_data)
bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
self.assertEqual(bundle1.order, 0)
def test_should_return_422_with_invalid_form(self):
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

@@ -0,0 +1,116 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_preview_empty_bundle(self):
bookmark1 = self.setup_bookmark(title="Test Bookmark 1")
bookmark2 = self.setup_bookmark(title="Test Bookmark 2")
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 2 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertContains(response, bookmark2.title)
self.assertNotContains(response, "No bookmarks match the current bundle")
def test_preview_with_search_terms(self):
bookmark1 = self.setup_bookmark(title="Python Programming")
bookmark2 = self.setup_bookmark(title="JavaScript Tutorial")
bookmark3 = self.setup_bookmark(title="Django Framework")
response = self.client.get(
reverse("linkding:bundles.preview"), {"search": "python"}
)
self.assertEqual(response.status_code, 200)
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_preview_no_matching_bookmarks(self):
bookmark = self.setup_bookmark(title="Python Guide")
response = self.client.get(
reverse("linkding:bundles.preview"), {"search": "nonexistent"}
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No bookmarks match the current bundle")
self.assertNotContains(response, bookmark.title)
def test_preview_renders_bookmark(self):
tag = self.setup_tag(name="test-tag")
bookmark = self.setup_bookmark(
title="Test Bookmark",
description="Test description",
url="https://example.com/test",
tags=[tag],
)
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, bookmark.title)
self.assertContains(response, bookmark.description)
self.assertContains(response, bookmark.url)
self.assertContains(response, "#test-tag")
def test_preview_renders_bookmark_in_preview_mode(self):
tag = self.setup_tag(name="test-tag")
self.setup_bookmark(
title="Test Bookmark",
description="Test description",
url="https://example.com/test",
tags=[tag],
)
response = self.client.get(reverse("linkding:bundles.preview"))
soup = self.make_soup(response.content.decode())
list_item = soup.select_one("li[ld-bookmark-item]")
actions = list_item.select(".actions > *")
self.assertEqual(len(actions), 1)
def test_preview_ignores_archived_bookmarks(self):
active_bookmark = self.setup_bookmark(title="Active Bookmark")
archived_bookmark = self.setup_bookmark(
title="Archived Bookmark", is_archived=True
)
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, active_bookmark.title)
self.assertNotContains(response, archived_bookmark.title)
def test_preview_requires_authentication(self):
self.client.logout()
response = self.client.get(reverse("linkding:bundles.preview"), follow=True)
self.assertRedirects(
response, f"/login/?next={reverse('linkding:bundles.preview')}"
)
def test_preview_only_shows_user_bookmarks(self):
other_user = self.setup_user()
own_bookmark = self.setup_bookmark(title="Own Bookmark")
other_bookmark = self.setup_bookmark(title="Other Bookmark", user=other_user)
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, own_bookmark.title)
self.assertNotContains(response, other_bookmark.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

@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
)
def assertPrevLink(self, html: str, page_number: int, href: str = None):
href = href if href else "?page={0}".format(page_number)
href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
<li class="page-item">
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
)
def assertNextLink(self, html: str, page_number: int, href: str = None):
href = href if href else "?page={0}".format(page_number)
href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
<li class="page-item">
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
href: str = None,
):
active_class = "active" if active else ""
href = href if href else "?page={0}".format(page_number)
href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
<li class="page-item {1}">
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
rendered_template = self.render_template(
100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
)
self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
self.assertPageLink(
rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
self.assertPrevLink(
rendered_template,
1,
href="http://testserver/test?q=cake&sort=title_asc&page=1",
)
self.assertPageLink(
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
rendered_template,
1,
False,
href="http://testserver/test?q=cake&sort=title_asc&page=1",
)
self.assertPageLink(
rendered_template,
2,
True,
href="http://testserver/test?q=cake&sort=title_asc&page=2",
)
self.assertNextLink(
rendered_template,
3,
href="http://testserver/test?q=cake&sort=title_asc&page=3",
)
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
def test_removes_details_parameter(self):
rendered_template = self.render_template(
100, 10, 2, url="/test?details=1&page=2"
)
self.assertPrevLink(rendered_template, 1, href="?page=1")
self.assertPageLink(rendered_template, 1, False, href="?page=1")
self.assertPageLink(rendered_template, 2, True, href="?page=2")
self.assertNextLink(rendered_template, 3, href="?page=3")
self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
self.assertPageLink(
rendered_template, 1, False, href="http://testserver/test?page=1"
)
self.assertPageLink(
rendered_template, 2, True, href="http://testserver/test?page=2"
)
self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")

View File

@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
]
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
expected_items = []
for item_list in item_lists:
expected_items = expected_items + item_list
@@ -1211,3 +1211,343 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def test_query_bookmarks_filter_modified_since(self):
# Create bookmarks with different modification dates
older_bookmark = self.setup_bookmark(title="old bookmark")
recent_bookmark = self.setup_bookmark(title="recent bookmark")
# Modify date field on bookmark directly to test modified_since
older_bookmark.date_modified = timezone.datetime(
2025, 1, 1, tzinfo=datetime.timezone.utc
)
older_bookmark.save()
recent_bookmark.date_modified = timezone.datetime(
2025, 5, 15, tzinfo=datetime.timezone.utc
)
recent_bookmark.save()
# Test with date between the two bookmarks
search = BookmarkSearch(modified_since="2025-03-01T00:00:00Z")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [recent_bookmark])
# Test with date before both bookmarks
search = BookmarkSearch(modified_since="2024-12-31T00:00:00Z")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
# Test with date after both bookmarks
search = BookmarkSearch(modified_since="2025-05-16T00:00:00Z")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [])
# Test with no modified_since - should return all bookmarks
search = BookmarkSearch()
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
# Test with invalid date format - should be ignored
search = BookmarkSearch(modified_since="invalid-date")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
def test_query_bookmarks_filter_added_since(self):
# Create bookmarks with different dates
older_bookmark = self.setup_bookmark(
title="old bookmark",
added=timezone.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
)
recent_bookmark = self.setup_bookmark(
title="recent bookmark",
added=timezone.datetime(2025, 5, 15, tzinfo=datetime.timezone.utc),
)
# Test with date between the two bookmarks
search = BookmarkSearch(added_since="2025-03-01T00:00:00Z")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [recent_bookmark])
# Test with date before both bookmarks
search = BookmarkSearch(added_since="2024-12-31T00:00:00Z")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
# Test with date after both bookmarks
search = BookmarkSearch(added_since="2025-05-16T00:00:00Z")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [])
# Test with no added_since - should return all bookmarks
search = BookmarkSearch()
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
# Test with invalid date format - should be ignored
search = BookmarkSearch(added_since="invalid-date")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
def test_query_bookmarks_with_bundle_search_terms(self):
bundle = self.setup_bundle(search="search_term_A search_term_B")
matching_bookmarks = [
self.setup_bookmark(
title="search_term_A content", description="search_term_B also here"
),
self.setup_bookmark(url="http://example.com/search_term_A/search_term_B"),
]
# Bookmarks that should not match
self.setup_bookmark(title="search_term_A only")
self.setup_bookmark(description="search_term_B only")
self.setup_bookmark(title="unrelated content")
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_search_and_bundle_search_terms(self):
bundle = self.setup_bundle(search="bundle_term_B")
search = BookmarkSearch(q="search_term_A", bundle=bundle)
matching_bookmarks = [
self.setup_bookmark(
title="search_term_A content", description="bundle_term_B also here"
)
]
# Bookmarks that should not match
self.setup_bookmark(title="search_term_A only")
self.setup_bookmark(description="bundle_term_B only")
self.setup_bookmark(title="unrelated content")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_any_tags(self):
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(tags=[tag2]),
self.setup_bookmark(tags=[tag1, tag2]),
]
# Bookmarks that should not match
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):
bundle = self.setup_bundle(any_tags="bundleTagA bundleTagB")
search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
search_tag1 = self.setup_tag(name="searchTag1")
search_tag2 = self.setup_tag(name="searchTag2")
bundle_tag_a = self.setup_tag(name="bundleTagA")
bundle_tag_b = self.setup_tag(name="bundleTagB")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),
self.setup_bookmark(
tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
),
]
# Bookmarks that should not match
self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])
self.setup_bookmark(tags=[search_tag1, search_tag2])
self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
self.setup_bookmark(tags=[search_tag2, bundle_tag_b])
self.setup_bookmark(tags=[bundle_tag_a])
self.setup_bookmark(tags=[bundle_tag_b])
self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_all_tags(self):
bundle = self.setup_bundle(all_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]
# Bookmarks that should not match
self.setup_bookmark(tags=[tag1])
self.setup_bookmark(tags=[tag2])
self.setup_bookmark(tags=[tag1, other_tag])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):
bundle = self.setup_bundle(all_tags="bundleTagA bundleTagB")
search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
search_tag1 = self.setup_tag(name="searchTag1")
search_tag2 = self.setup_tag(name="searchTag2")
bundle_tag_a = self.setup_tag(name="bundleTagA")
bundle_tag_b = self.setup_tag(name="bundleTagB")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(
tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
)
]
# Bookmarks that should not match
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])
self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])
self.setup_bookmark(tags=[search_tag1, search_tag2])
self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_excluded_tags(self):
bundle = self.setup_bundle(excluded_tags="excludeTag1 excludeTag2")
exclude_tag1 = self.setup_tag(name="excludeTag1")
exclude_tag2 = self.setup_tag(name="excludeTag2")
keep_tag = self.setup_tag(name="keepTag")
keep_other_tag = self.setup_tag(name="keepOtherTag")
matching_bookmarks = [
self.setup_bookmark(tags=[keep_tag]),
self.setup_bookmark(tags=[keep_other_tag]),
self.setup_bookmark(tags=[keep_tag, keep_other_tag]),
self.setup_bookmark(),
]
# Bookmarks that should not be returned
self.setup_bookmark(tags=[exclude_tag1])
self.setup_bookmark(tags=[exclude_tag2])
self.setup_bookmark(tags=[exclude_tag1, keep_tag])
self.setup_bookmark(tags=[exclude_tag2, keep_tag])
self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])
self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_combined_tags(self):
bundle = self.setup_bundle(
any_tags="anyTagA anyTagB",
all_tags="allTag1 allTag2",
excluded_tags="excludedTag",
)
any_tag_a = self.setup_tag(name="anyTagA")
any_tag_b = self.setup_tag(name="anyTagB")
all_tag_1 = self.setup_tag(name="allTag1")
all_tag_2 = self.setup_tag(name="allTag2")
other_tag = self.setup_tag(name="otherTag")
excluded_tag = self.setup_tag(name="excludedTag")
matching_bookmarks = [
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),
self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),
]
# Bookmarks that should not match
self.setup_bookmark(tags=[any_tag_a, all_tag_1])
self.setup_bookmark(tags=[any_tag_b, all_tag_2])
self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])
self.setup_bookmark(tags=[all_tag_1, all_tag_2])
self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])
self.setup_bookmark(tags=[any_tag_a])
self.setup_bookmark(tags=[any_tag_b])
self.setup_bookmark(tags=[all_tag_1])
self.setup_bookmark(tags=[all_tag_2])
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_archived_bookmarks_with_bundle(self):
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(is_archived=True, tags=[tag1]),
self.setup_bookmark(is_archived=True, tags=[tag2]),
self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),
]
# Bookmarks that should not match
self.setup_bookmark(is_archived=True, tags=[other_tag])
self.setup_bookmark(is_archived=True)
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(tags=[tag2]),
self.setup_bookmark(tags=[tag1, tag2]),
query = queries.query_archived_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_shared_bookmarks_with_bundle(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),
]
# Bookmarks that should not match
self.setup_bookmark(user=user1, shared=True, tags=[other_tag])
self.setup_bookmark(user=user2, shared=True)
self.setup_bookmark(user=user1, shared=False, tags=[tag1]),
self.setup_bookmark(user=user2, shared=False, tags=[tag2]),
self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2]),
query = queries.query_shared_bookmarks(
None, self.profile, BookmarkSearch(q="", bundle=bundle), False
)
self.assertQueryResult(query, [matching_bookmarks])

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

@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"items_per_page": "30",
"sticky_pagination": False,
"collapse_side_panel": False,
"hide_bundles": False,
}
return {**form_data, **overrides}
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"items_per_page": "10",
"sticky_pagination": True,
"collapse_side_panel": True,
"hide_bundles": True,
}
response = self.client.post(
reverse("linkding:settings.update"), form_data, follow=True
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
self.assertSuccessMessage(html, "Profile updated")

View File

@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import UserProfile
from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views import contexts
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request)
tag_cloud_context = context_type(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
tag_cloud_context = context_type(request, search)
context = RequestContext(request, {"tag_cloud": tag_cloud_context})
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
return template_to_render.render(context)

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"),

View File

@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should render toasts container
self.assertContains(response, '<div class="toasts">')
self.assertContains(response, '<div class="message-list">')
# Should render two toasts
self.assertContains(response, '<div class="toast d-flex">', count=2)
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should not render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
self.assertContains(response, '<div class="message-list">', count=0)
# Should not render toasts
self.assertContains(response, '<div class="toast">', count=0)
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should not render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
self.assertContains(response, '<div class="message-list">', count=0)
# Should not render toasts
self.assertContains(response, '<div class="toast">', count=0)

View File

@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
search = BookmarkSearch.from_request(request.GET)
search = BookmarkSearch.from_request(request, request.GET)
context = RequestContext(
request,
{

View File

@@ -124,7 +124,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Cancel edit, verify return to details url
details_url = url + f"&details={bookmark.id}"
with self.page.expect_navigation(url=self.live_server_url + details_url):
self.page.get_by_text("Nevermind").click()
self.page.get_by_text("Cancel").click()
def test_delete(self):
bookmark = self.setup_bookmark()

View File

@@ -0,0 +1,50 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
def test_update_preview_on_filter_changes(self):
group1 = self.setup_numbered_bookmarks(3, prefix="foo")
group2 = self.setup_numbered_bookmarks(3, prefix="bar")
with sync_playwright() as p:
# shows all bookmarks initially
page = self.open(reverse("linkding:bundles.new"), p)
expect(
page.get_by_text(f"Found 6 bookmarks matching this bundle")
).to_be_visible()
self.assertVisibleBookmarks(group1 + group2)
# filter by group1
search = page.get_by_label("Search")
search.fill("foo")
expect(
page.get_by_text(f"Found 3 bookmarks matching this bundle")
).to_be_visible()
self.assertVisibleBookmarks(group1)
# filter by group2
search.fill("bar")
expect(
page.get_by_text(f"Found 3 bookmarks matching this bundle")
).to_be_visible()
self.assertVisibleBookmarks(group2)
# filter by invalid group
search.fill("invalid")
expect(
page.get_by_text(f"No bookmarks match the current bundle")
).to_be_visible()
self.assertVisibleBookmarks([])
def assertVisibleBookmarks(self, bookmarks):
self.assertEqual(len(bookmarks), self.count_bookmarks())
for bookmark in bookmarks:
expect(self.locate_bookmark(bookmark.title)).to_be_visible()

View File

@@ -4,8 +4,9 @@ from urllib.parse import quote
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
from bookmarks.services import website_loader
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
mock_website_metadata = website_loader.WebsiteMetadata(
url="https://example.com",
@@ -311,3 +312,26 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
# Verify that the fields are NOT visually marked as modified
expect(title_field).to_have_class("form-input")
expect(description_field).to_have_class("form-input")
def test_ctrl_enter_submits_form_from_description(self):
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
url_field = page.get_by_label("URL")
description_field = page.get_by_label("Description")
url_field.fill("https://example.com")
description_field.fill("Test description")
description_field.focus()
# Press Ctrl+Enter to submit form
description_field.press("Control+Enter")
# Should navigate away from new bookmark page after successful submission
expect(page).not_to_have_url(
self.live_server_url + reverse("linkding:bookmarks.new")
)
self.assertEqual(1, Bookmark.objects.count())
bookmark = Bookmark.objects.first()
self.assertEqual("https://example.com", bookmark.url)
self.assertEqual("Example Domain", bookmark.title)

View File

@@ -49,6 +49,10 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
return bookmark_tags.filter(has_text=title)
def count_bookmarks(self):
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
return bookmark_tags.count()
def locate_details_modal(self):
return self.page.locator(".modal.bookmark-details")

View File

@@ -43,6 +43,12 @@ urlpatterns = [
views.assets.read,
name="assets.read",
),
# Bundles
path("bundles", views.bundles.index, name="bundles.index"),
path("bundles/action", views.bundles.action, name="bundles.action"),
path("bundles/new", views.bundles.new, name="bundles.new"),
path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
path("bundles/preview", views.bundles.preview, name="bundles.preview"),
# Settings
path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"),
@@ -64,6 +70,7 @@ urlpatterns = [
include(api_routes.bookmark_asset_router.urls),
),
path("api/tags/", include(api_routes.tag_router.urls)),
path("api/bundles/", include(api_routes.bundle_router.urls)),
path("api/user/", include(api_routes.user_router.urls)),
# Feeds
path("feeds/<str:feed_key>/all", feeds.AllBookmarksFeed(), name="feeds.all"),

View File

@@ -1,6 +1,7 @@
from .assets import *
from .auth import *
from .bookmarks import *
from . import bundles
from .settings import *
from .toasts import *
from .health import health

View File

@@ -1,6 +1,6 @@
from django.http import Http404
from bookmarks.models import Bookmark, BookmarkAsset, Toast
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
from bookmarks.type_defs import HttpRequest
@@ -32,6 +32,17 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
raise Http404("Bookmark does not exist")
def bundle_read(request: HttpRequest, bundle_id: int | str):
return bundle_write(request, bundle_id)
def bundle_write(request: HttpRequest, bundle_id: int | str):
try:
return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
except (BookmarkBundle.DoesNotExist, ValueError):
raise Http404("Bundle does not exist")
def asset_read(request: HttpRequest, asset_id: int | str):
try:
asset = BookmarkAsset.objects.get(pk=asset_id)

View File

@@ -31,7 +31,9 @@ def view(request, asset_id: int):
asset = access.asset_read(request, asset_id)
content = _get_asset_content(asset)
return HttpResponse(content, content_type=asset.content_type)
response = HttpResponse(content, content_type=asset.content_type)
response["Content-Disposition"] = f'inline; filename="{asset.download_name}"'
return response
def read(request, asset_id: int):

View File

@@ -31,6 +31,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks,
unshare_bookmarks,
refresh_bookmarks_metadata,
create_html_snapshots,
)
from bookmarks.type_defs import HttpRequest
from bookmarks.utils import get_safe_return_url
@@ -42,8 +43,12 @@ def index(request: HttpRequest):
if request.method == "POST":
return search_action(request)
bookmark_list = contexts.ActiveBookmarkListContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
bundles = contexts.BundlesContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request, search)
bookmark_details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
@@ -54,6 +59,7 @@ def index(request: HttpRequest):
{
"page_title": "Bookmarks - Linkding",
"bookmark_list": bookmark_list,
"bundles": bundles,
"tag_cloud": tag_cloud,
"details": bookmark_details,
},
@@ -65,8 +71,12 @@ def archived(request: HttpRequest):
if request.method == "POST":
return search_action(request)
bookmark_list = contexts.ArchivedBookmarkListContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
bundles = contexts.BundlesContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
bookmark_details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
@@ -77,6 +87,7 @@ def archived(request: HttpRequest):
{
"page_title": "Archived bookmarks - Linkding",
"bookmark_list": bookmark_list,
"bundles": bundles,
"tag_cloud": tag_cloud,
"details": bookmark_details,
},
@@ -87,8 +98,11 @@ def shared(request: HttpRequest):
if request.method == "POST":
return search_action(request)
bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.SharedBookmarkListContext(request, search)
tag_cloud = contexts.SharedTagCloudContext(request, search)
bookmark_details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
@@ -132,13 +146,13 @@ def search_action(request: HttpRequest):
if "save" in request.POST:
if not request.user.is_authenticated:
return HttpResponseForbidden()
search = BookmarkSearch.from_request(request.POST)
search = BookmarkSearch.from_request(request, request.POST)
request.user_profile.search_preferences = search.preferences_dict
request.user_profile.save()
# redirect to base url including new query params
search = BookmarkSearch.from_request(
request.POST, request.user_profile.search_preferences
request, request.POST, request.user_profile.search_preferences
)
base_url = request.path
query_params = search.query_params
@@ -248,7 +262,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
@login_required
def index_action(request: HttpRequest):
search = BookmarkSearch.from_request(request.GET)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
query = queries.query_bookmarks(request.user, request.user_profile, search)
response = handle_action(request, query)
@@ -263,7 +279,9 @@ def index_action(request: HttpRequest):
@login_required
def archived_action(request: HttpRequest):
search = BookmarkSearch.from_request(request.GET)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
response = handle_action(request, query)
@@ -351,6 +369,8 @@ def handle_action(request: HttpRequest, query: QuerySet[Bookmark] = None):
return unshare_bookmarks(bookmark_ids, request.user)
if "bulk_refresh" == bulk_action:
return refresh_bookmarks_metadata(bookmark_ids, request.user)
if "bulk_snapshot" == bulk_action:
return create_html_snapshots(bookmark_ids, request.user)
@login_required

119
bookmarks/views/bundles.py Normal file
View File

@@ -0,0 +1,119 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
from bookmarks.queries import parse_query_string
from bookmarks.services import bundles
from bookmarks.views import access
from bookmarks.views.contexts import ActiveBookmarkListContext
@login_required
def index(request: HttpRequest):
bundles = BookmarkBundle.objects.filter(owner=request.user).order_by("order")
context = {"bundles": bundles}
return render(request, "bundles/index.html", context)
@login_required
def action(request: HttpRequest):
if "remove_bundle" in request.POST:
remove_bundle_id = request.POST.get("remove_bundle")
bundle = access.bundle_write(request, remove_bundle_id)
bundle_name = bundle.name
bundles.delete_bundle(bundle)
messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
elif "move_bundle" in request.POST:
bundle_id = request.POST.get("move_bundle")
bundle_to_move = access.bundle_write(request, bundle_id)
move_position = int(request.POST.get("move_position"))
bundles.move_bundle(bundle_to_move, move_position)
return HttpResponseRedirect(reverse("linkding:bundles.index"))
def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
form_data = request.POST if request.method == "POST" else None
initial_data = {}
if bundle is None and request.method == "GET":
query_param = request.GET.get("q")
if query_param:
parsed = parse_query_string(query_param)
if parsed["search_terms"]:
initial_data["search"] = " ".join(parsed["search_terms"])
if parsed["tag_names"]:
initial_data["all_tags"] = " ".join(parsed["tag_names"])
form = BookmarkBundleForm(form_data, instance=bundle, initial=initial_data)
if request.method == "POST":
if form.is_valid():
instance = form.save(commit=False)
if bundle is None:
instance.order = None
bundles.create_bundle(instance, request.user)
else:
instance.save()
messages.success(request, "Bundle saved successfully.")
return HttpResponseRedirect(reverse("linkding:bundles.index"))
status = 422 if request.method == "POST" and not form.is_valid() else 200
bookmark_list = _get_bookmark_list_preview(request, bundle, initial_data)
context = {
"form": form,
"bundle": bundle,
"bookmark_list": bookmark_list,
}
return render(request, template, context, status=status)
@login_required
def new(request: HttpRequest):
return _handle_edit(request, "bundles/new.html")
@login_required
def edit(request: HttpRequest, bundle_id: int):
bundle = access.bundle_write(request, bundle_id)
return _handle_edit(request, "bundles/edit.html", bundle)
@login_required
def preview(request: HttpRequest):
bookmark_list = _get_bookmark_list_preview(request)
context = {"bookmark_list": bookmark_list}
return render(request, "bundles/preview.html", context)
def _get_bookmark_list_preview(
request: HttpRequest,
bundle: BookmarkBundle | None = None,
initial_data: dict = None,
):
if request.method == "GET" and bundle:
preview_bundle = bundle
else:
form_data = (
request.POST.copy() if request.method == "POST" else request.GET.copy()
)
if initial_data:
for key, value in initial_data.items():
form_data[key] = value
form_data["name"] = "Preview Bundle" # Set dummy name for form validation
form = BookmarkBundleForm(form_data)
preview_bundle = form.save(commit=False)
search = BookmarkSearch(bundle=preview_bundle)
bookmark_list = ActiveBookmarkListContext(request, search)
bookmark_list.is_preview = True
return bookmark_list

View File

@@ -13,6 +13,7 @@ from bookmarks import utils
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
BookmarkSearch,
User,
UserProfile,
@@ -178,15 +179,13 @@ class BookmarkItem:
class BookmarkListContext:
request_context = RequestContext
def __init__(self, request: HttpRequest) -> None:
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
request_context = self.request_context(request)
user = request.user
user_profile = request.user_profile
self.request = request
self.search = BookmarkSearch.from_request(
self.request.GET, user_profile.search_preferences
)
self.search = search
query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page")
@@ -219,6 +218,8 @@ class BookmarkListContext:
self.show_preview_images = user_profile.enable_preview_images
self.show_notes = user_profile.permanent_notes
self.collapse_side_panel = user_profile.collapse_side_panel
self.is_preview = False
self.snapshot_feature_enabled = settings.LD_ENABLE_SNAPSHOTS
@staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
@@ -315,14 +316,12 @@ class TagGroup:
class TagCloudContext:
request_context = RequestContext
def __init__(self, request: HttpRequest) -> None:
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
request_context = self.request_context(request)
user_profile = request.user_profile
self.request = request
self.search = BookmarkSearch.from_request(
self.request.GET, user_profile.search_preferences
)
self.search = search
query_set = request_context.get_tag_query_set(self.search)
tags = list(query_set)
@@ -371,6 +370,7 @@ class BookmarkAssetItem:
self.asset_type = asset.asset_type
self.file = asset.file
self.file_size = asset.file_size
self.content_type = asset.content_type
self.status = asset.status
icon_classes = []
@@ -461,3 +461,23 @@ def get_details_context(
return None
return context_type(request, bookmark)
class BundlesContext:
def __init__(self, request: HttpRequest) -> None:
self.request = request
self.user = request.user
self.user_profile = request.user_profile
self.bundles = (
BookmarkBundle.objects.filter(owner=self.user).order_by("order").all()
)
self.is_empty = len(self.bundles) == 0
selected_bundle_id = (
int(request.GET.get("bundle")) if request.GET.get("bundle") else None
)
self.selected_bundle = next(
(bundle for bundle in self.bundles if bundle.id == selected_bundle_id),
None,
)

View File

@@ -1,3 +1,4 @@
from bookmarks.models import BookmarkSearch
from bookmarks.views import contexts, turbo
@@ -14,8 +15,11 @@ def render_bookmark_update(request, bookmark_list, tag_cloud, details):
def active_bookmark_update(request):
bookmark_list = contexts.ActiveBookmarkListContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
tag_cloud = contexts.ActiveTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
def archived_bookmark_update(request):
bookmark_list = contexts.ArchivedBookmarkListContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
def shared_bookmark_update(request):
bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.SharedBookmarkListContext(request, search)
tag_cloud = contexts.SharedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)

View File

@@ -11,6 +11,7 @@ from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from rest_framework.authtoken.models import Token
from bookmarks.models import (
@@ -239,8 +240,12 @@ def bookmark_export(request: HttpRequest):
prefetch_related_objects(bookmarks, "tags")
file_content = exporter.export_netscape_html(list(bookmarks))
# Generate filename with current date and time
current_time = timezone.now()
filename = current_time.strftime("bookmarks_%Y-%m-%d_%H-%M-%S.html")
response = HttpResponse(content_type="text/plain; charset=UTF-8")
response["Content-Disposition"] = 'attachment; filename="bookmarks.html"'
response["Content-Disposition"] = f'attachment; filename="{filename}"'
response.write(file_content)
return response

View File

@@ -1,55 +1,57 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
// https://astro.build/config
export default defineConfig({
integrations: [
starlight({
title: 'linkding',
logo: {
src: './src/assets/logo.svg',
},
social: {
github: 'https://github.com/sissbruecker/linkding',
},
sidebar: [
{
label: 'Getting Started',
items: [
{ label: 'Installation', slug: 'installation' },
{ label: 'Options', slug: 'options' },
{ label: 'Managed Hosting', slug: 'managed-hosting' },
{ label: 'Browser Extension', slug: 'browser-extension' },
],
},
{
label: 'Guides',
items: [
{ label: 'Backups', slug: 'backups' },
{ label: 'Archiving', slug: 'archiving' },
{ label: 'Auto Tagging', slug: 'auto-tagging' },
{ label: 'Keyboard Shortcuts', slug: 'shortcuts' },
{ label: 'How To', slug: 'how-to' },
{ label: 'Troubleshooting', slug: 'troubleshooting' },
{ label: 'Admin', slug: 'admin' },
{ label: 'REST API', slug: 'api' },
],
},
{
label: 'Resources',
items: [
{ label: 'Community', slug: 'community' },
{ label: 'Acknowledgements', slug: 'acknowledgements' },
],
},
],
customCss: [
'./src/styles/custom.css',
],
editLink: {
baseUrl: 'https://github.com/sissbruecker/linkding/edit/master/docs/',
},
}),
],
integrations: [
starlight({
title: "linkding",
logo: {
src: "./src/assets/logo.svg",
},
social: [
{
icon: "github",
label: "GitHub",
href: "https://github.com/sissbruecker/linkding",
},
],
sidebar: [
{
label: "Getting Started",
items: [
{ label: "Installation", slug: "installation" },
{ label: "Options", slug: "options" },
{ label: "Managed Hosting", slug: "managed-hosting" },
{ label: "Browser Extension", slug: "browser-extension" },
],
},
{
label: "Guides",
items: [
{ label: "Backups", slug: "backups" },
{ label: "Archiving", slug: "archiving" },
{ label: "Auto Tagging", slug: "auto-tagging" },
{ label: "Keyboard Shortcuts", slug: "shortcuts" },
{ label: "How To", slug: "how-to" },
{ label: "Troubleshooting", slug: "troubleshooting" },
{ label: "Admin", slug: "admin" },
{ label: "REST API", slug: "api" },
],
},
{
label: "Resources",
items: [
{ label: "Community", slug: "community" },
{ label: "Acknowledgements", slug: "acknowledgements" },
],
},
],
customCss: ["./src/styles/custom.css"],
editLink: {
baseUrl: "https://github.com/sissbruecker/linkding/edit/master/docs/",
},
}),
],
});

101
docs/package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": {
"@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.34.3",
"astro": "^5.7.13",
"astro": "^5.12.8",
"sharp": "^0.32.5",
"typescript": "^5.6.2"
}
@@ -34,9 +34,9 @@
}
},
"node_modules/@astrojs/compiler": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.0.tgz",
"integrity": "sha512-7bCjW6tVDpUurQLeKBUN9tZ5kSv5qYrGmcn0sG0IwacL7isR2ZbyyA3AdZ4uxsuUFOS2SlgReTH7wkxO6zpqWA==",
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz",
"integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==",
"license": "MIT"
},
"node_modules/@astrojs/internal-helpers": {
@@ -204,9 +204,9 @@
}
},
"node_modules/@astrojs/telemetry": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.2.1.tgz",
"integrity": "sha512-SSVM820Jqc6wjsn7qYfV9qfeQvePtVc1nSofhyap7l0/iakUKywj3hfy3UJAOV4sGV4Q/u450RD4AaCaFvNPlg==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
"integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==",
"license": "MIT",
"dependencies": {
"ci-info": "^4.2.0",
@@ -218,7 +218,7 @@
"which-pm-runs": "^1.1.0"
},
"engines": {
"node": "^18.17.1 || ^20.3.0 || >=22.0.0"
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
"node_modules/@astrojs/yaml2ts": {
@@ -2047,15 +2047,15 @@
}
},
"node_modules/astro": {
"version": "5.7.13",
"resolved": "https://registry.npmjs.org/astro/-/astro-5.7.13.tgz",
"integrity": "sha512-cRGq2llKOhV3XMcYwQpfBIUcssN6HEK5CRbcMxAfd9OcFhvWE7KUy50zLioAZVVl3AqgUTJoNTlmZfD2eG0G1w==",
"version": "5.12.8",
"resolved": "https://registry.npmjs.org/astro/-/astro-5.12.8.tgz",
"integrity": "sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==",
"license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.11.0",
"@astrojs/internal-helpers": "0.6.1",
"@astrojs/markdown-remark": "6.3.1",
"@astrojs/telemetry": "3.2.1",
"@astrojs/compiler": "^2.12.2",
"@astrojs/internal-helpers": "0.7.1",
"@astrojs/markdown-remark": "6.3.5",
"@astrojs/telemetry": "3.3.0",
"@capsizecss/unpack": "^2.4.0",
"@oslojs/encoding": "^1.1.0",
"@rollup/pluginutils": "^5.1.4",
@@ -2082,6 +2082,7 @@
"github-slugger": "^2.0.0",
"html-escaper": "3.0.3",
"http-cache-semantics": "^4.1.1",
"import-meta-resolve": "^4.1.0",
"js-yaml": "^4.1.0",
"kleur": "^4.1.5",
"magic-string": "^0.30.17",
@@ -2096,6 +2097,7 @@
"rehype": "^13.0.2",
"semver": "^7.7.1",
"shiki": "^3.2.1",
"smol-toml": "^1.3.4",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.12",
"tsconfck": "^3.1.5",
@@ -2109,7 +2111,7 @@
"xxhash-wasm": "^1.1.0",
"yargs-parser": "^21.1.1",
"yocto-spinner": "^0.2.1",
"zod": "^3.24.2",
"zod": "^3.24.4",
"zod-to-json-schema": "^3.24.5",
"zod-to-ts": "^1.2.0"
},
@@ -2117,7 +2119,7 @@
"astro": "astro.js"
},
"engines": {
"node": "^18.17.1 || ^20.3.0 || >=22.0.0",
"node": "18.20.8 || ^20.3.0 || >=22.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0"
},
@@ -2141,6 +2143,53 @@
"astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0"
}
},
"node_modules/astro/node_modules/@astrojs/internal-helpers": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz",
"integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==",
"license": "MIT"
},
"node_modules/astro/node_modules/@astrojs/markdown-remark": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.5.tgz",
"integrity": "sha512-MiR92CkE2BcyWf3b86cBBw/1dKiOH0qhLgXH2OXA6cScrrmmks1Rr4Tl0p/lFpvmgQQrP54Pd1uidJfmxGrpWQ==",
"license": "MIT",
"dependencies": {
"@astrojs/internal-helpers": "0.7.1",
"@astrojs/prism": "3.3.0",
"github-slugger": "^2.0.0",
"hast-util-from-html": "^2.0.3",
"hast-util-to-text": "^4.0.2",
"import-meta-resolve": "^4.1.0",
"js-yaml": "^4.1.0",
"mdast-util-definitions": "^6.0.0",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2",
"shiki": "^3.2.1",
"smol-toml": "^1.3.4",
"unified": "^11.0.5",
"unist-util-remove-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"unist-util-visit-parents": "^6.0.1",
"vfile": "^6.0.3"
}
},
"node_modules/astro/node_modules/@astrojs/prism": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz",
"integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==",
"license": "MIT",
"dependencies": {
"prismjs": "^1.30.0"
},
"engines": {
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
"node_modules/astro/node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
@@ -2548,9 +2597,9 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"node_modules/ci-info": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz",
"integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
"integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==",
"funding": [
{
"type": "github",
@@ -5584,9 +5633,9 @@
}
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
"integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
@@ -6511,9 +6560,9 @@
}
},
"node_modules/tar-fs": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
"integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",

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