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
This commit is contained in:
Sascha Ißbrücker
2025-06-19 16:47:29 +02:00
committed by GitHub
parent 8be72a5d1f
commit 1672dc0152
59 changed files with 2290 additions and 267 deletions

View File

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

View File

@@ -1,6 +1,6 @@
from django.http import Http404
from bookmarks.models import Bookmark, BookmarkAsset, Toast
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
from bookmarks.type_defs import HttpRequest
@@ -32,6 +32,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)

View File

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

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

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

View File

@@ -13,6 +13,7 @@ from bookmarks import utils
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
BookmarkSearch,
User,
UserProfile,
@@ -178,15 +179,13 @@ class BookmarkItem:
class BookmarkListContext:
request_context = RequestContext
def __init__(self, request: HttpRequest) -> None:
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
request_context = self.request_context(request)
user = request.user
user_profile = request.user_profile
self.request = request
self.search = BookmarkSearch.from_request(
self.request.GET, user_profile.search_preferences
)
self.search = search
query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page")
@@ -219,6 +218,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,
)

View File

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