From 82e5b7d9d5ccefc8623146978f62fcdea863a1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Tue, 26 Aug 2025 12:01:36 +0200 Subject: [PATCH] Add basic tag management (#1175) --- bookmarks/forms.py | 98 +++++- bookmarks/frontend/behaviors/filter-drawer.js | 2 +- bookmarks/frontend/behaviors/form.js | 31 ++ bookmarks/styles/bookmark-page.css | 6 - bookmarks/styles/bundles.css | 15 +- bookmarks/styles/crud.css | 65 ++++ bookmarks/styles/tags.css | 6 + bookmarks/styles/theme-light.css | 2 + bookmarks/styles/theme/buttons.css | 6 + bookmarks/styles/theme/forms.css | 10 +- bookmarks/styles/theme/modals.css | 11 +- bookmarks/styles/theme/pagination.css | 5 + bookmarks/styles/theme/tables.css | 23 +- bookmarks/styles/theme/utilities.css | 38 +++ .../templates/bookmarks/bundle_section.html | 2 +- .../templates/bookmarks/details/modal.html | 2 +- .../templates/bookmarks/tag_section.html | 18 +- bookmarks/templates/bundles/index.html | 76 +++-- bookmarks/templates/settings/general.html | 22 +- bookmarks/templates/tags/edit.html | 23 ++ bookmarks/templates/tags/form.html | 19 ++ bookmarks/templates/tags/index.html | 125 ++++++++ bookmarks/templates/tags/merge.html | 68 +++++ bookmarks/templates/tags/new.html | 23 ++ bookmarks/tests/test_bundles_index_view.py | 42 +-- bookmarks/tests/test_tags_edit_view.py | 113 +++++++ bookmarks/tests/test_tags_index_view.py | 281 ++++++++++++++++++ bookmarks/tests/test_tags_merge_view.py | 219 ++++++++++++++ bookmarks/tests/test_tags_new_view.py | 79 +++++ bookmarks/urls.py | 5 + bookmarks/views/__init__.py | 1 + bookmarks/views/tags.py | 151 ++++++++++ 32 files changed, 1475 insertions(+), 112 deletions(-) create mode 100644 bookmarks/styles/crud.css create mode 100644 bookmarks/styles/tags.css create mode 100644 bookmarks/templates/tags/edit.html create mode 100644 bookmarks/templates/tags/form.html create mode 100644 bookmarks/templates/tags/index.html create mode 100644 bookmarks/templates/tags/merge.html create mode 100644 bookmarks/templates/tags/new.html create mode 100644 bookmarks/tests/test_tags_edit_view.py create mode 100644 bookmarks/tests/test_tags_index_view.py create mode 100644 bookmarks/tests/test_tags_merge_view.py create mode 100644 bookmarks/tests/test_tags_new_view.py create mode 100644 bookmarks/views/tags.py diff --git a/bookmarks/forms.py b/bookmarks/forms.py index 244fbbf..87b546b 100644 --- a/bookmarks/forms.py +++ b/bookmarks/forms.py @@ -1,11 +1,18 @@ from django import forms from django.forms.utils import ErrorList +from django.utils import timezone -from bookmarks.models import Bookmark, build_tag_string -from bookmarks.validators import BookmarkURLValidator -from bookmarks.type_defs import HttpRequest +from bookmarks.models import ( + Bookmark, + Tag, + build_tag_string, + parse_tag_string, + sanitize_tag_name, +) from bookmarks.services.bookmarks import create_bookmark, update_bookmark +from bookmarks.type_defs import HttpRequest from bookmarks.utils import normalize_url +from bookmarks.validators import BookmarkURLValidator class CustomErrorList(ErrorList): @@ -105,3 +112,88 @@ def convert_tag_string(tag_string: str): # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # strings return tag_string.replace(" ", ",") + + +class TagForm(forms.ModelForm): + class Meta: + model = Tag + fields = ["name"] + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs, error_class=CustomErrorList) + self.user = user + + def clean_name(self): + name = self.cleaned_data.get("name", "").strip() + + name = sanitize_tag_name(name) + + queryset = Tag.objects.filter(name__iexact=name, owner=self.user) + if self.instance.pk: + queryset = queryset.exclude(pk=self.instance.pk) + + if queryset.exists(): + raise forms.ValidationError(f'Tag "{name}" already exists.') + + return name + + def save(self, commit=True): + tag = super().save(commit=False) + if not self.instance.pk: + tag.owner = self.user + tag.date_added = timezone.now() + else: + tag.date_modified = timezone.now() + if commit: + tag.save() + return tag + + +class TagMergeForm(forms.Form): + target_tag = forms.CharField() + merge_tags = forms.CharField() + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs, error_class=CustomErrorList) + self.user = user + + def clean_target_tag(self): + target_tag_name = self.cleaned_data.get("target_tag", "") + + target_tag_names = parse_tag_string(target_tag_name, " ") + if len(target_tag_names) != 1: + raise forms.ValidationError( + "Please enter only one tag name for the target tag." + ) + + target_tag_name = target_tag_names[0] + + try: + target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user) + except Tag.DoesNotExist: + raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.') + + return target_tag + + def clean_merge_tags(self): + merge_tags_string = self.cleaned_data.get("merge_tags", "") + + merge_tag_names = parse_tag_string(merge_tags_string, " ") + if not merge_tag_names: + raise forms.ValidationError("Please enter at least one tag to merge.") + + merge_tags = [] + for tag_name in merge_tag_names: + try: + tag = Tag.objects.get(name__iexact=tag_name, owner=self.user) + merge_tags.append(tag) + except Tag.DoesNotExist: + raise forms.ValidationError(f'Tag "{tag_name}" does not exist.') + + target_tag = self.cleaned_data.get("target_tag") + if target_tag and target_tag in merge_tags: + raise forms.ValidationError( + "The target tag cannot be selected for merging." + ) + + return merge_tags diff --git a/bookmarks/frontend/behaviors/filter-drawer.js b/bookmarks/frontend/behaviors/filter-drawer.js index e5c340e..62c4413 100644 --- a/bookmarks/frontend/behaviors/filter-drawer.js +++ b/bookmarks/frontend/behaviors/filter-drawer.js @@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
{% include 'bookmarks/tag_cloud.html' %} diff --git a/bookmarks/templates/bundles/index.html b/bookmarks/templates/bundles/index.html index a8d1b5f..f42fe1e 100644 --- a/bookmarks/templates/bundles/index.html +++ b/bookmarks/templates/bundles/index.html @@ -7,41 +7,55 @@ {% endblock %} {% block content %} -
-

Bundles

+
+
+

Bundles

+ Add bundle +
{% include 'shared/messages.html' %} {% if bundles %}
{% csrf_token %} -
+ + + + + + + + {% for bundle in bundles %} -
-
- - - - - - - - - -
-
- {{ bundle.name }} -
-
+
+ + + {% endfor %} - + +
Name + Actions +
+
+ + + + + + + + + + {{ bundle.name }} +
+
Edit - - +
+ @@ -51,21 +65,17 @@

Create your first bundle to get started

{% endif %} - -