From cd215a92374175090ffa947440173c476a201d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sun, 10 Aug 2025 22:45:28 +0200 Subject: [PATCH] Create bundle from current search query (#1154) --- bookmarks/styles/components.css | 2 +- .../templates/bookmarks/bundle_section.html | 33 ++++++--- bookmarks/tests/test_bundles_edit_view.py | 38 ++++++++++ bookmarks/tests/test_bundles_new_view.py | 74 ++++++++++++++++++- bookmarks/views/bundles.py | 30 +++++++- 5 files changed, 160 insertions(+), 17 deletions(-) diff --git a/bookmarks/styles/components.css b/bookmarks/styles/components.css index 3904a37..1289230 100644 --- a/bookmarks/styles/components.css +++ b/bookmarks/styles/components.css @@ -25,7 +25,7 @@ } @media (max-width: 600px) { - .section-header { + .section-header:not(.no-wrap) { flex-direction: column; } } diff --git a/bookmarks/templates/bookmarks/bundle_section.html b/bookmarks/templates/bookmarks/bundle_section.html index bd707f4..bfe8c60 100644 --- a/bookmarks/templates/bookmarks/bundle_section.html +++ b/bookmarks/templates/bookmarks/bundle_section.html @@ -1,16 +1,29 @@ {% if not request.user_profile.hide_bundles %}
-
+

Bundles

- - - - - - - +
    {% for bundle in bundles.bundles %} diff --git a/bookmarks/tests/test_bundles_edit_view.py b/bookmarks/tests/test_bundles_edit_view.py index 45e5e8a..2d19c09 100644 --- a/bookmarks/tests/test_bundles_edit_view.py +++ b/bookmarks/tests/test_bundles_edit_view.py @@ -120,3 +120,41 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin): reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data ) self.assertEqual(response.status_code, 404) + + def test_should_show_correct_preview(self): + bundle_tag = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[bundle_tag]) + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + bundle = self.setup_bundle(name="Test Bundle", all_tags=bundle_tag.name) + + response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id])) + self.assertContains(response, "Found 1 bookmarks matching this bundle") + self.assertContains(response, bookmark1.title) + self.assertNotContains(response, bookmark2.title) + self.assertNotContains(response, bookmark3.title) + + def test_should_show_correct_preview_after_posting_invalid_data(self): + initial_tag = self.setup_tag(name="initial-tag") + updated_tag = self.setup_tag(name="updated-tag") + bookmark1 = self.setup_bookmark(tags=[initial_tag]) + bookmark2 = self.setup_bookmark(tags=[updated_tag]) + bookmark3 = self.setup_bookmark() + bundle = self.setup_bundle(name="Test Bundle", all_tags=initial_tag.name) + + form_data = { + "name": "", + "search": "", + "any_tags": "", + "all_tags": updated_tag.name, + "excluded_tags": "", + } + response = self.client.post( + reverse("linkding:bundles.edit", args=[bundle.id]), form_data + ) + self.assertIn( + "Found 1 bookmarks matching this bundle", response.content.decode() + ) + self.assertNotIn(bookmark1.title, response.content.decode()) + self.assertIn(bookmark2.title, response.content.decode()) + self.assertNotIn(bookmark3.title, response.content.decode()) diff --git a/bookmarks/tests/test_bundles_new_view.py b/bookmarks/tests/test_bundles_new_view.py index db39963..f6c19f4 100644 --- a/bookmarks/tests/test_bundles_new_view.py +++ b/bookmarks/tests/test_bundles_new_view.py @@ -1,11 +1,12 @@ from django.test import TestCase from django.urls import reverse +from urllib.parse import urlencode from bookmarks.models import BookmarkBundle -from bookmarks.tests.helpers import BookmarkFactoryMixin +from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin -class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin): +class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): def setUp(self) -> None: user = self.get_or_create_test_user() @@ -75,3 +76,72 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin): form_data = self.create_form_data({"name": ""}) response = self.client.post(reverse("linkding:bundles.new"), form_data) self.assertEqual(response.status_code, 422) + + def test_should_prefill_form_from_search_query_parameters(self): + query = "machine learning #python #ai" + url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query}) + response = self.client.get(url) + + soup = self.make_soup(response.content.decode()) + search_field = soup.select_one('input[name="search"]') + all_tags_field = soup.select_one('input[name="all_tags"]') + + self.assertEqual(search_field.get("value"), "machine learning") + self.assertEqual(all_tags_field.get("value"), "python ai") + + def test_should_ignore_special_search_commands(self): + query = "python tutorial !untagged !unread" + url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query}) + response = self.client.get(url) + + soup = self.make_soup(response.content.decode()) + search_field = soup.select_one('input[name="search"]') + all_tags_field = soup.select_one('input[name="all_tags"]') + + self.assertEqual(search_field.get("value"), "python tutorial") + self.assertIsNone(all_tags_field.get("value")) + + def test_should_not_prefill_when_no_query_parameter(self): + response = self.client.get(reverse("linkding:bundles.new")) + + soup = self.make_soup(response.content.decode()) + search_field = soup.select_one('input[name="search"]') + all_tags_field = soup.select_one('input[name="all_tags"]') + + self.assertIsNone(search_field.get("value")) + self.assertIsNone(all_tags_field.get("value")) + + def test_should_not_prefill_when_editing_existing_bundle(self): + bundle = self.setup_bundle( + name="Existing Bundle", search="Tutorial", all_tags="java spring" + ) + + query = "machine learning #python #ai" + url = ( + reverse("linkding:bundles.edit", args=[bundle.id]) + + "?" + + urlencode({"q": query}) + ) + response = self.client.get(url) + + soup = self.make_soup(response.content.decode()) + search_field = soup.select_one('input[name="search"]') + all_tags_field = soup.select_one('input[name="all_tags"]') + + self.assertEqual(search_field.get("value"), "Tutorial") + self.assertEqual(all_tags_field.get("value"), "java spring") + + def test_should_show_correct_preview_with_prefilled_values(self): + bundle_tag = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[bundle_tag]) + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + query = "#" + bundle_tag.name + url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query}) + response = self.client.get(url) + + self.assertContains(response, "Found 1 bookmarks matching this bundle") + self.assertContains(response, bookmark1.title) + self.assertNotContains(response, bookmark2.title) + self.assertNotContains(response, bookmark3.title) diff --git a/bookmarks/views/bundles.py b/bookmarks/views/bundles.py index 52ae2a4..151a005 100644 --- a/bookmarks/views/bundles.py +++ b/bookmarks/views/bundles.py @@ -5,6 +5,7 @@ from django.shortcuts import render from django.urls import reverse from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch +from bookmarks.queries import parse_query_string from bookmarks.services import bundles from bookmarks.views import access from bookmarks.views.contexts import ActiveBookmarkListContext @@ -37,7 +38,18 @@ def action(request: HttpRequest): 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) + initial_data = {} + if bundle is None and request.method == "GET": + query_param = request.GET.get("q") + if query_param: + parsed = parse_query_string(query_param) + + if parsed["search_terms"]: + initial_data["search"] = " ".join(parsed["search_terms"]) + if parsed["tag_names"]: + initial_data["all_tags"] = " ".join(parsed["tag_names"]) + + form = BookmarkBundleForm(form_data, instance=bundle, initial=initial_data) if request.method == "POST": if form.is_valid(): @@ -53,8 +65,12 @@ def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = N 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} + bookmark_list = _get_bookmark_list_preview(request, bundle, initial_data) + context = { + "form": form, + "bundle": bundle, + "bookmark_list": bookmark_list, + } return render(request, template, context, status=status) @@ -79,7 +95,9 @@ def preview(request: HttpRequest): def _get_bookmark_list_preview( - request: HttpRequest, bundle: BookmarkBundle | None = None + request: HttpRequest, + bundle: BookmarkBundle | None = None, + initial_data: dict = None, ): if request.method == "GET" and bundle: preview_bundle = bundle @@ -87,6 +105,10 @@ def _get_bookmark_list_preview( form_data = ( request.POST.copy() if request.method == "POST" else request.GET.copy() ) + if initial_data: + for key, value in initial_data.items(): + form_data[key] = value + form_data["name"] = "Preview Bundle" # Set dummy name for form validation form = BookmarkBundleForm(form_data) preview_bundle = form.save(commit=False)