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.content_type = asset.content_type 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, )