Create bundle from current search query (#1154)

This commit is contained in:
Sascha Ißbrücker
2025-08-10 22:45:28 +02:00
committed by GitHub
parent 1e56b0e6f3
commit cd215a9237
5 changed files with 160 additions and 17 deletions

View File

@@ -25,7 +25,7 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.section-header { .section-header:not(.no-wrap) {
flex-direction: column; flex-direction: column;
} }
} }

View File

@@ -1,16 +1,29 @@
{% if not request.user_profile.hide_bundles %} {% if not request.user_profile.hide_bundles %}
<section aria-labelledby="bundles-heading"> <section aria-labelledby="bundles-heading">
<div class="section-header"> <div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2> <h2 id="bundles-heading">Bundles</h2>
<a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles"> <div ld-dropdown class="dropdown dropdown-right ml-auto">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" <button class="btn dropdown-toggle" aria-label="Bundles menu">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/> <path d="M4 6l16 0"/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/> <path d="M4 12l16 0"/>
</svg> <path d="M4 18l16 0"/>
</a> </svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
</li>
{% if bookmark_list.search.q %}
<li class="menu-item">
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
bundle from search</a>
</li>
{% endif %}
</ul>
</div>
</div> </div>
<ul class="bundle-menu"> <ul class="bundle-menu">
{% for bundle in bundles.bundles %} {% for bundle in bundles.bundles %}

View File

@@ -120,3 +120,41 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_should_show_correct_preview(self):
bundle_tag = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[bundle_tag])
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
bundle = self.setup_bundle(name="Test Bundle", all_tags=bundle_tag.name)
response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)
def test_should_show_correct_preview_after_posting_invalid_data(self):
initial_tag = self.setup_tag(name="initial-tag")
updated_tag = self.setup_tag(name="updated-tag")
bookmark1 = self.setup_bookmark(tags=[initial_tag])
bookmark2 = self.setup_bookmark(tags=[updated_tag])
bookmark3 = self.setup_bookmark()
bundle = self.setup_bundle(name="Test Bundle", all_tags=initial_tag.name)
form_data = {
"name": "",
"search": "",
"any_tags": "",
"all_tags": updated_tag.name,
"excluded_tags": "",
}
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), form_data
)
self.assertIn(
"Found 1 bookmarks matching this bundle", response.content.decode()
)
self.assertNotIn(bookmark1.title, response.content.decode())
self.assertIn(bookmark2.title, response.content.decode())
self.assertNotIn(bookmark3.title, response.content.decode())

View File

@@ -1,11 +1,12 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from urllib.parse import urlencode
from bookmarks.models import BookmarkBundle 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: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -75,3 +76,72 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = self.create_form_data({"name": ""}) form_data = self.create_form_data({"name": ""})
response = self.client.post(reverse("linkding:bundles.new"), form_data) response = self.client.post(reverse("linkding:bundles.new"), form_data)
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
def test_should_prefill_form_from_search_query_parameters(self):
query = "machine learning #python #ai"
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "machine learning")
self.assertEqual(all_tags_field.get("value"), "python ai")
def test_should_ignore_special_search_commands(self):
query = "python tutorial !untagged !unread"
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "python tutorial")
self.assertIsNone(all_tags_field.get("value"))
def test_should_not_prefill_when_no_query_parameter(self):
response = self.client.get(reverse("linkding:bundles.new"))
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertIsNone(search_field.get("value"))
self.assertIsNone(all_tags_field.get("value"))
def test_should_not_prefill_when_editing_existing_bundle(self):
bundle = self.setup_bundle(
name="Existing Bundle", search="Tutorial", all_tags="java spring"
)
query = "machine learning #python #ai"
url = (
reverse("linkding:bundles.edit", args=[bundle.id])
+ "?"
+ urlencode({"q": query})
)
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "Tutorial")
self.assertEqual(all_tags_field.get("value"), "java spring")
def test_should_show_correct_preview_with_prefilled_values(self):
bundle_tag = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[bundle_tag])
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
query = "#" + bundle_tag.name
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)

View File

@@ -5,6 +5,7 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
from bookmarks.queries import parse_query_string
from bookmarks.services import bundles from bookmarks.services import bundles
from bookmarks.views import access from bookmarks.views import access
from bookmarks.views.contexts import ActiveBookmarkListContext from bookmarks.views.contexts import ActiveBookmarkListContext
@@ -37,7 +38,18 @@ def action(request: HttpRequest):
def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None): def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
form_data = request.POST if request.method == "POST" else 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 request.method == "POST":
if form.is_valid(): if form.is_valid():
@@ -53,8 +65,12 @@ def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = N
return HttpResponseRedirect(reverse("linkding:bundles.index")) return HttpResponseRedirect(reverse("linkding:bundles.index"))
status = 422 if request.method == "POST" and not form.is_valid() else 200 status = 422 if request.method == "POST" and not form.is_valid() else 200
bookmark_list = _get_bookmark_list_preview(request, bundle) bookmark_list = _get_bookmark_list_preview(request, bundle, initial_data)
context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list} context = {
"form": form,
"bundle": bundle,
"bookmark_list": bookmark_list,
}
return render(request, template, context, status=status) return render(request, template, context, status=status)
@@ -79,7 +95,9 @@ def preview(request: HttpRequest):
def _get_bookmark_list_preview( 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: if request.method == "GET" and bundle:
preview_bundle = bundle preview_bundle = bundle
@@ -87,6 +105,10 @@ def _get_bookmark_list_preview(
form_data = ( form_data = (
request.POST.copy() if request.method == "POST" else request.GET.copy() 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_data["name"] = "Preview Bundle" # Set dummy name for form validation
form = BookmarkBundleForm(form_data) form = BookmarkBundleForm(form_data)
preview_bundle = form.save(commit=False) preview_bundle = form.save(commit=False)