Files
linkding/bookmarks/views/contexts.py
Sascha Ißbrücker 1672dc0152 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
2025-06-19 16:47:29 +02:00

482 lines
17 KiB
Python

import re
import urllib.parse
from typing import Set, List
from django.conf import settings
from django.core.paginator import Paginator
from django.db import models
from django.http import Http404
from django.urls import reverse
from bookmarks import queries
from bookmarks import utils
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
BookmarkSearch,
User,
UserProfile,
Tag,
)
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.type_defs import HttpRequest
from bookmarks.views import access
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
class RequestContext:
index_view = "linkding:bookmarks.index"
action_view = "linkding:bookmarks.index.action"
def __init__(self, request: HttpRequest):
self.request = request
self.index_url = reverse(self.index_view)
self.action_url = reverse(self.action_view)
self.query_params = request.GET.copy()
self.query_params.pop("details", None)
def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:
query_params = self.query_params.copy()
if add:
query_params.update(add)
if remove:
for key in remove:
query_params.pop(key, None)
encoded_params = query_params.urlencode()
return view_url + "?" + encoded_params if encoded_params else view_url
def index(self, add: dict = None, remove: dict = None) -> str:
return self.get_url(self.index_url, add=add, remove=remove)
def action(self, add: dict = None, remove: dict = None) -> str:
return self.get_url(self.action_url, add=add, remove=remove)
def details(self, bookmark_id: int) -> str:
return self.get_url(self.index_url, add={"details": bookmark_id})
def get_bookmark_query_set(self, search: BookmarkSearch):
raise NotImplementedError("Must be implemented by subclass")
def get_tag_query_set(self, search: BookmarkSearch):
raise NotImplementedError("Must be implemented by subclass")
class ActiveBookmarksContext(RequestContext):
index_view = "linkding:bookmarks.index"
action_view = "linkding:bookmarks.index.action"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_bookmarks(
self.request.user, self.request.user_profile, search
)
def get_tag_query_set(self, search: BookmarkSearch):
return queries.query_bookmark_tags(
self.request.user, self.request.user_profile, search
)
class ArchivedBookmarksContext(RequestContext):
index_view = "linkding:bookmarks.archived"
action_view = "linkding:bookmarks.archived.action"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmarks(
self.request.user, self.request.user_profile, search
)
def get_tag_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmark_tags(
self.request.user, self.request.user_profile, search
)
class SharedBookmarksContext(RequestContext):
index_view = "linkding:bookmarks.shared"
action_view = "linkding:bookmarks.shared.action"
def get_bookmark_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, search, public_only
)
def get_tag_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, search, public_only
)
class BookmarkItem:
def __init__(
self,
context: RequestContext,
bookmark: Bookmark,
user: User,
profile: UserProfile,
) -> None:
self.bookmark = bookmark
is_editable = bookmark.owner == user
self.is_editable = is_editable
self.id = bookmark.id
self.url = bookmark.url
self.title = bookmark.resolved_title
self.description = bookmark.resolved_description
self.notes = bookmark.notes
self.tag_names = bookmark.tag_names
if bookmark.latest_snapshot_id:
self.snapshot_url = reverse(
"linkding:assets.view", args=[bookmark.latest_snapshot_id]
)
self.snapshot_title = "View latest snapshot"
else:
self.snapshot_url = bookmark.web_archive_snapshot_url
self.snapshot_title = (
"View snapshot on the Internet Archive Wayback Machine"
)
if not self.snapshot_url:
self.snapshot_url = generate_fallback_webarchive_url(
bookmark.url, bookmark.date_added
)
self.favicon_file = bookmark.favicon_file
self.preview_image_file = bookmark.preview_image_file
self.is_archived = bookmark.is_archived
self.unread = bookmark.unread
self.owner = bookmark.owner
self.details_url = context.details(bookmark.id)
css_classes = []
if bookmark.unread:
css_classes.append("unread")
if bookmark.shared:
css_classes.append("shared")
self.css_classes = " ".join(css_classes)
if profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE:
self.display_date = utils.humanize_relative_date(bookmark.date_added)
elif (
profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
):
self.display_date = utils.humanize_absolute_date(bookmark.date_added)
self.show_notes_button = bookmark.notes and not profile.permanent_notes
self.show_mark_as_read = is_editable and bookmark.unread
self.show_unshare = is_editable and bookmark.shared and profile.enable_sharing
self.has_extra_actions = (
self.show_notes_button or self.show_mark_as_read or self.show_unshare
)
class BookmarkListContext:
request_context = RequestContext
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 = search
query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page")
paginator = Paginator(query_set, user_profile.items_per_page)
bookmarks_page = paginator.get_page(page_number)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
self.items = [
BookmarkItem(request_context, bookmark, user, user_profile)
for bookmark in bookmarks_page
]
self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count
self.return_url = request_context.index()
self.action_url = request_context.action()
self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display
self.description_display = user_profile.bookmark_description_display
self.description_max_lines = user_profile.bookmark_description_max_lines
self.show_url = user_profile.display_url
self.show_view_action = user_profile.display_view_bookmark_action
self.show_edit_action = user_profile.display_edit_bookmark_action
self.show_archive_action = user_profile.display_archive_bookmark_action
self.show_remove_action = user_profile.display_remove_bookmark_action
self.show_favicons = user_profile.enable_favicons
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):
query_params = search.query_params
if page is not None:
query_params["page"] = page
query_string = urllib.parse.urlencode(query_params)
return base_url if query_string == "" else base_url + "?" + query_string
@staticmethod
def generate_action_url(
search: BookmarkSearch, base_action_url: str, return_url: str
):
query_params = search.query_params
query_params["return_url"] = return_url
query_string = urllib.parse.urlencode(query_params)
return (
base_action_url
if query_string == ""
else base_action_url + "?" + query_string
)
class ActiveBookmarkListContext(BookmarkListContext):
request_context = ActiveBookmarksContext
class ArchivedBookmarkListContext(BookmarkListContext):
request_context = ArchivedBookmarksContext
class SharedBookmarkListContext(BookmarkListContext):
request_context = SharedBookmarksContext
class TagGroup:
def __init__(self, char: str):
self.tags = []
self.char = char
def __repr__(self):
return f"<{self.char} TagGroup>"
@staticmethod
def create_tag_groups(mode: str, tags: Set[Tag]):
if mode == UserProfile.TAG_GROUPING_ALPHABETICAL:
return TagGroup._create_tag_groups_alphabetical(tags)
elif mode == UserProfile.TAG_GROUPING_DISABLED:
return TagGroup._create_tag_groups_disabled(tags)
else:
raise ValueError(f"{mode} is not a valid tag grouping mode")
@staticmethod
def _create_tag_groups_alphabetical(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None
groups = []
cjk_used = False
cjk_group = TagGroup("Ideographic")
# Group tags that start with a different character than the previous one
for tag in sorted_tags:
tag_char = tag.name[0].lower()
if CJK_RE.match(tag_char):
cjk_used = True
cjk_group.tags.append(tag)
elif not group or group.char != tag_char:
group = TagGroup(tag_char)
groups.append(group)
group.tags.append(tag)
else:
group.tags.append(tag)
if cjk_used:
groups.append(cjk_group)
return groups
@staticmethod
def _create_tag_groups_disabled(tags: Set[Tag]):
if len(tags) == 0:
return []
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = TagGroup("Ungrouped")
for tag in sorted_tags:
group.tags.append(tag)
return [group]
class TagCloudContext:
request_context = RequestContext
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
request_context = self.request_context(request)
user_profile = request.user_profile
self.request = request
self.search = search
query_set = request_context.get_tag_query_set(self.search)
tags = list(query_set)
selected_tags = self.get_selected_tags(tags)
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = utils.unique(
selected_tags, key=lambda x: str.lower(x.name)
)
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(user_profile.tag_grouping, unselected_tags)
self.tags = unique_tags
self.groups = groups
self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags
def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.search.q)
tag_names = parsed_query["tag_names"]
if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query["search_terms"]
tag_names = [tag_name.lower() for tag_name in tag_names]
return [tag for tag in tags if tag.name.lower() in tag_names]
class ActiveTagCloudContext(TagCloudContext):
request_context = ActiveBookmarksContext
class ArchivedTagCloudContext(TagCloudContext):
request_context = ArchivedBookmarksContext
class SharedTagCloudContext(TagCloudContext):
request_context = SharedBookmarksContext
class BookmarkAssetItem:
def __init__(self, asset: BookmarkAsset):
self.asset = asset
self.id = asset.id
self.display_name = asset.display_name
self.asset_type = asset.asset_type
self.file = asset.file
self.file_size = asset.file_size
self.status = asset.status
icon_classes = []
text_classes = []
if asset.status == BookmarkAsset.STATUS_PENDING:
icon_classes.append("text-tertiary")
text_classes.append("text-tertiary")
elif asset.status == BookmarkAsset.STATUS_FAILURE:
icon_classes.append("text-error")
text_classes.append("text-error")
else:
icon_classes.append("icon-color")
self.icon_classes = " ".join(icon_classes)
self.text_classes = " ".join(text_classes)
class BookmarkDetailsContext:
request_context = RequestContext
def __init__(self, request: HttpRequest, bookmark: Bookmark):
request_context = self.request_context(request)
user = request.user
user_profile = request.user_profile
self.edit_return_url = request_context.details(bookmark.id)
self.action_url = request_context.action(add={"details": bookmark.id})
self.delete_url = request_context.action()
self.close_url = request_context.index()
self.bookmark = bookmark
self.profile = request.user_profile
self.is_editable = bookmark.owner == user
self.sharing_enabled = user_profile.enable_sharing
self.preview_image_enabled = user_profile.enable_preview_images
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
self.snapshots_enabled = settings.LD_ENABLE_SNAPSHOTS
self.uploads_enabled = not settings.LD_DISABLE_ASSET_UPLOAD
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
if not self.web_archive_snapshot_url:
self.web_archive_snapshot_url = generate_fallback_webarchive_url(
bookmark.url, bookmark.date_added
)
self.assets = [
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
]
self.has_pending_assets = any(
asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets
)
self.latest_snapshot = next(
(
asset
for asset in self.assets
if asset.asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
and asset.status == BookmarkAsset.STATUS_COMPLETE
),
None,
)
class ActiveBookmarkDetailsContext(BookmarkDetailsContext):
request_context = ActiveBookmarksContext
class ArchivedBookmarkDetailsContext(BookmarkDetailsContext):
request_context = ArchivedBookmarksContext
class SharedBookmarkDetailsContext(BookmarkDetailsContext):
request_context = SharedBookmarksContext
def get_details_context(
request: HttpRequest, context_type
) -> BookmarkDetailsContext | None:
bookmark_id = request.GET.get("details")
if not bookmark_id:
return None
try:
bookmark = access.bookmark_read(request, bookmark_id)
except Http404:
# just ignore, might end up in a situation where the bookmark was deleted
# in between navigating back and forth
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,
)