From 1672dc01521a9ac56ef07e4c2c2c09d3a5c3c44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Thu, 19 Jun 2025 16:47:29 +0200 Subject: [PATCH] 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 --- bookmarks/api/routes.py | 2 +- .../components/TagAutocomplete.svelte | 63 +++-- ...userprofile_hide_bundles_bookmarkbundle.py | 50 ++++ bookmarks/models.py | 69 ++++- bookmarks/queries.py | 62 +++- bookmarks/styles/bookmark-details.css | 43 +-- bookmarks/styles/bookmark-page.css | 20 ++ bookmarks/styles/bundles.css | 34 +++ bookmarks/styles/components.css | 57 ++++ bookmarks/styles/layout.css | 12 - bookmarks/styles/theme-light.css | 1 + bookmarks/styles/theme/utilities.css | 8 + bookmarks/templates/bookmarks/archive.html | 12 +- .../templates/bookmarks/bookmark_list.html | 128 +++++---- .../templates/bookmarks/bundle_section.html | 23 ++ .../templates/bookmarks/details/assets.html | 10 +- bookmarks/templates/bookmarks/index.html | 12 +- bookmarks/templates/bookmarks/layout.html | 2 +- bookmarks/templates/bookmarks/pagination.html | 6 +- bookmarks/templates/bookmarks/shared.html | 9 +- .../templates/bookmarks/tag_section.html | 8 + bookmarks/templates/bundles/edit.html | 33 +++ bookmarks/templates/bundles/form.html | 91 ++++++ bookmarks/templates/bundles/index.html | 124 ++++++++ bookmarks/templates/bundles/new.html | 33 +++ bookmarks/templates/bundles/preview.html | 12 + bookmarks/templates/settings/general.html | 9 + bookmarks/templates/shared/messages.html | 9 + bookmarks/templatetags/pagination.py | 16 +- bookmarks/tests/helpers.py | 31 +- bookmarks/tests/test_bookmark_action_view.py | 40 +++ .../tests/test_bookmark_archived_view.py | 56 +++- .../tests/test_bookmark_details_modal.py | 14 +- bookmarks/tests/test_bookmark_index_view.py | 88 ++++++ bookmarks/tests/test_bookmark_search_form.py | 15 +- bookmarks/tests/test_bookmark_search_model.py | 130 ++++++++- bookmarks/tests/test_bookmark_search_tag.py | 2 +- bookmarks/tests/test_bookmark_shared_view.py | 57 ++++ bookmarks/tests/test_bookmarks_api.py | 28 ++ .../tests/test_bookmarks_list_template.py | 27 +- bookmarks/tests/test_bundles_edit_view.py | 122 ++++++++ bookmarks/tests/test_bundles_index_view.py | 198 +++++++++++++ bookmarks/tests/test_bundles_new_view.py | 77 +++++ bookmarks/tests/test_bundles_preview_view.py | 116 ++++++++ bookmarks/tests/test_pagination_tag.py | 42 ++- bookmarks/tests/test_queries.py | 266 +++++++++++++++++- bookmarks/tests/test_settings_general_view.py | 3 + bookmarks/tests/test_tag_cloud_template.py | 7 +- bookmarks/tests/test_toasts_view.py | 6 +- bookmarks/tests/test_user_select_tag.py | 2 +- .../tests_e2e/e2e_test_bundle_preview.py | 50 ++++ bookmarks/tests_e2e/helpers.py | 4 + bookmarks/urls.py | 6 + bookmarks/views/__init__.py | 1 + bookmarks/views/access.py | 9 +- bookmarks/views/bookmarks.py | 37 ++- bookmarks/views/bundles.py | 109 +++++++ bookmarks/views/contexts.py | 34 ++- bookmarks/views/partials.py | 22 +- 59 files changed, 2290 insertions(+), 267 deletions(-) create mode 100644 bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py create mode 100644 bookmarks/styles/bundles.css create mode 100644 bookmarks/templates/bookmarks/bundle_section.html create mode 100644 bookmarks/templates/bookmarks/tag_section.html create mode 100644 bookmarks/templates/bundles/edit.html create mode 100644 bookmarks/templates/bundles/form.html create mode 100644 bookmarks/templates/bundles/index.html create mode 100644 bookmarks/templates/bundles/new.html create mode 100644 bookmarks/templates/bundles/preview.html create mode 100644 bookmarks/templates/shared/messages.html create mode 100644 bookmarks/tests/test_bundles_edit_view.py create mode 100644 bookmarks/tests/test_bundles_index_view.py create mode 100644 bookmarks/tests/test_bundles_new_view.py create mode 100644 bookmarks/tests/test_bundles_preview_view.py create mode 100644 bookmarks/tests_e2e/e2e_test_bundle_preview.py create mode 100644 bookmarks/views/bundles.py diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py index eac5b5d..c63d15d 100644 --- a/bookmarks/api/routes.py +++ b/bookmarks/api/routes.py @@ -50,7 +50,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": diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte index 09553b4..8214eca 100644 --- a/bookmarks/frontend/components/TagAutocomplete.svelte +++ b/bookmarks/frontend/components/TagAutocomplete.svelte @@ -77,6 +77,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(); } @@ -128,41 +129,41 @@ diff --git a/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py b/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py new file mode 100644 index 0000000..7be682f --- /dev/null +++ b/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py @@ -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, + ), + ), + ], + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index d411c9a..375c0cd 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -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 @@ -157,6 +158,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,11 +193,21 @@ class BookmarkSearch: FILTER_UNREAD_YES = "yes" FILTER_UNREAD_NO = "no" - params = ["q", "user", "sort", "shared", "unread", "modified_since", "added_since"] + 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, @@ -187,19 +219,23 @@ class BookmarkSearch: 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"] @@ -232,7 +268,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): @@ -241,14 +284,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): @@ -271,6 +321,7 @@ 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) @@ -295,7 +346,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 @@ -416,6 +471,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: @@ -456,6 +512,7 @@ class UserProfileForm(forms.ModelForm): "items_per_page", "sticky_pagination", "collapse_side_panel", + "hide_bundles", ] diff --git a/bookmarks/queries.py b/bookmarks/queries.py index f661f9f..5337b0b 100644 --- a/bookmarks/queries.py +++ b/bookmarks/queries.py @@ -7,12 +7,21 @@ from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharFiel 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) @@ -36,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 @@ -102,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 diff --git a/bookmarks/styles/bookmark-details.css b/bookmarks/styles/bookmark-details.css index 5aa0215..0dad87d 100644 --- a/bookmarks/styles/bookmark-details.css +++ b/bookmarks/styles/bookmark-details.css @@ -49,50 +49,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 { diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css index f3f5d7d..e5e332a 100644 --- a/bookmarks/styles/bookmark-page.css +++ b/bookmarks/styles/bookmark-page.css @@ -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; diff --git a/bookmarks/styles/bundles.css b/bookmarks/styles/bundles.css new file mode 100644 index 0000000..97c24bc --- /dev/null +++ b/bookmarks/styles/bundles.css @@ -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; + } +} diff --git a/bookmarks/styles/components.css b/bookmarks/styles/components.css index 9f4c021..3904a37 100644 --- a/bookmarks/styles/components.css +++ b/bookmarks/styles/components.css @@ -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; + } + } +} diff --git a/bookmarks/styles/layout.css b/bookmarks/styles/layout.css index bc951d1..4e02435 100644 --- a/bookmarks/styles/layout.css +++ b/bookmarks/styles/layout.css @@ -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; - } -} diff --git a/bookmarks/styles/theme-light.css b/bookmarks/styles/theme-light.css index 57a5cd9..ac68d39 100644 --- a/bookmarks/styles/theme-light.css +++ b/bookmarks/styles/theme-light.css @@ -28,3 +28,4 @@ @import "markdown.css"; @import "reader-mode.css"; @import "settings.css"; +@import "bundles.css"; diff --git a/bookmarks/styles/theme/utilities.css b/bookmarks/styles/theme/utilities.css index bbdd0d2..91437f4 100644 --- a/bookmarks/styles/theme/utilities.css +++ b/bookmarks/styles/theme/utilities.css @@ -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; diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html index 96e6465..5e699d5 100644 --- a/bookmarks/templates/bookmarks/archive.html +++ b/bookmarks/templates/bookmarks/archive.html @@ -30,16 +30,10 @@ - {# Tag cloud #} + {# Filters #}
-
-
-

Tags

-
-
- {% include 'bookmarks/tag_cloud.html' %} -
-
+ {% include 'bookmarks/bundle_section.html' %} + {% include 'bookmarks/tag_section.html' %}
{% endblock %} diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index d23e649..2e38354 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -77,72 +77,76 @@ {% else %} {{ bookmark_item.display_date }} {% endif %} - | - {% endif %} - {# View link is visible for both owned and shared bookmarks #} - {% if bookmark_list.show_view_action %} - View - {% endif %} - {% if bookmark_item.is_editable %} - {# Bookmark owner actions #} - {% if bookmark_list.show_edit_action %} - Edit + {% if not bookmark_list.is_preview %} + | {% endif %} - {% if bookmark_list.show_archive_action %} - {% if bookmark_item.is_archived %} - - {% else %} - - {% endif %} - {% endif %} - {% if bookmark_list.show_remove_action %} - - {% endif %} - {% else %} - {# Shared bookmark actions #} - Shared by - {{ bookmark_item.owner.username }} - {% endif %} - {% if bookmark_item.has_extra_actions %} -
- | - {% if bookmark_item.show_mark_as_read %} - + {% else %} + + {% endif %} + {% endif %} + {% if bookmark_list.show_remove_action %} + {% endif %} - {% if bookmark_item.show_unshare %} - - {% endif %} - {% if bookmark_item.show_notes_button %} - - {% endif %} -
+ {% else %} + {# Shared bookmark actions #} + Shared by + {{ bookmark_item.owner.username }} + + {% endif %} + {% if bookmark_item.has_extra_actions %} +
+ | + {% if bookmark_item.show_mark_as_read %} + + {% endif %} + {% if bookmark_item.show_unshare %} + + {% endif %} + {% if bookmark_item.show_notes_button %} + + {% endif %} +
+ {% endif %} {% endif %} diff --git a/bookmarks/templates/bookmarks/bundle_section.html b/bookmarks/templates/bookmarks/bundle_section.html new file mode 100644 index 0000000..bd707f4 --- /dev/null +++ b/bookmarks/templates/bookmarks/bundle_section.html @@ -0,0 +1,23 @@ +{% if not request.user_profile.hide_bundles %} +
+
+

Bundles

+ + + + + + + +
+ +
+{% endif %} diff --git a/bookmarks/templates/bookmarks/details/assets.html b/bookmarks/templates/bookmarks/details/assets.html index a6a120a..3212e49 100644 --- a/bookmarks/templates/bookmarks/details/assets.html +++ b/bookmarks/templates/bookmarks/details/assets.html @@ -1,12 +1,12 @@
{% if details.assets %} -
+
{% for asset in details.assets %} -
-
+
+
{% include 'bookmarks/details/asset_icon.html' %}
-
+
{{ asset.display_name }} {% if asset.status == 'pending' %}(queued){% endif %} @@ -16,7 +16,7 @@ {{ asset.file_size|filesizeformat }} {% endif %}
-
+
{% if asset.file %} View {% endif %} diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html index 849aa26..c8cbd16 100644 --- a/bookmarks/templates/bookmarks/index.html +++ b/bookmarks/templates/bookmarks/index.html @@ -32,16 +32,10 @@ - {# Tag cloud #} + {# Filters #}
-
-
-

Tags

-
-
- {% include 'bookmarks/tag_cloud.html' %} -
-
+ {% include 'bookmarks/bundle_section.html' %} + {% include 'bookmarks/tag_section.html' %}
{% endblock %} diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html index 9508cf6..41ff112 100644 --- a/bookmarks/templates/bookmarks/layout.html +++ b/bookmarks/templates/bookmarks/layout.html @@ -67,7 +67,7 @@
{% if has_toasts %} -
+
{% csrf_token %} {% for toast in toast_messages %} diff --git a/bookmarks/templates/bookmarks/pagination.html b/bookmarks/templates/bookmarks/pagination.html index 62a6394..6af81cb 100644 --- a/bookmarks/templates/bookmarks/pagination.html +++ b/bookmarks/templates/bookmarks/pagination.html @@ -3,7 +3,7 @@
    {% if prev_link %}
  • - Previous + Previous
  • {% else %}
  • @@ -14,7 +14,7 @@ {% for page_link in page_links %} {% if page_link %}
  • - {{ page_link.number }} + {{ page_link.number }}
  • {% else %}
  • @@ -25,7 +25,7 @@ {% if next_link %}
  • - Next + Next
  • {% else %}
  • diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html index 88eab9e..c1d7764 100644 --- a/bookmarks/templates/bookmarks/shared.html +++ b/bookmarks/templates/bookmarks/shared.html @@ -38,14 +38,7 @@
-
-
-

Tags

-
-
- {% include 'bookmarks/tag_cloud.html' %} -
-
+ {% include 'bookmarks/tag_section.html' %}
{% endblock %} diff --git a/bookmarks/templates/bookmarks/tag_section.html b/bookmarks/templates/bookmarks/tag_section.html new file mode 100644 index 0000000..78558e0 --- /dev/null +++ b/bookmarks/templates/bookmarks/tag_section.html @@ -0,0 +1,8 @@ +
+
+

Tags

+
+
+ {% include 'bookmarks/tag_cloud.html' %} +
+
diff --git a/bookmarks/templates/bundles/edit.html b/bookmarks/templates/bundles/edit.html new file mode 100644 index 0000000..a67f855 --- /dev/null +++ b/bookmarks/templates/bundles/edit.html @@ -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 %} +
+
+
+

Edit bundle

+
+ + {% include 'shared/messages.html' %} + + + {% csrf_token %} + {% include 'bundles/form.html' %} + +
+ + +
+{% endblock %} diff --git a/bookmarks/templates/bundles/form.html b/bookmarks/templates/bundles/form.html new file mode 100644 index 0000000..bb119cd --- /dev/null +++ b/bookmarks/templates/bundles/form.html @@ -0,0 +1,91 @@ +{% load widget_tweaks %} + +
+ + {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} + {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} +
+ +
+ + {{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} + {% if form.search.errors %} +
+ {{ form.search.errors }} +
+ {% endif %} +
+ Search terms to match bookmarks in this bundle. +
+
+ +
+ + {{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} +
+ At least one of these tags must be present in a bookmark to match. +
+
+ +
+ + {{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} +
+ All of these tags must be present in a bookmark to match. +
+
+ +
+ + {{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} +
+ None of these tags must be present in a bookmark to match. +
+
+ + + + diff --git a/bookmarks/templates/bundles/index.html b/bookmarks/templates/bundles/index.html new file mode 100644 index 0000000..a8d1b5f --- /dev/null +++ b/bookmarks/templates/bundles/index.html @@ -0,0 +1,124 @@ +{% extends "bookmarks/layout.html" %} + +{% block head %} + {% with page_title="Bundles - Linkding" %} + {{ block.super }} + {% endwith %} +{% endblock %} + +{% block content %} +
+

Bundles

+ + {% include 'shared/messages.html' %} + + {% if bundles %} +
+ {% csrf_token %} +
+ {% for bundle in bundles %} +
+
+ + + + + + + + + +
+
+ {{ bundle.name }} +
+
+ Edit + +
+
+ {% endfor %} +
+ + +
+ {% else %} +
+

You have no bundles yet

+

Create your first bundle to get started

+
+ {% endif %} + + +
+ + +{% endblock %} diff --git a/bookmarks/templates/bundles/new.html b/bookmarks/templates/bundles/new.html new file mode 100644 index 0000000..a73b71c --- /dev/null +++ b/bookmarks/templates/bundles/new.html @@ -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 %} +
+
+
+

New bundle

+
+ + {% include 'shared/messages.html' %} + +
+ {% csrf_token %} + {% include 'bundles/form.html' %} +
+
+ + +
+{% endblock %} diff --git a/bookmarks/templates/bundles/preview.html b/bookmarks/templates/bundles/preview.html new file mode 100644 index 0000000..a7973fb --- /dev/null +++ b/bookmarks/templates/bundles/preview.html @@ -0,0 +1,12 @@ + + {% if bookmark_list.is_empty %} +
+ No bookmarks match the current bundle. +
+ {% else %} +
+ Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle. +
+ {% include 'bookmarks/bookmark_list.html' %} + {% endif %} +
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html index c8cfc6d..16800bf 100644 --- a/bookmarks/templates/settings/general.html +++ b/bookmarks/templates/settings/general.html @@ -139,6 +139,15 @@ Instead, the tags are shown in an expandable drawer.
+
+ +
+ Allows to hide the bundles in the side panel if you don't intend to use them. +
+
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }} diff --git a/bookmarks/templates/shared/messages.html b/bookmarks/templates/shared/messages.html new file mode 100644 index 0000000..fce07fc --- /dev/null +++ b/bookmarks/templates/shared/messages.html @@ -0,0 +1,9 @@ +{% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+{% endif %} diff --git a/bookmarks/templatetags/pagination.py b/bookmarks/templatetags/pagination.py index bb46e1e..9f0245e 100644 --- a/bookmarks/templatetags/pagination.py +++ b/bookmarks/templatetags/pagination.py @@ -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()}" diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index 5607287..7db175b 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -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, @@ -239,7 +266,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()) diff --git a/bookmarks/tests/test_bookmark_action_view.py b/bookmarks/tests/test_bookmark_action_view.py index ef523f7..29fd3a0 100644 --- a/bookmarks/tests/test_bookmark_action_view.py +++ b/bookmarks/tests/test_bookmark_action_view.py @@ -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() diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py index c871d8b..00b9c7b 100644 --- a/bookmarks/tests/test_bookmark_archived_view.py +++ b/bookmarks/tests/test_bookmark_archived_view.py @@ -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 = { @@ -515,3 +550,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('

Bundles

', 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('

Bundles

', html, count=0) diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py index c99c58d..8c69876 100644 --- a/bookmarks/tests/test_bookmark_details_modal.py +++ b/bookmarks/tests/test_bookmark_details_modal.py @@ -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): diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py index 8884e03..a82d014 100644 --- a/bookmarks/tests/test_bookmark_index_view.py +++ b/bookmarks/tests/test_bookmark_index_view.py @@ -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 = { @@ -494,3 +542,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('

Bundles

', 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('

Bundles

', html, count=0) diff --git a/bookmarks/tests/test_bookmark_search_form.py b/bookmarks/tests/test_bookmark_search_form.py index b516b6e..2143617 100644 --- a/bookmarks/tests/test_bookmark_search_form.py +++ b/bookmarks/tests/test_bookmark_search_form.py @@ -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 diff --git a/bookmarks/tests/test_bookmark_search_model.py b/bookmarks/tests/test_bookmark_search_model.py index 69caddf..a2bb8c7 100644 --- a/bookmarks/tests/test_bookmark_search_model.py +++ b/bookmarks/tests/test_bookmark_search_model.py @@ -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, { diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py index eb77675..3ab8a9a 100644 --- a/bookmarks/tests/test_bookmark_search_tag.py +++ b/bookmarks/tests/test_bookmark_search_tag.py @@ -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, { diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py index e7746c6..f512ccd 100644 --- a/bookmarks/tests/test_bookmark_shared_view.py +++ b/bookmarks/tests/test_bookmark_shared_view.py @@ -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) diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py index 68a4510..5f22d03 100644 --- a/bookmarks/tests/test_bookmarks_api.py +++ b/bookmarks/tests/test_bookmarks_api.py @@ -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) diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py index 802ae45..cf5d60d 100644 --- a/bookmarks/tests/test_bookmarks_list_template.py +++ b/bookmarks/tests/test_bookmarks_list_template.py @@ -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} - | """, 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) diff --git a/bookmarks/tests/test_bundles_edit_view.py b/bookmarks/tests/test_bundles_edit_view.py new file mode 100644 index 0000000..45e5e8a --- /dev/null +++ b/bookmarks/tests/test_bundles_edit_view.py @@ -0,0 +1,122 @@ +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'', + html, + ) + + self.assertInHTML( + f'', + html, + ) + + self.assertInHTML( + f'', + html, + ) + + self.assertInHTML( + f'', + html, + ) + + self.assertInHTML( + f'', + 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) diff --git a/bookmarks/tests/test_bundles_index_view.py b/bookmarks/tests/test_bundles_index_view.py new file mode 100644 index 0000000..57d19f3 --- /dev/null +++ b/bookmarks/tests/test_bundles_index_view.py @@ -0,0 +1,198 @@ +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""" +
+
+ + + + + + + + + +
+
+ {bundle.name} +
+
+ Edit + +
+
+ """ + + 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'{user_bundle.name}', 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('

You have no bundles yet

', html) + self.assertInHTML( + '

Create your first bundle to get started

', + 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'Add new bundle', + 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_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()) diff --git a/bookmarks/tests/test_bundles_new_view.py b/bookmarks/tests/test_bundles_new_view.py new file mode 100644 index 0000000..db39963 --- /dev/null +++ b/bookmarks/tests/test_bundles_new_view.py @@ -0,0 +1,77 @@ +from django.test import TestCase +from django.urls import reverse + +from bookmarks.models import BookmarkBundle +from bookmarks.tests.helpers import BookmarkFactoryMixin + + +class BundleNewViewTestCase(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_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) diff --git a/bookmarks/tests/test_bundles_preview_view.py b/bookmarks/tests/test_bundles_preview_view.py new file mode 100644 index 0000000..3eb6a0a --- /dev/null +++ b/bookmarks/tests/test_bundles_preview_view.py @@ -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) diff --git a/bookmarks/tests/test_pagination_tag.py b/bookmarks/tests/test_pagination_tag.py index 19ec728..ce4530d 100644 --- a/bookmarks/tests/test_pagination_tag.py +++ b/bookmarks/tests/test_pagination_tag.py @@ -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( """
  • @@ -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( """
  • @@ -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( """
  • @@ -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") diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py index 01e2d0c..7516aa7 100644 --- a/bookmarks/tests/test_queries.py +++ b/bookmarks/tests/test_queries.py @@ -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 @@ -1287,3 +1287,267 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin): 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]) diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py index a99a04a..ad250bb 100644 --- a/bookmarks/tests/test_settings_general_view.py +++ b/bookmarks/tests/test_settings_general_view.py @@ -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") diff --git a/bookmarks/tests/test_tag_cloud_template.py b/bookmarks/tests/test_tag_cloud_template.py index 04cad07..5736cc6 100644 --- a/bookmarks/tests/test_tag_cloud_template.py +++ b/bookmarks/tests/test_tag_cloud_template.py @@ -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) diff --git a/bookmarks/tests/test_toasts_view.py b/bookmarks/tests/test_toasts_view.py index 9d4d93a..4144c8d 100644 --- a/bookmarks/tests/test_toasts_view.py +++ b/bookmarks/tests/test_toasts_view.py @@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin): response = self.client.get(reverse("linkding:bookmarks.index")) # Should render toasts container - self.assertContains(response, '
    ') + self.assertContains(response, '
    ') # Should render two toasts self.assertContains(response, '
    ', 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, '
    ', count=0) + self.assertContains(response, '
    ', count=0) # Should not render toasts self.assertContains(response, '
    ', 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, '
    ', count=0) + self.assertContains(response, '
    ', count=0) # Should not render toasts self.assertContains(response, '
    ', count=0) diff --git a/bookmarks/tests/test_user_select_tag.py b/bookmarks/tests/test_user_select_tag.py index ed2980f..94b09c8 100644 --- a/bookmarks/tests/test_user_select_tag.py +++ b/bookmarks/tests/test_user_select_tag.py @@ -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, { diff --git a/bookmarks/tests_e2e/e2e_test_bundle_preview.py b/bookmarks/tests_e2e/e2e_test_bundle_preview.py new file mode 100644 index 0000000..44e5b6e --- /dev/null +++ b/bookmarks/tests_e2e/e2e_test_bundle_preview.py @@ -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() diff --git a/bookmarks/tests_e2e/helpers.py b/bookmarks/tests_e2e/helpers.py index 48ce248..cdfce86 100644 --- a/bookmarks/tests_e2e/helpers.py +++ b/bookmarks/tests_e2e/helpers.py @@ -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") diff --git a/bookmarks/urls.py b/bookmarks/urls.py index be0273a..ff963c9 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -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//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"), diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py index 802057b..397cda5 100644 --- a/bookmarks/views/__init__.py +++ b/bookmarks/views/__init__.py @@ -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 diff --git a/bookmarks/views/access.py b/bookmarks/views/access.py index a22070e..0228d4a 100644 --- a/bookmarks/views/access.py +++ b/bookmarks/views/access.py @@ -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,13 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str): raise Http404("Bookmark does not exist") +def bundle_write(request: HttpRequest, bundle_id: int | str): + try: + return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user) + except BookmarkBundle.DoesNotExist: + raise Http404("Bundle does not exist") + + def asset_read(request: HttpRequest, asset_id: int | str): try: asset = BookmarkAsset.objects.get(pk=asset_id) diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py index b19411f..7b4556c 100644 --- a/bookmarks/views/bookmarks.py +++ b/bookmarks/views/bookmarks.py @@ -42,8 +42,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 +58,7 @@ def index(request: HttpRequest): { "page_title": "Bookmarks - Linkding", "bookmark_list": bookmark_list, + "bundles": bundles, "tag_cloud": tag_cloud, "details": bookmark_details, }, @@ -65,8 +70,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 +86,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 +97,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 +145,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 +261,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 +278,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) diff --git a/bookmarks/views/bundles.py b/bookmarks/views/bundles.py new file mode 100644 index 0000000..062db8d --- /dev/null +++ b/bookmarks/views/bundles.py @@ -0,0 +1,109 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db.models import Max +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.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 + bundle.delete() + messages.success(request, f"Bundle '{bundle_name}' removed successfully.") + + elif "move_bundle" in request.POST: + bundle_id = request.POST.get("move_bundle") + move_position = int(request.POST.get("move_position")) + bundle_to_move = access.bundle_write(request, bundle_id) + user_bundles = list( + BookmarkBundle.objects.filter(owner=request.user).order_by("order") + ) + + if move_position != user_bundles.index(bundle_to_move): + user_bundles.remove(bundle_to_move) + user_bundles.insert(move_position, bundle_to_move) + for bundle_index, bundle in enumerate(user_bundles): + bundle.order = bundle_index + + BookmarkBundle.objects.bulk_update(user_bundles, ["order"]) + + 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 + form = BookmarkBundleForm(form_data, instance=bundle) + + if request.method == "POST": + if form.is_valid(): + instance = form.save(commit=False) + instance.owner = request.user + + if bundle is None: # New bundle + max_order_result = BookmarkBundle.objects.filter( + owner=request.user + ).aggregate(Max("order", default=-1)) + instance.order = max_order_result["order__max"] + 1 + + 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) + 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 +): + if request.method == "GET" and bundle: + preview_bundle = bundle + else: + form_data = ( + request.POST.copy() if request.method == "POST" else request.GET.copy() + ) + 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 diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py index 86aec9e..cace23f 100644 --- a/bookmarks/views/contexts.py +++ b/bookmarks/views/contexts.py @@ -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,7 @@ 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 @staticmethod def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None): @@ -315,14 +315,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) @@ -461,3 +459,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, + ) diff --git a/bookmarks/views/partials.py b/bookmarks/views/partials.py index 8930a66..34a4eed 100644 --- a/bookmarks/views/partials.py +++ b/bookmarks/views/partials.py @@ -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 )