Compare commits

..

14 Commits

Author SHA1 Message Date
Sascha Ißbrücker
30708cc5e3 Bump version 2023-10-01 22:05:02 +02:00
Sascha Ißbrücker
3e4f08f51b Add user profile endpoint (#541)
* feat: Implement UserProfile serializer and add API endpoint per #457

* chore: Document API addition

* Address review comments

---------

Co-authored-by: fkulla <mail@florian.direct>
2023-10-01 21:57:32 +02:00
Sascha Ißbrücker
41f79e35a0 Allow saving search preferences (#540)
* Add indicator for modified filters

* Rename shared filter values

* Add update search preferences handler

* Separate search and preferences forms

* Properly initialize bookmark search from get or post

* Add tests for applying search preferences

* Implement saving search preferences

* Remove bookmark search query alias

* Use search preferences as default

* Only show save button for authenticated users

* Only show modified indicator if preferences are modified

* Fix overriding search preferences

* Add missing migration
2023-10-01 21:22:44 +02:00
Sascha Ißbrücker
4a2642f16c Update CHANGELOG.md 2023-09-26 09:24:49 +02:00
Sascha Ißbrücker
e70315ed26 Test that bookmark actions URL is encoded 2023-09-26 08:34:43 +02:00
Sascha Ißbrücker
3e36f90b38 Add filter for unread state (#535) 2023-09-16 10:39:27 +02:00
Sascha Ißbrücker
28acf3299c Add support for exporting/importing bookmark notes (#532) 2023-09-10 23:37:37 +02:00
Sascha Ißbrücker
ffcc40b227 Add filter for shared state (#531)
* Add shared filter to bookmark search model

* Add shared filter UI

* Implement shared filter

* Add API test

* Use radio buttons

* Rename shared parameter

* Improve radio button CSS
2023-09-10 22:14:07 +02:00
Sascha Ißbrücker
b7ddee2d93 Make code blocks in notes scrollable (#530) 2023-09-10 10:24:34 +02:00
Sascha Ißbrücker
d9c4ddb4d7 Add button to show tags on smaller screens (#529)
* Implement tag modal

* Improve header controls responsiveness

* Improve modal styles

* Cleanup
2023-09-10 08:44:49 +02:00
Sascha Ißbrücker
0975914a86 Add sort option to bookmark list (#522)
* Rename BookmarkFilters to BookmarkSearch

* Refactor queries to accept BookmarkSearch

* Sort query by data added and title

* Ensure pagination respects search parameters

* Ensure tag cloud respects search parameters

* Ensure user select respects search parameters

* Ensure return url respects search options

* Fix passing search options to user select

* Fix BookmarkSearch initialization

* Extract common search form logic

* Ensure partial update respects search options

* Add sort UI

* Use custom ICU collation when sorting with SQLite

* Support sort in API
2023-09-01 22:48:21 +02:00
Sascha Ißbrücker
0c50906056 Fix case-insensitive search for unicode characters in SQLite (#520) 2023-08-27 15:41:23 +02:00
Sascha Ißbrücker
54c79225ce Add script for running dev server with postgres 2023-08-27 15:30:51 +02:00
Sascha Ißbrücker
a382e171ad Update CHANGELOG.md 2023-08-25 16:56:52 +02:00
57 changed files with 2724 additions and 663 deletions

View File

@@ -1,5 +1,39 @@
# Changelog
## v1.21.1 (26/09/2023)
### What's Changed
* Fix bulk edit to respect searched tags by @sissbruecker in https://github.com/sissbruecker/linkding/pull/537
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.21.1
---
## v1.21.0 (25/08/2023)
### What's Changed
* Make search autocomplete respect link target setting by @sissbruecker in https://github.com/sissbruecker/linkding/pull/513
* Various CSS improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/514
* Display shared state in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/515
* Allow bulk editing unread and shared state of bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/517
* Bump uwsgi from 2.0.20 to 2.0.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/516
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.1...v1.21.0
---
## v1.20.1 (23/08/2023)
### What's Changed
* Update cached styles and scripts after version change by @sissbruecker in https://github.com/sissbruecker/linkding/pull/510
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.0...v1.20.1
---
## v1.20.0 (22/08/2023)
### What's Changed

View File

@@ -33,13 +33,39 @@ RUN mkdir /opt/venv && \
/opt/venv/bin/pip install -Ur requirements.txt
FROM python-base AS compile-icu
RUN apt-get update && apt-get -y install libicu-dev libsqlite3-dev wget unzip
WORKDIR /etc/linkding
# Defines SQLite version
# Since this is only needed for downloading the header files this probably
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
# extension do not change
ARG SQLITE_RELEASE_YEAR=2023
ARG SQLITE_RELEASE=3430000
# Compile the ICU extension needed for case-insensitive search and ordering
# with SQLite. This does:
# - Download SQLite amalgamation for header files
# - Download ICU extension source file
# - Compile ICU extension
RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQLITE_RELEASE}.zip && \
unzip sqlite-amalgamation-${SQLITE_RELEASE}.zip && \
cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3.h ./sqlite3.h && \
cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3ext.h ./sqlite3ext.h && \
wget https://www.sqlite.org/src/raw/ext/icu/icu.c?name=91c021c7e3e8bbba286960810fa303295c622e323567b2e6def4ce58e4466e60 -O icu.c && \
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support libpq-dev curl
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev curl
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv
# copy output from build stage
COPY --from=python-build /etc/linkding/static static/
# copy compiled icu extension
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
# copy application code
COPY . .
# Expose uwsgi server at port 9090

View File

@@ -5,8 +5,8 @@ from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer, UserProfileSerializer
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
from bookmarks.services.website_loader import WebsiteMetadata
@@ -34,8 +34,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
user = self.request.user
# For list action, use query set that applies search and tag projections
if self.action == 'list':
query_string = self.request.GET.get('q')
return queries.query_bookmarks(user, user.profile, query_string)
search = BookmarkSearch.from_request(self.request.GET)
return queries.query_bookmarks(user, user.profile, search)
# For single entity actions use default query set without projections
return Bookmark.objects.all().filter(owner=user)
@@ -46,8 +46,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
@action(methods=['get'], detail=False)
def archived(self, request):
user = request.user
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(user, user.profile, query_string)
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
@@ -55,10 +55,10 @@ class BookmarkViewSet(viewsets.GenericViewSet,
@action(methods=['get'], detail=False)
def shared(self, request):
filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first()
search = BookmarkSearch.from_request(request.GET)
user = User.objects.filter(username=search.user).first()
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only)
query_set = queries.query_shared_bookmarks(user, request.user_profile, search, public_only)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
@@ -108,6 +108,13 @@ class TagViewSet(viewsets.GenericViewSet,
return {'user': self.request.user}
class UserViewSet(viewsets.GenericViewSet):
@action(methods=['get'], detail=False)
def profile(self, request):
return Response(UserProfileSerializer(request.user.profile).data)
router = DefaultRouter()
router.register(r'bookmarks', BookmarkViewSet, basename='bookmark')
router.register(r'tags', TagViewSet, basename='tag')
router.register(r'user', UserViewSet, basename='user')

View File

@@ -2,7 +2,7 @@ from django.db.models import prefetch_related_objects
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.services.tags import get_or_create_tag
@@ -89,3 +89,21 @@ class TagSerializer(serializers.ModelSerializer):
def create(self, validated_data):
return get_or_create_tag(validated_data['name'], self.context['user'])
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = [
"theme",
"bookmark_date_display",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"display_url",
"permanent_notes",
"search_preferences",
]

View File

@@ -1,5 +1,5 @@
from bookmarks import queries
from bookmarks.models import Toast
from bookmarks.models import BookmarkSearch, Toast
from bookmarks import utils
@@ -17,7 +17,7 @@ def toasts(request):
def public_shares(request):
# Only check for public shares for anonymous users
if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(None, request.user_profile, '', True)
query_set = queries.query_shared_bookmarks(None, request.user_profile, BookmarkSearch(), True)
has_public_shares = query_set.count() > 0
return {
'has_public_shares': has_public_shares,

View File

@@ -50,6 +50,21 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bookmark('foo 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['foo 1', 'foo 3', 'foo 4', 'foo 5'])
def test_partial_update_respects_sort(self):
self.setup_numbered_bookmarks(5, prefix='foo')
with sync_playwright() as p:
url = reverse('bookmarks:index') + '?sort=title_asc'
page = self.open(url, p)
first_item = page.locator('li[ld-bookmark-item]').first
expect(first_item).to_contain_text('foo 1')
first_item.get_by_text('Archive').click()
first_item = page.locator('li[ld-bookmark-item]').first
expect(first_item).to_contain_text('foo 2')
def test_partial_update_respects_page(self):
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix='foo', suffix='-')

View File

@@ -4,7 +4,7 @@ from django.contrib.syndication.views import Feed
from django.db.models import QuerySet
from django.urls import reverse
from bookmarks.models import Bookmark, FeedToken
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
from bookmarks import queries
@@ -17,8 +17,8 @@ class FeedContext:
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
query_string = request.GET.get('q')
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string)
search = BookmarkSearch(q=request.GET.get('q', ''))
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
return FeedContext(feed_token, query_set)
def item_title(self, item: Bookmark):

View File

@@ -3,10 +3,10 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
listBookmarks(filters, options = { limit: 100, offset: 0, path: "" }) {
listBookmarks(search, options = { limit: 100, offset: 0, path: "" }) {
const query = [`limit=${options.limit}`, `offset=${options.offset}`];
Object.keys(filters).forEach((key) => {
const value = filters[key];
Object.keys(search).forEach((key) => {
const value = search[key];
if (value) {
query.push(`${key}=${encodeURIComponent(value)}`);
}

View File

@@ -0,0 +1,65 @@
import { registerBehavior } from "./index";
class ModalBehavior {
constructor(element) {
const toggle = element;
toggle.addEventListener("click", this.onToggleClick.bind(this));
this.toggle = toggle;
}
onToggleClick() {
const contentSelector = this.toggle.getAttribute("modal-content");
const content = document.querySelector(contentSelector);
if (!content) {
return;
}
// Create modal
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="btn btn-link close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
// Teleport content element
const contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;
// Register close handlers
const modalOverlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".btn.close");
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
this.modal = modal;
}
onClose() {
// Teleport content back
this.contentOwner.append(this.content);
// Remove modal
this.modal.remove();
}
}
registerBehavior("ld-modal", ModalBehavior);

View File

@@ -10,7 +10,7 @@
export let tags;
export let mode = '';
export let apiClient;
export let filters;
export let search;
export let linkTarget = '_blank';
let isFocus = false;
@@ -103,7 +103,7 @@
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
@@ -115,11 +115,11 @@
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionFilters = {
...filters,
const suggestionSearch = {
...search,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
@@ -132,7 +132,7 @@
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
@@ -143,17 +143,17 @@
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...recentSearches,
...bookmarks,
]
}
@@ -215,10 +215,10 @@
</li>
{/each}
{#if suggestions.search.length > 0}
{#if suggestions.recentSearches.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
{#each suggestions.recentSearches as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}

View File

@@ -4,6 +4,7 @@ import { ApiClient } from "./api";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/modal";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";

View File

@@ -11,7 +11,7 @@ class Command(BaseCommand):
help = "Enable WAL journal mode when using an SQLite database"
def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
if not settings.USE_SQLITE:
return
connection = connections['default']

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-09-30 10:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0024_userprofile_enable_public_sharing'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='search_preferences',
field=models.JSONField(default=dict),
),
]

View File

@@ -5,10 +5,10 @@ from typing import List
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.core.handlers.wsgi import WSGIRequest
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.http import QueryDict
from bookmarks.utils import unique
from bookmarks.validators import BookmarkURLValidator
@@ -124,10 +124,130 @@ class BookmarkForm(forms.ModelForm):
return self.instance and self.instance.notes
class BookmarkFilters:
def __init__(self, request: WSGIRequest):
self.query = request.GET.get('q') or ''
self.user = request.GET.get('user') or ''
class BookmarkSearch:
SORT_ADDED_ASC = 'added_asc'
SORT_ADDED_DESC = 'added_desc'
SORT_TITLE_ASC = 'title_asc'
SORT_TITLE_DESC = 'title_desc'
FILTER_SHARED_OFF = 'off'
FILTER_SHARED_SHARED = 'yes'
FILTER_SHARED_UNSHARED = 'no'
FILTER_UNREAD_OFF = 'off'
FILTER_UNREAD_YES = 'yes'
FILTER_UNREAD_NO = 'no'
params = ['q', 'user', 'sort', 'shared', 'unread']
preferences = ['sort', 'shared', 'unread']
defaults = {
'q': '',
'user': '',
'sort': SORT_ADDED_DESC,
'shared': FILTER_SHARED_OFF,
'unread': FILTER_UNREAD_OFF,
}
def __init__(self,
q: str = None,
user: str = None,
sort: str = None,
shared: str = None,
unread: str = None,
preferences: dict = None):
if not preferences:
preferences = {}
self.defaults = {**BookmarkSearch.defaults, **preferences}
self.q = q or self.defaults['q']
self.user = user or self.defaults['user']
self.sort = sort or self.defaults['sort']
self.shared = shared or self.defaults['shared']
self.unread = unread or self.defaults['unread']
def is_modified(self, param):
value = self.__dict__[param]
return value != self.defaults[param]
@property
def modified_params(self):
return [field for field in self.params if self.is_modified(field)]
@property
def modified_preferences(self):
return [preference for preference in self.preferences if self.is_modified(preference)]
@property
def has_modifications(self):
return len(self.modified_params) > 0
@property
def has_modified_preferences(self):
return len(self.modified_preferences) > 0
@property
def query_params(self):
return {param: self.__dict__[param] for param in self.modified_params}
@property
def preferences_dict(self):
return {preference: self.__dict__[preference] for preference in self.preferences}
@staticmethod
def from_request(query_dict: QueryDict, preferences: dict = None):
initial_values = {}
for param in BookmarkSearch.params:
value = query_dict.get(param)
if value:
initial_values[param] = value
return BookmarkSearch(**initial_values, preferences=preferences)
class BookmarkSearchForm(forms.Form):
SORT_CHOICES = [
(BookmarkSearch.SORT_ADDED_ASC, 'Added ↑'),
(BookmarkSearch.SORT_ADDED_DESC, 'Added ↓'),
(BookmarkSearch.SORT_TITLE_ASC, 'Title ↑'),
(BookmarkSearch.SORT_TITLE_DESC, 'Title ↓'),
]
FILTER_SHARED_CHOICES = [
(BookmarkSearch.FILTER_SHARED_OFF, 'Off'),
(BookmarkSearch.FILTER_SHARED_SHARED, 'Shared'),
(BookmarkSearch.FILTER_SHARED_UNSHARED, 'Unshared'),
]
FILTER_UNREAD_CHOICES = [
(BookmarkSearch.FILTER_UNREAD_OFF, 'Off'),
(BookmarkSearch.FILTER_UNREAD_YES, 'Unread'),
(BookmarkSearch.FILTER_UNREAD_NO, 'Read'),
]
q = forms.CharField()
user = forms.ChoiceField()
sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
def __init__(self, search: BookmarkSearch, editable_fields: List[str] = None, users: List[User] = None):
super().__init__()
editable_fields = editable_fields or []
self.editable_fields = editable_fields
# set choices for user field if users are provided
if users:
user_choices = [(user.username, user.username) for user in users]
user_choices.insert(0, ('', 'Everyone'))
self.fields['user'].choices = user_choices
for param in search.params:
# set initial values for modified params
self.fields[param].initial = search.__dict__[param]
# Mark non-editable modified fields as hidden. That way, templates
# rendering a form can just loop over hidden_fields to ensure that
# all necessary search options are kept when submitting the form.
if search.is_modified(param) and param not in editable_fields:
self.fields[param].widget = forms.HiddenInput()
class UserProfile(models.Model):
@@ -180,6 +300,7 @@ class UserProfile(models.Model):
enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
search_preferences = models.JSONField(default=dict, null=False)
class UserProfileForm(forms.ModelForm):

View File

@@ -1,32 +1,35 @@
from typing import Optional
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q, QuerySet, Exists, OuterRef
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
from django.db.models.expressions import RawSQL
from django.db.models.functions import Lower
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.utils import unique
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
def query_bookmarks(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
return _base_bookmarks_query(user, profile, search) \
.filter(is_archived=False)
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
def query_archived_bookmarks(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
return _base_bookmarks_query(user, profile, search) \
.filter(is_archived=True)
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str,
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, search: BookmarkSearch,
public_only: bool) -> QuerySet:
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
if public_only:
conditions = conditions & Q(owner__profile__enable_public_sharing=True)
return _base_bookmarks_query(user, profile, query_string).filter(conditions)
return _base_bookmarks_query(user, profile, search).filter(conditions)
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: BookmarkSearch) -> QuerySet:
query_set = Bookmark.objects
# Filter for user
@@ -34,7 +37,7 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_stri
query_set = query_set.filter(owner=user)
# Split query into search terms and tags
query = parse_query_string(query_string)
query = parse_query_string(search.q)
# Filter for search terms and tags
for term in query['search_terms']:
@@ -60,45 +63,85 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_stri
query_set = query_set.filter(
tags=None
)
# Unread bookmarks
# Legacy unread bookmarks filter from query
if query['unread']:
query_set = query_set.filter(
unread=True
)
# Unread filter from bookmark search
if search.unread == BookmarkSearch.FILTER_UNREAD_YES:
query_set = query_set.filter(unread=True)
elif search.unread == BookmarkSearch.FILTER_UNREAD_NO:
query_set = query_set.filter(unread=False)
# Shared filter
if search.shared == BookmarkSearch.FILTER_SHARED_SHARED:
query_set = query_set.filter(shared=True)
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
query_set = query_set.filter(shared=False)
# Sort by date added
query_set = query_set.order_by('-date_added')
if search.sort == BookmarkSearch.SORT_ADDED_ASC:
query_set = query_set.order_by('date_added')
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
query_set = query_set.order_by('-date_added')
# Sort by title
if search.sort == BookmarkSearch.SORT_TITLE_ASC or search.sort == BookmarkSearch.SORT_TITLE_DESC:
# For the title, the resolved_title logic from the Bookmark entity needs
# to be replicated as there is no corresponding database field
query_set = query_set.annotate(
effective_title=Case(
When(Q(title__isnull=False) & ~Q(title__exact=''), then=Lower('title')),
When(Q(website_title__isnull=False) & ~Q(website_title__exact=''), then=Lower('website_title')),
default=Lower('url'),
output_field=CharField()
))
# For SQLite, if the ICU extension is loaded, use the custom collation
# loaded into the connection. This results in an improved sort order for
# unicode characters (umlauts, etc.)
if settings.USE_SQLITE and settings.USE_SQLITE_ICU_EXTENSION:
order_field = RawSQL('effective_title COLLATE ICU', ())
else:
order_field = 'effective_title'
if search.sort == BookmarkSearch.SORT_TITLE_ASC:
query_set = query_set.order_by(order_field)
elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
query_set = query_set.order_by(order_field).reverse()
return query_set
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_bookmarks(user, profile, query_string)
def query_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
bookmarks_query = query_bookmarks(user, profile, search)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, profile, query_string)
def query_archived_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, profile, search)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str,
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, search: BookmarkSearch,
public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string, public_only)
bookmarks_query = query_shared_bookmarks(user, profile, search, public_only)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_shared_bookmark_users(profile: UserProfile, query_string: str, public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string, public_only)
def query_shared_bookmark_users(profile: UserProfile, search: BookmarkSearch, public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, search, public_only)
query_set = User.objects.filter(bookmark__in=bookmarks_query)

View File

@@ -31,12 +31,15 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
url = bookmark.url
title = html.escape(bookmark.resolved_title or '')
desc = html.escape(bookmark.resolved_description or '')
if bookmark.notes:
desc += f'[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]'
tags = ','.join(bookmark.tag_names)
toread = '1' if bookmark.unread else '0'
private = '0' if bookmark.shared else '1'
added = int(bookmark.date_added.timestamp())
doc.append(f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
doc.append(
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
if desc:
doc.append(f'<DD>{desc}')

View File

@@ -168,6 +168,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
'shared',
'title',
'description',
'notes',
'owner'])
# Bulk insert new bookmarks into DB
Bookmark.objects.bulk_create(bookmarks_to_create)
@@ -214,5 +215,7 @@ def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark,
bookmark.title = netscape_bookmark.title
if netscape_bookmark.description:
bookmark.description = netscape_bookmark.description
if netscape_bookmark.notes:
bookmark.notes = netscape_bookmark.notes
if options.map_private_flag and not netscape_bookmark.private:
bookmark.shared = True

View File

@@ -8,6 +8,7 @@ class NetscapeBookmark:
href: str
title: str
description: str
notes: str
date_added: str
tag_string: str
to_read: bool
@@ -26,6 +27,7 @@ class BookmarkParser(HTMLParser):
self.tags = ''
self.title = ''
self.description = ''
self.notes = ''
self.toread = ''
self.private = ''
@@ -58,6 +60,7 @@ class BookmarkParser(HTMLParser):
href=self.href,
title='',
description='',
notes='',
date_added=self.add_date,
tag_string=self.tags,
to_read=self.toread == '1',
@@ -69,12 +72,16 @@ class BookmarkParser(HTMLParser):
self.title = data.strip()
def handle_dd_data(self, data):
self.description = data.strip()
desc = data.strip()
if '[linkding-notes]' in desc:
self.notes = desc.split('[linkding-notes]')[1].split('[/linkding-notes]')[0]
self.description = desc.split('[linkding-notes]')[0]
def add_bookmark(self):
if self.bookmark:
self.bookmark.title = self.title
self.bookmark.description = self.description
self.bookmark.notes = self.notes
self.bookmarks.append(self.bookmark)
self.bookmark = None
self.href = ''
@@ -82,6 +89,7 @@ class BookmarkParser(HTMLParser):
self.tags = ''
self.title = ''
self.description = ''
self.notes = ''
self.toread = ''
self.private = ''

View File

@@ -1,8 +1,27 @@
from django.conf import settings
from django.contrib.auth import user_logged_in
from django.db.backends.signals import connection_created
from django.dispatch import receiver
from bookmarks.services import tasks
@receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs):
tasks.schedule_bookmarks_without_snapshots(user)
@receiver(connection_created)
def extend_sqlite(connection=None, **kwargs):
# Load ICU extension into Sqlite connection to support case-insensitive
# comparisons with unicode characters
if connection.vendor == 'sqlite' and settings.USE_SQLITE_ICU_EXTENSION:
connection.connection.enable_load_extension(True)
connection.connection.load_extension(settings.SQLITE_ICU_EXTENSION_PATH.rstrip('.so'))
with connection.cursor() as cursor:
# Load an ICU collation for case-insensitive ordering.
# The first param can be a specific locale, it seems that not
# providing one will use a default collation from the ICU project
# that works reasonably for multiple languages
cursor.execute("SELECT icu_load_collation('', 'ICU');")

View File

@@ -50,14 +50,20 @@ section.content-area {
border-bottom: solid 1px $border-color;
display: flex;
flex-wrap: wrap;
column-gap: $unit-6;
padding-bottom: $unit-2;
margin-bottom: $unit-4;
h2 {
flex: 0 0 auto;
line-height: 1.8rem;
margin-right: auto;
margin-bottom: 0;
}
.header-controls {
flex: 1 1 0;
display: flex;
}
}
}

View File

@@ -2,31 +2,36 @@
grid-gap: $unit-10;
}
/* Bookmark search box */
.bookmarks-page .search {
$searchbox-width: 180px;
$searchbox-width-md: 300px;
$searchbox-height: 1.8rem;
/* Bookmark area header controls */
.bookmarks-page .content-area-header {
--searchbox-max-width: 350px;
--searchbox-height: 1.8rem;
@media (max-width: $size-sm) {
--searchbox-max-width: initial;
flex-direction: column;
}
}
.bookmarks-page .search-container {
flex: 1 1 0;
display: flex;
justify-content: flex-end;
// Regular input
input[type='search'] {
width: $searchbox-width;
height: $searchbox-height;
height: var(--searchbox-height);
-webkit-appearance: none;
@media (min-width: $control-width-md) {
width: $searchbox-width-md;
}
}
// Enhanced auto-complete input
// This needs a bit more wrangling to make the CSS component align with the attached button
.form-autocomplete {
height: $searchbox-height;
height: var(--searchbox-height);
.form-autocomplete-input {
width: $searchbox-width;
height: $searchbox-height;
width: 100%;
height: var(--searchbox-height);
input[type='search'] {
width: 100%;
@@ -34,9 +39,68 @@
margin: 0;
border: none;
}
}
}
@media (min-width: $control-width-md) {
width: $searchbox-width-md;
.input-group {
flex: 1 1 0;
min-width: var(--searchbox-min-width);
max-width: var(--searchbox-max-width);
}
.input-group > :first-child {
flex: 1 1 0;
}
// Group search options button with search button
.input-group input[type='submit'] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dropdown-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dropdown {
margin-left: -1px;
}
// Search option menu styles
.dropdown {
.menu {
padding: $unit-4;
min-width: 250px;
}
&:focus-within {
.menu {
display: block;
}
}
.menu .actions {
margin-top: $unit-4;
display: flex;
justify-content: space-between;
}
.radio-group {
margin-bottom: $unit-1;
.form-label {
padding-bottom: 0;
}
.form-radio.form-inline {
margin: 0 $unit-2 0 0;
padding: 0;
display: inline-flex;
align-items: center;
column-gap: $unit-1;
}
.form-icon {
top: 0;
position: relative;
}
}
}
@@ -164,7 +228,7 @@ ul.bookmark-list {
display: none;
max-height: 300px;
margin: $unit-1 0;
overflow: auto;
overflow-y: auto;
}
&.show-notes .notes,
@@ -203,6 +267,7 @@ ul.bookmark-list .notes-content {
padding: $unit-1 $unit-2;
background-color: $code-bg-color;
border-radius: $unit-1;
overflow-x: auto;
}
pre code {

View File

@@ -21,9 +21,11 @@
@import "../../node_modules/spectre.css/src/media";
// Components
@import "../../node_modules/spectre.css/src/badges";
@import "../../node_modules/spectre.css/src/dropdowns";
@import "../../node_modules/spectre.css/src/empty";
@import "../../node_modules/spectre.css/src/menus";
@import "../../node_modules/spectre.css/src/modals";
@import "../../node_modules/spectre.css/src/pagination";
@import "../../node_modules/spectre.css/src/tabs";
@import "../../node_modules/spectre.css/src/toasts";
@@ -62,6 +64,19 @@ a:visited:hover {
transition: none !important;
}
// Fix radio button sub-pixel size
.form-radio .form-icon {
width: 14px;
height: 14px;
border-width: 1px;
}
.form-radio input:checked + .form-icon::before {
top: 3px;
left: 3px;
transform: unset;
}
// Make code work with light and dark theme
code {
color: $gray-color-dark;
@@ -100,6 +115,18 @@ ul.menu li:first-child {
}
}
.modal {
// Add border to separate from background in dark mode
.modal-container {
border: solid 1px $border-color;
}
// Fix modal header to use default color
.modal-header {
color: inherit;
}
}
// Increase input font size on small viewports to prevent zooming on focus the input
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
// viewport size

View File

@@ -44,6 +44,10 @@ a:focus, .btn:focus {
border-color: $dt-primary-button-color;
}
.form-radio input:checked + .form-icon::before {
background: $light-color;
}
// Pagination
.pagination .page-item.active a {
background: $dt-primary-button-color;

View File

@@ -14,14 +14,16 @@
<section class="content-area col-2">
<div class="content-area-header mb-0">
<h2>Archived bookmarks</h2>
<div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
method="post">
<form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}

View File

@@ -65,7 +65,7 @@
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive

View File

@@ -14,13 +14,15 @@
<section class="content-area col-2">
<div class="content-area-header mb-0">
<h2>Bookmarks</h2>
<div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags %}
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
<form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}

View File

@@ -26,7 +26,7 @@
</li>
{% endif %}
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
@@ -65,7 +65,7 @@
</li>
{% endif %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>

View File

@@ -1,44 +1,108 @@
<div class="search">
<form action="" method="get" role="search">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ filters.query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}">
{% endif %}
{% load widget_tweaks %}
<div class="search-container">
<form id="search" class="input-group" action="" method="get" role="search">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ search.q }}">
<input type="submit" value="Search" class="btn input-group-btn">
{% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</form>
<div class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M6 4v4"></path>
<path d="M6 12v8"></path>
<path d="M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M12 4v10"></path>
<path d="M12 18v2"></path>
<path d="M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M18 4v1"></path>
<path d="M18 9v11"></path>
</svg>
</button>
<div class="menu text-sm" tabindex="0">
<form id="search_preferences" action="" method="post">
{% csrf_token %}
{% if 'sort' in preferences_form.editable_fields %}
<div class="form-group">
<label for="{{ preferences_form.sort.id_for_label }}"
class="form-label{% if 'sort' in search.modified_params %} text-bold{% endif %}">Sort by</label>
{{ preferences_form.sort|add_class:"form-select select-sm" }}
</div>
{% endif %}
{% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
{% for radio in preferences_form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
<i class="form-icon"></i>
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
{% endif %}
{% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
{% for radio in preferences_form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
<i class="form-icon"></i>
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="actions">
<button type="submit" class="btn btn-sm btn-primary" name="apply">Apply</button>
{% if request.user.is_authenticated %}
<button type="submit" class="btn btn-sm" name="save">Save as default</button>
{% endif %}
</div>
{% for hidden_field in preferences_form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</form>
</div>
</div>
</div>
{# Replace search input with auto-complete component #}
<script type="application/javascript">
window.addEventListener("load", function () {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const filters = {
q: '{{ filters.query }}',
user: '{{ filters.user }}',
const search = {
q: '{{ search.q }}',
user: '{{ search.user }}',
shared: '{{ search.shared }}',
unread: '{{ search.unread }}',
}
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
const input = document.querySelector('#search input[name="q"]')
const wrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
target: wrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ filters.query }}',
value: '{{ search.q|safe }}',
tags: uniqueTags,
mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
apiClient,
filters,
search,
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
input.replaceWith(wrapper.firstElementChild);
});
</script>

View File

@@ -13,10 +13,13 @@
<section class="content-area col-2">
<div class="content-area-header">
<h2>Shared bookmarks</h2>
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?return_url={{ bookmark_list.return_url }}"
<form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}"
method="post">
{% csrf_token %}
@@ -32,7 +35,7 @@
<h2>User</h2>
</div>
<div>
{% user_select filters users %}
{% user_select bookmark_list.search users %}
<br>
</div>
<div class="content-area-header">

View File

@@ -1,19 +1,12 @@
{% load widget_tweaks %}
<form id="user-select" action="" method="get">
{% if filters.query %}
<input type="hidden" name="q" value="{{ filters.query }}">
{% endif %}
{% for hidden_field in form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
<div class="form-group">
<div class="d-flex">
<select name="user" class="form-select">
<option value="">Everyone</option>
{% for user in users %}
<option value="{{ user.username }}"
{% if user.username == filters.user %}selected{% endif %}
data-is-user-option>
{{ user.username }}
</option>
{% endfor %}
</select>
{{ form.user|add_class:"form-select" }}
<noscript>
<button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript>

View File

@@ -2,7 +2,7 @@ from typing import List
from django import template
from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User
from bookmarks.models import BookmarkForm, BookmarkSearch, BookmarkSearchForm, Tag, build_tag_string, User
register = template.Library()
@@ -19,21 +19,31 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''):
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ''):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
search_form = BookmarkSearchForm(search, editable_fields=['q'])
if mode == 'shared':
preferences_form = BookmarkSearchForm(search, editable_fields=['sort'])
else:
preferences_form = BookmarkSearchForm(search, editable_fields=['sort', 'shared', 'unread'])
return {
'request': context['request'],
'filters': filters,
'search': search,
'search_form': search_form,
'preferences_form': preferences_form,
'tags_string': tags_string,
'mode': mode,
}
@register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True)
def user_select(context, filters: BookmarkFilters, users: List[User]):
def user_select(context, search: BookmarkSearch, users: List[User]):
sorted_users = sorted(users, key=lambda x: str.lower(x.username))
form = BookmarkSearchForm(search, editable_fields=['user'], users=sorted_users)
return {
'filters': filters,
'search': search,
'users': sorted_users,
'form': form,
}

View File

@@ -29,7 +29,7 @@ class BookmarkFactoryMixin:
tags=None,
user: User = None,
url: str = '',
title: str = '',
title: str = None,
description: str = '',
notes: str = '',
website_title: str = '',
@@ -38,7 +38,7 @@ class BookmarkFactoryMixin:
favicon_file: str = '',
added: datetime = None,
):
if not title:
if title is None:
title = get_random_string(length=32)
if tags is None:
tags = []
@@ -77,10 +77,12 @@ class BookmarkFactoryMixin:
suffix: str = '',
tag_prefix: str = '',
archived: bool = False,
unread: bool = False,
shared: bool = False,
with_tags: bool = False,
user: User = None):
user = user or self.get_or_create_test_user()
bookmarks = []
if not prefix:
if archived:
@@ -104,8 +106,17 @@ class BookmarkFactoryMixin:
tags = []
if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)]
self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags, user=user)
tags = [self.setup_tag(name=tag_name, user=user)]
bookmark = self.setup_bookmark(url=url,
title=title,
is_archived=archived,
unread=unread,
shared=shared,
tags=tags,
user=user)
bookmarks.append(bookmark)
return bookmarks
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
@@ -128,6 +139,15 @@ class BookmarkFactoryMixin:
user.profile.save()
return user
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())
return all_tags
def get_random_string(self, length: int = 32):
return get_random_string(length=length)
class HtmlTestMixin:
def make_soup(self, html: str):

View File

@@ -440,21 +440,6 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_select_across_respects_query(self):
self.setup_numbered_bookmarks(3, prefix='foo')
self.setup_numbered_bookmarks(3, prefix='bar')
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
def test_bulk_select_across_ignores_page(self):
self.setup_numbered_bookmarks(100)
@@ -493,6 +478,21 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 2').first())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 3').first())
def test_index_action_bulk_select_across_respects_query(self):
self.setup_numbered_bookmarks(3, prefix='foo')
self.setup_numbered_bookmarks(3, prefix='bar')
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
@@ -511,6 +511,21 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
def test_archived_action_bulk_select_across_respects_query(self):
self.setup_numbered_bookmarks(3, prefix='foo', archived=True)
self.setup_numbered_bookmarks(3, prefix='bar', archived=True)
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
self.client.post(reverse('bookmarks:archived.action') + '?q=foo', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data()

View File

@@ -1,10 +1,11 @@
import urllib.parse
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
@@ -15,38 +16,51 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html
)
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html,
count=0
)
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one('div.tag-cloud')
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select('a[data-is-tag-item]')
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertContains(response, tag.name)
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select('a[data-is-tag-item]')
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertNotContains(response, tag.name)
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select('p.selected-tags')[0]
selected_tags = soup.select_one('p.selected-tags')
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select('a')
@@ -55,69 +69,53 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(f'''
<a href="{url}">Edit</a>
''', html)
def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode())
needle = collapse_whitespace(f'''
<form class="bookmark-actions"
action="{url}"
method="post" autocomplete="off">
''')
self.assertIn(needle, html)
def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
invisible_bookmarks = [
self.setup_bookmark(is_archived=False),
self.setup_bookmark(is_archived=True, user=other_user),
]
response = self.client.get(reverse('bookmarks:archived'))
html = collapse_whitespace(response.content.decode())
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix='foo', archived=True)
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix='bar', archived=True)
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
response = self.client.get(reverse('bookmarks:archived') + '?q=foo')
html = collapse_whitespace(response.content.decode())
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
unarchived_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=False, tag_prefix='unarchived')
other_user_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, user=other_user,
tag_prefix='otheruser')
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=False, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]], user=other_user)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(unarchived_bookmarks + other_user_bookmarks)
response = self.client.get(reverse('bookmarks:archived'))
@@ -125,29 +123,40 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, prefix='foo',
tag_prefix='foo')
invisible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, prefix='bar',
tag_prefix='bar')
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]])
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
response = self.client.get(reverse('bookmarks:archived') + '?q=foo')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
user_profile.save()
unread_bookmarks = self.setup_numbered_bookmarks(3, archived=True, unread=True, with_tags=True, prefix='unread',
tag_prefix='unread')
read_bookmarks = self.setup_numbered_bookmarks(3, archived=True, unread=False, with_tags=True, prefix='read',
tag_prefix='read')
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags)
self.assertInvisibleTags(response, read_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
@@ -194,11 +203,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
response = self.client.get(reverse('bookmarks:archived'))
@@ -209,16 +214,67 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title='foo', is_archived=True)
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:archived')
# without query params
return_url = urllib.parse.quote(base_url)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertEditLink(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and sort and page
url_params = '?q=foo&sort=title_asc&page=2'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self):
action_url = reverse('bookmarks:archived.action')
base_url = reverse('bookmarks:archived')
# without params
return_url = urllib.parse.quote_plus(base_url)
url = f'{action_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = '?q=foo&sort=title_asc'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self):
url = reverse('bookmarks:archived')
response = self.client.get(url)
@@ -256,3 +312,113 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
<option value="bulk_unshare">Unshare</option>
</select>
''', html)
def test_apply_search_preferences(self):
# no params
response = self.client.post(reverse('bookmarks:archived'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:archived'))
# some params
response = self.client.post(reverse('bookmarks:archived'), {
'q': 'foo',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:archived') + '?q=foo&sort=title_asc')
# params with default value are removed
response = self.client.post(reverse('bookmarks:archived'), {
'q': 'foo',
'user': '',
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:archived') + '?q=foo&unread=yes')
# page is removed
response = self.client.post(reverse('bookmarks:archived'), {
'q': 'foo',
'page': '2',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:archived') + '?q=foo&sort=title_asc')
def test_save_search_preferences(self):
user_profile = self.user.profile
# no params
self.client.post(reverse('bookmarks:archived'), {
'save': '',
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# with param
self.client.post(reverse('bookmarks:archived'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# add a param
self.client.post(reverse('bookmarks:archived'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# remove a param
self.client.post(reverse('bookmarks:archived'), {
'save': '',
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# ignores non-preferences
self.client.post(reverse('bookmarks:archived'), {
'save': '',
'q': 'foo',
'user': 'john',
'page': '3',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
def test_url_encode_bookmark_actions_url(self):
url = reverse('bookmarks:archived') + '?q=%23foo'
response = self.client.get(url)
html = response.content.decode()
soup = self.make_soup(html)
actions_form = soup.select('form.bookmark-actions')[0]
self.assertEqual(actions_form.attrs['action'],
'/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo')

View File

@@ -1,11 +1,11 @@
from typing import List
import urllib.parse
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
@@ -16,38 +16,51 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html
)
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html,
count=0
)
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one('div.tag-cloud')
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select('a[data-is-tag-item]')
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertContains(response, tag.name)
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select('a[data-is-tag-item]')
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertNotContains(response, tag.name)
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select('p.selected-tags')[0]
selected_tags = soup.select_one('p.selected-tags')
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select('a')
@@ -56,69 +69,51 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(f'''
<a href="{url}">Edit</a>
''', html)
def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode())
needle = collapse_whitespace(f'''
<form class="bookmark-actions"
action="{url}"
method="post" autocomplete="off">
''')
self.assertIn(needle, html)
def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
visible_bookmarks = self.setup_numbered_bookmarks(3)
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(user=other_user),
]
response = self.client.get(reverse('bookmarks:index'))
html = collapse_whitespace(response.content.decode())
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix='foo')
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix='bar')
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True)
archived_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, tag_prefix='archived')
other_user_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, user=other_user, tag_prefix='otheruser')
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[invisible_tags[1]], is_archived=True)
self.setup_bookmark(tags=[invisible_tags[2]], user=other_user)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(archived_bookmarks + other_user_bookmarks)
response = self.client.get(reverse('bookmarks:index'))
@@ -126,29 +121,38 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, prefix='foo', tag_prefix='foo')
invisible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, prefix='bar', tag_prefix='bar')
self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(tags=[invisible_tags[0]])
self.setup_bookmark(tags=[invisible_tags[1]])
self.setup_bookmark(tags=[invisible_tags[2]])
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
user_profile.save()
unread_bookmarks = self.setup_numbered_bookmarks(3, unread=True, with_tags=True, prefix='unread',
tag_prefix='unread')
read_bookmarks = self.setup_numbered_bookmarks(3, unread=False, with_tags=True, prefix='read',
tag_prefix='read')
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags)
self.assertInvisibleTags(response, read_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
@@ -195,11 +199,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
visible_bookmarks = self.setup_numbered_bookmarks(3)
response = self.client.get(reverse('bookmarks:index'))
@@ -210,40 +210,66 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
visible_bookmarks = self.setup_numbered_bookmarks(3)
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_should_contain_query_params(self):
def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title='foo')
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:index')
# without query params
url = reverse('bookmarks:index')
response = self.client.get(url)
html = response.content.decode()
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
return_url = urllib.parse.quote_plus(url)
return_url = urllib.parse.quote(base_url)
url = f'{edit_url}?return_url={return_url}'
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}">Edit</a>
''', html)
response = self.client.get(base_url)
self.assertEditLink(response, url)
# with query params
url = reverse('bookmarks:index') + '?q=foo&user=user'
response = self.client.get(url)
html = response.content.decode()
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
return_url = urllib.parse.quote_plus(url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}">Edit</a>
''', html)
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and sort and page
url_params = '?q=foo&sort=title_asc&page=2'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self):
action_url = reverse('bookmarks:index.action')
base_url = reverse('bookmarks:index')
# without params
return_url = urllib.parse.quote_plus(base_url)
url = f'{action_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = '?q=foo&sort=title_asc'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self):
url = reverse('bookmarks:index')
@@ -282,3 +308,113 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
<option value="bulk_unshare">Unshare</option>
</select>
''', html)
def test_apply_search_preferences(self):
# no params
response = self.client.post(reverse('bookmarks:index'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:index'))
# some params
response = self.client.post(reverse('bookmarks:index'), {
'q': 'foo',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:index') + '?q=foo&sort=title_asc')
# params with default value are removed
response = self.client.post(reverse('bookmarks:index'), {
'q': 'foo',
'user': '',
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:index') + '?q=foo&unread=yes')
# page is removed
response = self.client.post(reverse('bookmarks:index'), {
'q': 'foo',
'page': '2',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:index') + '?q=foo&sort=title_asc')
def test_save_search_preferences(self):
user_profile = self.user.profile
# no params
self.client.post(reverse('bookmarks:index'), {
'save': '',
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# with param
self.client.post(reverse('bookmarks:index'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# add a param
self.client.post(reverse('bookmarks:index'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# remove a param
self.client.post(reverse('bookmarks:index'), {
'save': '',
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# ignores non-preferences
self.client.post(reverse('bookmarks:index'), {
'save': '',
'q': 'foo',
'user': 'john',
'page': '3',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
def test_url_encode_bookmark_actions_url(self):
url = reverse('bookmarks:index') + '?q=%23foo'
response = self.client.get(url)
html = response.content.decode()
soup = self.make_soup(html)
actions_form = soup.select('form.bookmark-actions')[0]
self.assertEqual(actions_form.attrs['action'],
'/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo')

View File

@@ -0,0 +1,74 @@
from django.test import TestCase
from bookmarks.models import BookmarkSearch, BookmarkSearchForm
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
def test_initial_values(self):
# no params
search = BookmarkSearch()
form = BookmarkSearchForm(search)
self.assertEqual(form['q'].initial, '')
self.assertEqual(form['user'].initial, '')
self.assertEqual(form['sort'].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form['shared'].initial, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(form['unread'].initial, BookmarkSearch.FILTER_UNREAD_OFF)
# with params
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123',
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES)
form = BookmarkSearchForm(search)
self.assertEqual(form['q'].initial, 'search query')
self.assertEqual(form['user'].initial, 'user123')
self.assertEqual(form['sort'].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form['shared'].initial, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(form['unread'].initial, BookmarkSearch.FILTER_UNREAD_YES)
def test_user_options(self):
users = [
self.setup_user('user1'),
self.setup_user('user2'),
self.setup_user('user3'),
]
search = BookmarkSearch()
form = BookmarkSearchForm(search, users=users)
self.assertCountEqual(form['user'].field.choices, [
('', 'Everyone'),
('user1', 'user1'),
('user2', 'user2'),
('user3', 'user3'),
])
def test_hidden_fields(self):
# no modified params
search = BookmarkSearch()
form = BookmarkSearchForm(search)
self.assertEqual(len(form.hidden_fields()), 0)
# some modified params
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC)
form = BookmarkSearchForm(search)
self.assertCountEqual(form.hidden_fields(), [form['q'], form['sort']])
# all modified params
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123',
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES)
form = BookmarkSearchForm(search)
self.assertCountEqual(form.hidden_fields(),
[form['q'], form['sort'], form['user'], form['shared'], form['unread']])
# some modified params are editable fields
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123')
form = BookmarkSearchForm(search, editable_fields=['q', 'user'])
self.assertCountEqual(form.hidden_fields(), [form['sort']])

View File

@@ -0,0 +1,162 @@
from django.http import QueryDict
from django.test import TestCase
from bookmarks.models import BookmarkSearch
class BookmarkSearchModelTest(TestCase):
def test_from_request(self):
# no params
query_dict = QueryDict()
search = BookmarkSearch.from_request(query_dict)
self.assertEqual(search.q, '')
self.assertEqual(search.user, '')
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# some params
query_dict = QueryDict('q=search query&user=user123')
bookmark_search = BookmarkSearch.from_request(query_dict)
self.assertEqual(bookmark_search.q, 'search query')
self.assertEqual(bookmark_search.user, 'user123')
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# all params
query_dict = QueryDict('q=search query&sort=title_asc&user=user123&shared=yes&unread=yes')
search = BookmarkSearch.from_request(query_dict)
self.assertEqual(search.q, 'search query')
self.assertEqual(search.user, 'user123')
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
# respects preferences
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
query_dict = QueryDict('q=search query')
search = BookmarkSearch.from_request(query_dict, preferences)
self.assertEqual(search.q, 'search query')
self.assertEqual(search.user, '')
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
# query overrides preferences
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_SHARED,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
query_dict = QueryDict('sort=title_desc&shared=no&unread=off')
search = BookmarkSearch.from_request(query_dict, preferences)
self.assertEqual(search.q, '')
self.assertEqual(search.user, '')
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
def test_modified_params(self):
# no params
bookmark_search = BookmarkSearch()
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# params are default values
bookmark_search = BookmarkSearch(q='', sort=BookmarkSearch.SORT_ADDED_DESC, user='', shared='')
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# some modified params
bookmark_search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['q', 'sort'])
# all modified params
bookmark_search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123',
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['q', 'sort', 'user', 'shared', 'unread'])
# preferences are not modified params
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
bookmark_search = BookmarkSearch(preferences=preferences)
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# param is not modified if it matches the preference
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
bookmark_search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_ASC,
unread=BookmarkSearch.FILTER_UNREAD_YES,
preferences=preferences)
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# overriding preferences is a modified param
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_SHARED,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
bookmark_search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC,
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
unread=BookmarkSearch.FILTER_UNREAD_OFF,
preferences=preferences)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['sort', 'shared', 'unread'])
def test_has_modifications(self):
# no params
bookmark_search = BookmarkSearch()
self.assertFalse(bookmark_search.has_modifications)
# params are default values
bookmark_search = BookmarkSearch(q='', sort=BookmarkSearch.SORT_ADDED_DESC, user='', shared='')
self.assertFalse(bookmark_search.has_modifications)
# modified params
bookmark_search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC)
self.assertTrue(bookmark_search.has_modifications)
def test_preferences_dict(self):
# no params
bookmark_search = BookmarkSearch()
self.assertEqual(bookmark_search.preferences_dict, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# with params
bookmark_search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC, unread=BookmarkSearch.FILTER_UNREAD_YES)
self.assertEqual(bookmark_search.preferences_dict, {
'sort': BookmarkSearch.SORT_TITLE_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# only returns preferences
bookmark_search = BookmarkSearch(q='search query', user='user123')
self.assertEqual(bookmark_search.preferences_dict, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})

View File

@@ -1,42 +1,226 @@
from bs4 import BeautifulSoup
from django.db.models import QuerySet
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkFilters, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import BookmarkSearch, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ''):
rf = RequestFactory()
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
filters = BookmarkFilters(request)
search = BookmarkSearch.from_request(request.GET)
context = RequestContext(request, {
'request': request,
'filters': filters,
'search': search,
'tags': tags,
'mode': mode,
})
template_to_render = Template(
'{% load bookmarks %}'
'{% bookmark_search filters tags %}'
'{% bookmark_search search tags mode %}'
)
return template_to_render.render(context)
def test_render_hidden_inputs_for_filter_params(self):
# Should render hidden inputs if query param exists
def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):
input = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNotNone(input)
if value is not None:
self.assertEqual(input['value'], value)
def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
input = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNone(input)
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
input = form.select_one(f'input[name="{name}"][type="search"]')
self.assertIsNotNone(input)
if value is not None:
self.assertEqual(input['value'], value)
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
select = form.select_one(f'select[name="{name}"]')
self.assertIsNotNone(select)
if value is not None:
options = select.select('option')
for option in options:
if option['value'] == value:
self.assertTrue(option.has_attr('selected'))
else:
self.assertFalse(option.has_attr('selected'))
def assertRadioGroup(self, form: BeautifulSoup, name: str, value: str = None):
radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) > 0)
if value is not None:
for radio in radios:
if radio['value'] == value:
self.assertTrue(radio.has_attr('checked'))
else:
self.assertFalse(radio.has_attr('checked'))
def assertNoRadioGroup(self, form: BeautifulSoup, name: str):
radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) == 0)
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ''):
id_attr = f'for="{id}"' if id else ''
tag = 'label' if id else 'div'
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str, id: str = ''):
id_attr = f'for="{id}"' if id else ''
tag = 'label' if id else 'div'
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def test_search_form_inputs(self):
# Without params
url = '/test'
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
search_form = soup.select_one('form#search')
self.assertSearchInput(search_form, 'q')
self.assertNoHiddenInput(search_form, 'user')
self.assertNoHiddenInput(search_form, 'sort')
self.assertNoHiddenInput(search_form, 'shared')
self.assertNoHiddenInput(search_form, 'unread')
# With params
url = '/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes'
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
search_form = soup.select_one('form#search')
self.assertSearchInput(search_form, 'q', 'foo')
self.assertHiddenInput(search_form, 'user', 'john')
self.assertHiddenInput(search_form, 'sort', BookmarkSearch.SORT_TITLE_ASC)
self.assertHiddenInput(search_form, 'shared', BookmarkSearch.FILTER_SHARED_SHARED)
self.assertHiddenInput(search_form, 'unread', BookmarkSearch.FILTER_UNREAD_YES)
def test_preferences_form_inputs(self):
# Without params
url = '/test'
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
self.assertNoHiddenInput(preferences_form, 'q')
self.assertNoHiddenInput(preferences_form, 'user')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_ADDED_DESC)
self.assertRadioGroup(preferences_form, 'shared', BookmarkSearch.FILTER_SHARED_OFF)
self.assertRadioGroup(preferences_form, 'unread', BookmarkSearch.FILTER_UNREAD_OFF)
# With params
url = '/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes'
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
self.assertHiddenInput(preferences_form, 'q', 'foo')
self.assertHiddenInput(preferences_form, 'user', 'john')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_TITLE_ASC)
self.assertRadioGroup(preferences_form, 'shared', BookmarkSearch.FILTER_SHARED_SHARED)
self.assertRadioGroup(preferences_form, 'unread', BookmarkSearch.FILTER_UNREAD_YES)
def test_preferences_form_inputs_shared_mode(self):
# Without params
url = '/test'
rendered_template = self.render_template(url, mode='shared')
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
self.assertNoHiddenInput(preferences_form, 'q')
self.assertNoHiddenInput(preferences_form, 'user')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_ADDED_DESC)
self.assertNoRadioGroup(preferences_form, 'shared')
self.assertNoRadioGroup(preferences_form, 'unread')
# With params
url = '/test?q=foo&user=john&sort=title_asc'
rendered_template = self.render_template(url, mode='shared')
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
self.assertHiddenInput(preferences_form, 'q', 'foo')
self.assertHiddenInput(preferences_form, 'user', 'john')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_TITLE_ASC)
self.assertNoRadioGroup(preferences_form, 'shared')
self.assertNoRadioGroup(preferences_form, 'unread')
def test_modified_indicator(self):
# Without modifications
url = '/test'
rendered_template = self.render_template(url)
self.assertIn('<button type="button" class="btn dropdown-toggle">', rendered_template)
# With modifications
url = '/test?sort=title_asc'
rendered_template = self.render_template(url)
self.assertIn('<button type="button" class="btn dropdown-toggle badge">', rendered_template)
# Ignores non-preferences modifications
url = '/test?q=foo&user=john'
rendered_template = self.render_template(url)
self.assertInHTML('''
<input type="hidden" name="user" value="john">
''', rendered_template)
self.assertIn('<button type="button" class="btn dropdown-toggle">', rendered_template)
# Should not render hidden inputs if query param does not exist
url = '/test?q=foo'
def test_modified_labels(self):
# Without modifications
url = '/test'
rendered_template = self.render_template(url)
self.assertInHTML('''
<input type="hidden" name="user" value="john">
''', rendered_template, count=0)
self.assertUnmodifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertUnmodifiedLabel(rendered_template, 'Shared filter')
self.assertUnmodifiedLabel(rendered_template, 'Unread filter')
# Modified sort
url = '/test?sort=title_asc'
rendered_template = self.render_template(url)
self.assertModifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertUnmodifiedLabel(rendered_template, 'Shared filter')
self.assertUnmodifiedLabel(rendered_template, 'Unread filter')
# Modified shared
url = '/test?shared=yes'
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertModifiedLabel(rendered_template, 'Shared filter')
self.assertUnmodifiedLabel(rendered_template, 'Unread filter')
# Modified unread
url = '/test?unread=yes'
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertUnmodifiedLabel(rendered_template, 'Shared filter')
self.assertModifiedLabel(rendered_template, 'Unread filter')

View File

@@ -1,14 +1,15 @@
import urllib.parse
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def authenticate(self) -> None:
user = self.get_or_create_test_user()
@@ -21,48 +22,69 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, '<li ld-bookmark-item class="shared">', count=len(bookmarks))
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 1, link_target)
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 0, link_target)
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: [Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one('div.tag-cloud')
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select('a[data-is-tag-item]')
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertContains(response, tag.name)
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select('a[data-is-tag-item]')
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
def assertInvisibleTags(self, response, tags: [Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
self.assertFalse(tag.name in tag_item_names)
def assertVisibleUserOptions(self, response, users: List[User]):
html = response.content.decode()
self.assertContains(response, 'data-is-user-option', count=len(users))
user_options = [
'<option value="" selected="">Everyone</option>'
]
for user in users:
self.assertInHTML(f'''
<option value="{user.username}" data-is-user-option>
{user.username}
</option>
''', html)
user_options.append(f'<option value="{user.username}">{user.username}</option>')
user_select_html = f'''
<select name="user" class="form-select" required="" id="id_user">
{''.join(user_options)}
</select>
'''
def assertInvisibleUserOptions(self, response, users: List[User]):
self.assertInHTML(user_select_html, html)
def assertEditLink(self, response, url):
html = response.content.decode()
for user in users:
self.assertInHTML(f'''
<option value="{user.username}" data-is-user-option>
{user.username}
</option>
''', html, count=0)
self.assertInHTML(f'''
<a href="{url}">Edit</a>
''', html)
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
self.authenticate()
@@ -84,10 +106,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
]
response = self.client.get(reverse('bookmarks:shared'))
html = collapse_whitespace(response.content.decode())
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -114,22 +133,12 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_list_bookmarks_matching_query(self):
self.authenticate()
user = self.setup_user(enable_sharing=True)
visible_bookmarks = [
self.setup_bookmark(shared=True, title='searchvalue', user=user),
self.setup_bookmark(shared=True, title='searchvalue', user=user),
self.setup_bookmark(shared=True, title='searchvalue', user=user)
]
invisible_bookmarks = [
self.setup_bookmark(shared=True, user=user),
self.setup_bookmark(shared=True, user=user),
self.setup_bookmark(shared=True, user=user)
]
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
visible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user, prefix='foo')
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
response = self.client.get(reverse('bookmarks:shared') + '?q=foo')
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -137,22 +146,11 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
visible_bookmarks = [
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user1),
]
invisible_bookmarks = [
self.setup_bookmark(shared=True, user=user2),
self.setup_bookmark(shared=True, user=user2),
self.setup_bookmark(shared=True, user=user2),
]
visible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user1, prefix='user1')
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user2, prefix='user2')
response = self.client.get(reverse('bookmarks:shared'))
html = collapse_whitespace(response.content.decode())
# Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -267,41 +265,57 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
self.authenticate()
expected_visible_users = [
self.setup_user(enable_sharing=True),
self.setup_user(enable_sharing=True),
self.setup_user(name='user_a', enable_sharing=True),
self.setup_user(name='user_b', enable_sharing=True),
]
self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1])
expected_invisible_users = [
self.setup_user(enable_sharing=True),
self.setup_user(enable_sharing=False),
]
self.setup_bookmark(shared=False, user=expected_invisible_users[0])
self.setup_bookmark(shared=True, user=expected_invisible_users[1])
self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True))
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False))
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users)
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
# users with public sharing enabled
expected_visible_users = [
self.setup_user(enable_sharing=True, enable_public_sharing=True),
self.setup_user(enable_sharing=True, enable_public_sharing=True),
self.setup_user(name='user_a', enable_sharing=True, enable_public_sharing=True),
self.setup_user(name='user_b', enable_sharing=True, enable_public_sharing=True),
]
self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1])
expected_invisible_users = [
self.setup_user(enable_sharing=True),
self.setup_user(enable_sharing=True),
]
self.setup_bookmark(shared=True, user=expected_invisible_users[0])
self.setup_bookmark(shared=True, user=expected_invisible_users[1])
# users with public sharing disabled
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
self.authenticate()
other_user = self.setup_user(enable_sharing=True)
user_profile = self.get_or_create_test_user().profile
user_profile.search_preferences = {
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
user_profile.save()
unread_bookmarks = self.setup_numbered_bookmarks(3, shared=True, unread=True, with_tags=True, prefix='unread',
tag_prefix='unread', user=other_user)
read_bookmarks = self.setup_numbered_bookmarks(3, shared=True, unread=False, with_tags=True, prefix='read',
tag_prefix='read', user=other_user)
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags)
self.assertInvisibleTags(response, read_tags)
def test_should_open_bookmarks_in_new_page_by_default(self):
self.authenticate()
@@ -334,3 +348,155 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_respects_search_options(self):
self.authenticate()
user = self.get_or_create_test_user()
user.profile.enable_sharing = True
user.profile.save()
bookmark = self.setup_bookmark(title='foo', shared=True, user=user)
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:shared')
# without query params
return_url = urllib.parse.quote(base_url)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertEditLink(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and user
url_params = f'?q=foo&user={user.username}'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and sort and page
url_params = '?q=foo&sort=title_asc&page=2'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
def test_apply_search_preferences(self):
# no params
response = self.client.post(reverse('bookmarks:shared'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared'))
# some params
response = self.client.post(reverse('bookmarks:shared'), {
'q': 'foo',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared') + '?q=foo&sort=title_asc')
# params with default value are removed
response = self.client.post(reverse('bookmarks:shared'), {
'q': 'foo',
'user': '',
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared') + '?q=foo&unread=yes')
# page is removed
response = self.client.post(reverse('bookmarks:shared'), {
'q': 'foo',
'page': '2',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared') + '?q=foo&sort=title_asc')
def test_save_search_preferences(self):
self.authenticate()
user_profile = self.user.profile
# no params
self.client.post(reverse('bookmarks:shared'), {
'save': '',
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# with param
self.client.post(reverse('bookmarks:shared'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
# add a param
self.client.post(reverse('bookmarks:shared'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# remove a param
self.client.post(reverse('bookmarks:shared'), {
'save': '',
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
# ignores non-preferences
self.client.post(reverse('bookmarks:shared'), {
'save': '',
'q': 'foo',
'user': 'john',
'page': '3',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
user_profile.refresh_from_db()
self.assertEqual(user_profile.search_preferences, {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
def test_url_encode_bookmark_actions_url(self):
url = reverse('bookmarks:shared') + '?q=%23foo'
response = self.client.get(url)
html = response.content.decode()
soup = self.make_soup(html)
actions_form = soup.select('form.bookmark-actions')[0]
self.assertEqual(actions_form.attrs['action'],
'/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo')

View File

@@ -6,8 +6,9 @@ from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from bookmarks.models import Bookmark
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.services import website_loader
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
@@ -15,15 +16,6 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
self.bookmark2 = self.setup_bookmark()
self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
def authenticate(self):
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
@@ -56,29 +48,102 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_list_bookmarks(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
self.setup_numbered_bookmarks(5, archived=True)
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_bookmarks_should_filter_by_query(self):
self.authenticate()
search_value = self.get_random_string()
bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
self.setup_numbered_bookmarks(5)
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
response = self.get(reverse('bookmarks:bookmark-list') + '?q=' + search_value,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_bookmarks_filter_unread(self):
self.authenticate()
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
read_bookmarks = self.setup_numbered_bookmarks(5, unread=False)
# Filter off
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], unread_bookmarks + read_bookmarks)
# Filter shared
response = self.get(reverse('bookmarks:bookmark-list') + '?unread=yes',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], unread_bookmarks)
# Filter unshared
response = self.get(reverse('bookmarks:bookmark-list') + '?unread=no',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], read_bookmarks)
def test_list_bookmarks_filter_shared(self):
self.authenticate()
unshared_bookmarks = self.setup_numbered_bookmarks(5)
shared_bookmarks = self.setup_numbered_bookmarks(5, shared=True)
# Filter off
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], unshared_bookmarks + shared_bookmarks)
# Filter shared
response = self.get(reverse('bookmarks:bookmark-list') + '?shared=yes',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
# Filter unshared
response = self.get(reverse('bookmarks:bookmark-list') + '?shared=no',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], unshared_bookmarks)
def test_list_bookmarks_should_respect_sort(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
bookmarks.reverse()
response = self.get(reverse('bookmarks:bookmark-list') + '?sort=title_desc',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
self.authenticate()
self.setup_numbered_bookmarks(5)
archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True)
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
self.assertBookmarkListEqual(response.data['results'], archived_bookmarks)
def test_list_archived_bookmarks_should_filter_by_query(self):
self.authenticate()
search_value = self.get_random_string()
archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True, prefix=search_value)
self.setup_numbered_bookmarks(5, archived=True)
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=' + search_value,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
self.assertBookmarkListEqual(response.data['results'], archived_bookmarks)
def test_list_archived_bookmarks_should_respect_sort(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5, archived=True)
bookmarks.reverse()
response = self.get(reverse('bookmarks:bookmark-archived') + '?sort=title_desc',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_shared_bookmarks(self):
self.authenticate()
@@ -158,6 +223,16 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
def test_list_shared_bookmarks_should_respect_sort(self):
self.authenticate()
user = self.setup_user(enable_sharing=True)
bookmarks = self.setup_numbered_bookmarks(5, shared=True, user=user)
bookmarks.reverse()
response = self.get(reverse('bookmarks:bookmark-shared') + '?sort=title_desc',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_create_bookmark(self):
self.authenticate()
@@ -295,34 +370,38 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_get_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [self.bookmark1])
self.assertBookmarkListEqual([response.data], [bookmark])
def test_update_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
data = {'url': 'https://example.com/updated'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '')
@@ -331,112 +410,119 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_update_bookmark_unread_flag(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/', 'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.unread, True)
def test_update_bookmark_shared_flag(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/', 'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.shared, True)
def test_patch_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.url, data['url'])
bookmark.refresh_from_db()
self.assertEqual(bookmark.url, data['url'])
data = {'title': 'Updated title'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.title, data['title'])
bookmark.refresh_from_db()
self.assertEqual(bookmark.title, data['title'])
data = {'description': 'Updated description'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description'])
bookmark.refresh_from_db()
self.assertEqual(bookmark.description, data['description'])
data = {'notes': 'Updated notes'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.notes, data['notes'])
bookmark.refresh_from_db()
self.assertEqual(bookmark.notes, data['notes'])
data = {'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertTrue(self.bookmark1.unread)
bookmark.refresh_from_db()
self.assertTrue(bookmark.unread)
data = {'unread': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.unread)
bookmark.refresh_from_db()
self.assertFalse(bookmark.unread)
data = {'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertTrue(self.bookmark1.shared)
bookmark.refresh_from_db()
self.assertTrue(bookmark.shared)
data = {'shared': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.shared)
bookmark.refresh_from_db()
self.assertFalse(bookmark.shared)
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
tag_names = [tag.name for tag in self.bookmark1.tags.all()]
bookmark.refresh_from_db()
tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, self.bookmark1.url)
self.assertEqual(updated_bookmark.title, self.bookmark1.title)
self.assertEqual(updated_bookmark.description, self.bookmark1.description)
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, bookmark.url)
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_delete_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
self.assertEqual(len(Bookmark.objects.filter(id=bookmark.id)), 0)
def test_archive(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
url = reverse('bookmarks:bookmark-archive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertTrue(bookmark.is_archived)
def test_unarchive(self):
self.authenticate()
bookmark = self.setup_bookmark(is_archived=True)
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
url = reverse('bookmarks:bookmark-unarchive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertFalse(bookmark.is_archived)
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
@@ -509,6 +595,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_can_only_access_own_bookmarks(self):
self.authenticate()
self.setup_bookmark()
self.setup_bookmark(is_archived=True)
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user)
@@ -517,11 +605,11 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse('bookmarks:bookmark-list')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 3)
self.assertEqual(len(response.data['results']), 1)
url = reverse('bookmarks:bookmark-archived')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
self.assertEqual(len(response.data['results']), 1)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
@@ -557,3 +645,49 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
self.assertIsNone(response.data['bookmark'])
def assertUserProfile(self, response: Response, profile: UserProfile):
self.assertEqual(response.data['theme'], profile.theme)
self.assertEqual(response.data['bookmark_date_display'], profile.bookmark_date_display)
self.assertEqual(response.data['bookmark_link_target'], profile.bookmark_link_target)
self.assertEqual(response.data['web_archive_integration'], profile.web_archive_integration)
self.assertEqual(response.data['tag_search'], profile.tag_search)
self.assertEqual(response.data['enable_sharing'], profile.enable_sharing)
self.assertEqual(response.data['enable_public_sharing'], profile.enable_public_sharing)
self.assertEqual(response.data['enable_favicons'], profile.enable_favicons)
self.assertEqual(response.data['display_url'], profile.display_url)
self.assertEqual(response.data['permanent_notes'], profile.permanent_notes)
self.assertEqual(response.data['search_preferences'], profile.search_preferences)
def test_user_profile(self):
self.authenticate()
# default profile
profile = self.user.profile
url = reverse('bookmarks:user-profile')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertUserProfile(response, profile)
# update profile
profile.theme = 'dark'
profile.bookmark_date_display = 'absolute'
profile.bookmark_link_target = '_self'
profile.web_archive_integration = 'enabled'
profile.tag_search = 'lax'
profile.enable_sharing = True
profile.enable_public_sharing = True
profile.enable_favicons = True
profile.display_url = True
profile.permanent_notes = True
profile.search_preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
}
profile.save()
url = reverse('bookmarks:user-profile')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertUserProfile(response, profile)

View File

@@ -111,3 +111,11 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
def test_user_profile_requires_authentication(self):
url = reverse('bookmarks:user-profile')
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(url, expected_status_code=status.HTTP_200_OK)

View File

@@ -55,7 +55,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
# Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f'''
<a href="{edit_url}?return_url=%2Fbookmarks">Edit</a>
<a href="{edit_url}?return_url=/bookmarks">Edit</a>
''', html, count=count)
# Archive link
self.assertInHTML(f'''

View File

@@ -18,7 +18,10 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
self.setup_tag(name='tag3')]),
self.setup_bookmark(url='https://example.com/3', title='Title 3', added=added, unread=True),
self.setup_bookmark(url='https://example.com/4', title='Title 4', added=added, shared=True),
self.setup_bookmark(url='https://example.com/5', title='Title 5', added=added, shared=True,
description='Example description', notes='Example notes'),
self.setup_bookmark(url='https://example.com/6', title='Title 6', added=added, shared=True,
notes='Example notes'),
]
html = exporter.export_netscape_html(bookmarks)
@@ -28,13 +31,18 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
f'<DT><A HREF="https://example.com/5" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
'<DD>Example description[linkding-notes]Example notes[/linkding-notes]',
f'<DT><A HREF="https://example.com/6" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
'<DD>[linkding-notes]Example notes[/linkding-notes]',
]
self.assertIn('\n\r'.join(lines), html)
def test_escape_html_in_title_and_description(self):
def test_escape_html(self):
bookmark = self.setup_bookmark(
title='<style>: The Style Information element',
description='The <style> HTML element contains style information for a document, or part of a document.'
description='The <style> HTML element contains style information for a document, or part of a document.',
notes='Interesting notes about the <style> HTML element.',
)
html = exporter.export_netscape_html([bookmark])
@@ -43,6 +51,10 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
'The &lt;style&gt; HTML element contains style information for a document, or part of a document.',
html
)
self.assertIn(
'Interesting notes about the &lt;style&gt; HTML element.',
html
)
def test_handle_empty_values(self):
bookmark = self.setup_bookmark()

View File

@@ -67,7 +67,8 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=True),
BookmarkHtmlTag(href='https://example.com/private', title='Private title', description='Private description',
BookmarkHtmlTag(href='https://example.com/private', title='Private title',
description='Private description',
add_date='4', private=True),
]
import_html = self.render_html(tags=html_tags)
@@ -90,7 +91,8 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
add_date='333', tags='updated-bar-tag, updated-other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=False),
BookmarkHtmlTag(href='https://example.com/private', title='Private title', description='Private description',
BookmarkHtmlTag(href='https://example.com/private', title='Private title',
description='Private description',
add_date='4', private=False),
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
]
@@ -293,6 +295,40 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(bookmark2.shared, False)
self.assertEqual(bookmark3.shared, True)
def test_notes(self):
# initial notes
test_html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description[linkding-notes]Example notes[/linkding-notes]
''')
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].description, 'Example description')
self.assertEqual(Bookmark.objects.all()[0].notes, 'Example notes')
# update notes
test_html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description[linkding-notes]Updated notes[/linkding-notes]
''')
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].description, 'Example description')
self.assertEqual(Bookmark.objects.all()[0].notes, 'Updated notes')
# does not override existing notes if empty
test_html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description
''')
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].description, 'Example description')
self.assertEqual(Bookmark.objects.all()[0].notes, 'Updated notes')
def test_schedule_snapshot_creation(self):
user = self.get_or_create_test_user()
test_html = self.render_html(tags_html='')

View File

@@ -113,9 +113,9 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
self.assertTruncationIndicators(rendered_template, 1)
def test_extend_existing_query(self):
rendered_template = self.render_template(100, 10, 2, url='/test?q=cake')
self.assertPrevLink(rendered_template, 1, href='?q=cake&page=1')
self.assertPageLink(rendered_template, 1, False, href='?q=cake&page=1')
self.assertPageLink(rendered_template, 2, True, href='?q=cake&page=2')
self.assertNextLink(rendered_template, 3, href='?q=cake&page=3')
def test_respects_search_parameters(self):
rendered_template = self.render_template(100, 10, 2, url='/test?q=cake&sort=title_asc&page=2')
self.assertPrevLink(rendered_template, 1, href='?q=cake&sort=title_asc&page=1')
self.assertPageLink(rendered_template, 1, False, href='?q=cake&sort=title_asc&page=1')
self.assertPageLink(rendered_template, 2, True, href='?q=cake&sort=title_asc&page=2')
self.assertNextLink(rendered_template, 3, href='?q=cake&sort=title_asc&page=3')

View File

@@ -149,3 +149,73 @@ class ParserTestCase(TestCase, ImportTestMixin):
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].private, False)
def test_notes(self):
# no description, no notes
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].description, '')
self.assertEqual(bookmarks[0].notes, '')
# description, no notes
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].description, 'Example description')
self.assertEqual(bookmarks[0].notes, '')
# description, notes
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description[linkding-notes]Example notes[/linkding-notes]
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].description, 'Example description')
self.assertEqual(bookmarks[0].notes, 'Example notes')
# description, notes without closing tag
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description[linkding-notes]Example notes
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].description, 'Example description')
self.assertEqual(bookmarks[0].notes, 'Example notes')
# no description, notes
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>[linkding-notes]Example notes[/linkding-notes]
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].description, '')
self.assertEqual(bookmarks[0].notes, 'Example notes')
# notes reset between bookmarks
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com/1" ADD_DATE="1">Example title</A>
<DD>[linkding-notes]Example notes[/linkding-notes]
<DT><A HREF="https://example.com/2" ADD_DATE="1">Example title</A>
<DD>Example description
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].description, '')
self.assertEqual(bookmarks[0].notes, 'Example notes')
self.assertEqual(bookmarks[1].description, 'Example description')
self.assertEqual(bookmarks[1].notes, '')
def test_unescape_content(self):
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">&lt;style&gt;: The Style Information element</A>
<DD>The &lt;style&gt; HTML element contains style information for a document, or part of a document.[linkding-notes]Interesting notes about the &lt;style&gt; HTML element.[/linkding-notes]
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].title,
'<style>: The Style Information element')
self.assertEqual(bookmarks[0].description,
'The <style> HTML element contains style information for a document, or part of a document.')
self.assertEqual(bookmarks[0].notes, 'Interesting notes about the <style> HTML element.')

View File

@@ -3,9 +3,10 @@ import operator
from django.contrib.auth import get_user_model
from django.db.models import QuerySet
from django.test import TestCase
from django.utils import timezone
from bookmarks import queries
from bookmarks.models import Bookmark, UserProfile
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique
@@ -145,12 +146,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
]
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())
return all_tags
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
expected_items = []
for item_list in item_lists:
@@ -163,7 +158,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_return_all_for_empty_query(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [
self.other_bookmarks,
self.term1_bookmarks,
@@ -178,7 +173,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_search_single_term(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term1'))
self.assertQueryResult(query, [
self.term1_bookmarks,
self.term1_term2_bookmarks,
@@ -188,35 +183,35 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_search_multiple_terms(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term2 term1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term2 term1'))
self.assertQueryResult(query, [self.term1_term2_bookmarks])
def test_query_bookmarks_should_search_single_tag(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '#tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='#tag1'))
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
def test_query_bookmarks_should_search_multiple_tags(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #tag2')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='#tag1 #tag2'))
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '#Tag1 #TAG2')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='#Tag1 #TAG2'))
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_terms_and_tags_combined(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term1 #tag1'))
self.assertQueryResult(query, [self.term1_tag1_bookmarks])
@@ -226,7 +221,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
self.profile.save()
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='tag1'))
self.assertQueryResult(query, [self.tag1_as_term_bookmarks])
def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self):
@@ -235,7 +230,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save()
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='tag1'))
self.assertQueryResult(query, [
self.tag1_bookmarks,
self.tag1_as_term_bookmarks,
@@ -243,17 +238,17 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.term1_tag1_bookmarks
])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 term1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='tag1 term1'))
self.assertQueryResult(query, [
self.term1_tag1_bookmarks,
])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 tag2')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='tag1 tag2'))
self.assertQueryResult(query, [
self.tag1_tag2_bookmarks,
])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 #tag2')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='tag1 #tag2'))
self.assertQueryResult(query, [
self.tag1_tag2_bookmarks,
])
@@ -261,28 +256,28 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_return_no_matches(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term3')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term3'))
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.user, self.profile, 'term1 term3')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term1 term3'))
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag2')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term1 #tag2'))
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.user, self.profile, '#tag3')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='#tag3'))
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmarks(self.user, self.profile, '#unused_tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='#unused_tag1'))
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #unused_tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='#tag1 #unused_tag1'))
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmarks(self.user, self.profile, 'term1 #unused_tag1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='term1 #unused_tag1'))
self.assertQueryResult(query, [])
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
@@ -292,7 +287,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.user, self.profile, '')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [[bookmark1, bookmark2]])
@@ -303,7 +298,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_archived_bookmarks(self.user, self.profile, '')
query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [[bookmark1, bookmark2]])
@@ -318,7 +313,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
query = queries.query_bookmarks(self.user, self.profile, '')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [owned_bookmarks])
@@ -333,7 +328,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user)
query = queries.query_archived_bookmarks(self.user, self.profile, '')
query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [owned_bookmarks])
@@ -343,7 +338,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, self.profile, '!untagged')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='!untagged'))
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):
@@ -352,7 +347,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title='term2')
self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, self.profile, '!untagged term1')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='!untagged term1'))
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):
@@ -361,7 +356,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), [])
def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
@@ -370,7 +365,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged')
query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(q='!untagged'))
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self):
@@ -379,7 +374,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, title='term2')
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged term1')
query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(q='!untagged term1'))
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):
@@ -388,39 +383,79 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
query = queries.query_archived_bookmarks(self.user, self.profile,
BookmarkSearch(q=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), [])
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
unread_bookmarks = [
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
]
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
read_bookmarks = self.setup_numbered_bookmarks(5, unread=False)
query = queries.query_bookmarks(self.user, self.profile, '!unread')
# Legacy query filter
query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(q='!unread'))
self.assertCountEqual(list(query), unread_bookmarks)
# Bookmark search filter - off
query = queries.query_bookmarks(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_OFF))
self.assertCountEqual(list(query), read_bookmarks + unread_bookmarks)
# Bookmark search filter - yes
query = queries.query_bookmarks(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_YES))
self.assertCountEqual(list(query), unread_bookmarks)
# Bookmark search filter - no
query = queries.query_bookmarks(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_NO))
self.assertCountEqual(list(query), read_bookmarks)
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
unread_bookmarks = [
self.setup_bookmark(is_archived=True, unread=True),
self.setup_bookmark(is_archived=True, unread=True),
self.setup_bookmark(is_archived=True, unread=True),
]
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True, archived=True)
read_bookmarks = self.setup_numbered_bookmarks(5, unread=False, archived=True)
query = queries.query_archived_bookmarks(self.user, self.profile, '!unread')
# Legacy query filter
query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(q='!unread'))
self.assertCountEqual(list(query), unread_bookmarks)
# Bookmark search filter - off
query = queries.query_archived_bookmarks(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_OFF))
self.assertCountEqual(list(query), read_bookmarks + unread_bookmarks)
# Bookmark search filter - yes
query = queries.query_archived_bookmarks(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_YES))
self.assertCountEqual(list(query), unread_bookmarks)
# Bookmark search filter - no
query = queries.query_archived_bookmarks(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_NO))
self.assertCountEqual(list(query), read_bookmarks)
def test_query_bookmarks_filter_shared(self):
unshared_bookmarks = self.setup_numbered_bookmarks(5)
shared_bookmarks = self.setup_numbered_bookmarks(5, shared=True)
# Filter is off
search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_OFF)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), unshared_bookmarks + shared_bookmarks)
# Filter for shared
search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_SHARED)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), shared_bookmarks)
# Filter for unshared
search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_UNSHARED)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), unshared_bookmarks)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.other_bookmarks),
@@ -435,7 +470,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_single_term(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term1'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_bookmarks),
@@ -446,7 +481,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_terms(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term2 term1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term2 term1'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
@@ -455,7 +490,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_single_tag(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='#tag1'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks),
@@ -466,7 +501,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_tags(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #tag2')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='#tag1 #tag2'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
@@ -475,7 +510,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '#Tag1 #TAG2')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='#Tag1 #TAG2'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
@@ -484,7 +519,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term1 #tag1'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
@@ -496,7 +531,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
self.profile.save()
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='tag1'))
self.assertQueryResult(query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks))
def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self):
@@ -505,7 +540,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save()
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='tag1'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks),
@@ -513,17 +548,17 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks)
])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 term1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='tag1 term1'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 tag2')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='tag1 tag2'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 #tag2')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='tag1 #tag2'))
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
@@ -531,28 +566,28 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_return_no_matches(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term3')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term3'))
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 term3')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term1 term3'))
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag2')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term1 #tag2'))
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.user, self.profile, '#tag3')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='#tag3'))
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmark_tags(self.user, self.profile, '#unused_tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='#unused_tag1'))
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #unused_tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='#tag1 #unused_tag1'))
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #unused_tag1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='term1 #unused_tag1'))
self.assertQueryResult(query, [])
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
@@ -562,7 +597,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.user, self.profile, '')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [[tag1]])
@@ -572,7 +607,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, self.profile, '')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [[tag]])
@@ -583,7 +618,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [[tag2]])
@@ -593,7 +628,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [[tag]])
@@ -608,7 +643,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_bookmark_tags(self.user, self.profile, '')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
@@ -623,7 +658,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(q=''))
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
@@ -634,13 +669,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title='term1', tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='!untagged'))
self.assertCountEqual(list(query), [])
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged term1')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='!untagged term1'))
self.assertCountEqual(list(query), [])
query = queries.query_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), [])
def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):
@@ -650,15 +685,64 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, title='term1', tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged')
query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(q='!untagged'))
self.assertCountEqual(list(query), [])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged term1')
query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(q='!untagged term1'))
self.assertCountEqual(list(query), [])
query = queries.query_archived_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
query = queries.query_archived_bookmark_tags(self.user, self.profile,
BookmarkSearch(q=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), [])
def test_query_bookmark_tags_filter_unread(self):
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True, with_tags=True)
read_bookmarks = self.setup_numbered_bookmarks(5, unread=False, with_tags=True)
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
# Legacy query filter
query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(q='!unread'))
self.assertCountEqual(list(query), unread_tags)
# Bookmark search filter - off
query = queries.query_bookmark_tags(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_OFF))
self.assertCountEqual(list(query), read_tags + unread_tags)
# Bookmark search filter - yes
query = queries.query_bookmark_tags(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_YES))
self.assertCountEqual(list(query), unread_tags)
# Bookmark search filter - no
query = queries.query_bookmark_tags(self.user, self.profile,
BookmarkSearch(unread=BookmarkSearch.FILTER_UNREAD_NO))
self.assertCountEqual(list(query), read_tags)
def test_query_bookmark_tags_filter_shared(self):
unshared_bookmarks = self.setup_numbered_bookmarks(5, with_tags=True)
shared_bookmarks = self.setup_numbered_bookmarks(5, with_tags=True, shared=True)
unshared_tags = self.get_tags_from_bookmarks(unshared_bookmarks)
shared_tags = self.get_tags_from_bookmarks(shared_bookmarks)
all_tags = unshared_tags + shared_tags
# Filter is off
search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_OFF)
query = queries.query_bookmark_tags(self.user, self.profile, search)
self.assertCountEqual(list(query), all_tags)
# Filter for shared
search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_SHARED)
query = queries.query_bookmark_tags(self.user, self.profile, search)
self.assertCountEqual(list(query), shared_tags)
# Filter for unshared
search = BookmarkSearch(shared=BookmarkSearch.FILTER_SHARED_UNSHARED)
query = queries.query_bookmark_tags(self.user, self.profile, search)
self.assertCountEqual(list(query), unshared_tags)
def test_query_shared_bookmarks(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -679,14 +763,14 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user4, shared=True, tags=[tag]),
# Should return shared bookmarks from all users
query_set = queries.query_shared_bookmarks(None, self.profile, '', False)
query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(q=''), False)
self.assertQueryResult(query_set, [shared_bookmarks])
# Should respect search query
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title', False)
query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(q='test title'), False)
self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name, False)
query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(q=f'#{tag.name}'), False)
self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
def test_query_publicly_shared_bookmarks(self):
@@ -696,7 +780,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
bookmark1 = self.setup_bookmark(user=user1, shared=True)
self.setup_bookmark(user=user2, shared=True)
query_set = queries.query_shared_bookmarks(None, self.profile, '', True)
query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(q=''), True)
self.assertQueryResult(query_set, [[bookmark1]])
def test_query_shared_bookmark_tags(self):
@@ -720,7 +804,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
query_set = queries.query_shared_bookmark_tags(None, self.profile, '', False)
query_set = queries.query_shared_bookmark_tags(None, self.profile, BookmarkSearch(q=''), False)
self.assertQueryResult(query_set, [shared_tags])
@@ -734,7 +818,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
query_set = queries.query_shared_bookmark_tags(None, self.profile, '', True)
query_set = queries.query_shared_bookmark_tags(None, self.profile, BookmarkSearch(q=''), True)
self.assertQueryResult(query_set, [[tag1]])
@@ -759,11 +843,11 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
# Should return users with shared bookmarks
query_set = queries.query_shared_bookmark_users(self.profile, '', False)
query_set = queries.query_shared_bookmark_users(self.profile, BookmarkSearch(q=''), False)
self.assertQueryResult(query_set, [users_with_shared_bookmarks])
# Should respect search query
query_set = queries.query_shared_bookmark_users(self.profile, 'test title', False)
query_set = queries.query_shared_bookmark_users(self.profile, BookmarkSearch(q='test title'), False)
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
def test_query_publicly_shared_bookmark_users(self):
@@ -773,5 +857,91 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user1, shared=True)
self.setup_bookmark(user=user2, shared=True)
query_set = queries.query_shared_bookmark_users(self.profile, '', True)
query_set = queries.query_shared_bookmark_users(self.profile, BookmarkSearch(q=''), True)
self.assertQueryResult(query_set, [[user1]])
def test_sorty_by_date_added_asc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_ADDED_ASC)
bookmarks = [
self.setup_bookmark(added=timezone.datetime(2020, 1, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 2, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 3, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2023, 4, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 5, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 6, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2020, 7, 1, tzinfo=timezone.utc)),
]
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.date_added)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def test_sorty_by_date_added_desc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_ADDED_DESC)
bookmarks = [
self.setup_bookmark(added=timezone.datetime(2020, 1, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 2, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 3, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2023, 4, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 5, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 6, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2020, 7, 1, tzinfo=timezone.utc)),
]
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.date_added, reverse=True)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def setup_title_sort_data(self):
# lots of combinations to test effective title logic
bookmarks = [
self.setup_bookmark(title='a_1_1'),
self.setup_bookmark(title='A_1_2'),
self.setup_bookmark(title='b_1_1'),
self.setup_bookmark(title='B_1_2'),
self.setup_bookmark(title='', website_title='a_2_1'),
self.setup_bookmark(title='', website_title='A_2_2'),
self.setup_bookmark(title='', website_title='b_2_1'),
self.setup_bookmark(title='', website_title='B_2_2'),
self.setup_bookmark(title='', website_title='', url='a_3_1'),
self.setup_bookmark(title='', website_title='', url='A_3_2'),
self.setup_bookmark(title='', website_title='', url='b_3_1'),
self.setup_bookmark(title='', website_title='', url='B_3_2'),
self.setup_bookmark(title='a_4_1', website_title='0'),
self.setup_bookmark(title='A_4_2', website_title='0'),
self.setup_bookmark(title='b_4_1', website_title='0'),
self.setup_bookmark(title='B_4_2', website_title='0'),
self.setup_bookmark(title='a_5_1', url='0'),
self.setup_bookmark(title='A_5_2', url='0'),
self.setup_bookmark(title='b_5_1', url='0'),
self.setup_bookmark(title='B_5_2', url='0'),
self.setup_bookmark(title='', website_title='a_6_1', url='0'),
self.setup_bookmark(title='', website_title='A_6_2', url='0'),
self.setup_bookmark(title='', website_title='b_6_1', url='0'),
self.setup_bookmark(title='', website_title='B_6_2', url='0'),
self.setup_bookmark(title='a_7_1', website_title='0', url='0'),
self.setup_bookmark(title='A_7_2', website_title='0', url='0'),
self.setup_bookmark(title='b_7_1', website_title='0', url='0'),
self.setup_bookmark(title='B_7_2', website_title='0', url='0'),
]
return bookmarks
def test_sort_by_title_asc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_ASC)
bookmarks = self.setup_title_sort_data()
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower())
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def test_sort_by_title_desc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC)
bookmarks = self.setup_title_sort_data()
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower(), reverse=True)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)

View File

@@ -101,6 +101,18 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
],
])
def test_tag_url_respects_search_options(self):
tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1')
rendered_template = self.render_template(url='/test?q=term1&sort=title_asc&page=2')
self.assertInHTML('''
<a href="?q=term1+%23tag1&sort=title_asc&page=2" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span>
</a>
''', rendered_template)
def test_selected_tags(self):
tags = [
self.setup_tag(name='tag1'),
@@ -191,7 +203,7 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
</a>
''', rendered_template, count=1)
def test_selected_tag_url_keeps_other_search_terms(self):
def test_selected_tag_url_keeps_other_query_terms(self):
tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1', description='term2')
@@ -204,6 +216,19 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
</a>
''', rendered_template)
def test_selected_tag_url_respects_search_options(self):
tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1', description='term2')
rendered_template = self.render_template(url='/test?q=term1 %23tag1 term2&sort=title_asc&page=2')
self.assertInHTML('''
<a href="?q=term1+term2&sort=title_asc&page=2"
class="text-bold mr-2">
<span>-tag1</span>
</a>
''', rendered_template)
def test_selected_tags_are_excluded_from_groups(self):
tags = [
self.setup_tag(name='tag1'),

View File

@@ -2,7 +2,7 @@ from django.db.models import QuerySet
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkFilters, User
from bookmarks.models import BookmarkSearch, User
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -12,32 +12,42 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
filters = BookmarkFilters(request)
search = BookmarkSearch.from_request(request.GET)
context = RequestContext(request, {
'request': request,
'filters': filters,
'search': search,
'users': users,
})
template_to_render = Template(
'{% load bookmarks %}'
'{% user_select filters users %}'
'{% user_select search users %}'
)
return template_to_render.render(context)
def assertUserOption(self, html: str, user: User, selected: bool = False):
self.assertInHTML(f'''
<option value="{user.username}"
{'selected' if selected else ''}
data-is-user-option>
<option value="{user.username}" {'selected' if selected else ''}>
{user.username}
</option>
''', html)
def assertHiddenInput(self, html: str, name: str, value: str = None):
needle = f'<input type="hidden" name="{name}"'
if value is not None:
needle += f' value="{value}"'
self.assertIn(needle, html)
def assertNoHiddenInput(self, html: str, name: str):
needle = f'<input type="hidden" name="{name}"'
self.assertNotIn(needle, html)
def test_empty_option(self):
rendered_template = self.render_template('/test')
self.assertInHTML(f'''
<option value="">Everyone</option>
<option value="" selected="">Everyone</option>
''', rendered_template)
def test_render_user_options(self):
@@ -60,19 +70,23 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
self.assertUserOption(rendered_template, user1, True)
def test_render_hidden_inputs_for_filter_params(self):
# Should render hidden inputs if query param exists
url = '/test?q=foo&user=john'
def test_hidden_inputs(self):
# Without params
url = '/test'
rendered_template = self.render_template(url)
self.assertInHTML('''
<input type="hidden" name="q" value="foo">
''', rendered_template)
self.assertNoHiddenInput(rendered_template, 'user')
self.assertNoHiddenInput(rendered_template, 'q')
self.assertNoHiddenInput(rendered_template, 'sort')
self.assertNoHiddenInput(rendered_template, 'shared')
self.assertNoHiddenInput(rendered_template, 'unread')
# Should not render hidden inputs if query param does not exist
url = '/test?user=john'
# With params
url = '/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes'
rendered_template = self.render_template(url)
self.assertInHTML('''
<input type="hidden" name="q" value="foo">
''', rendered_template, count=0)
self.assertNoHiddenInput(rendered_template, 'user')
self.assertHiddenInput(rendered_template, 'q', 'foo')
self.assertHiddenInput(rendered_template, 'sort', 'title_asc')
self.assertHiddenInput(rendered_template, 'shared', 'yes')
self.assertHiddenInput(rendered_template, 'unread', 'yes')

View File

@@ -1,11 +1,13 @@
import urllib.parse
from django.contrib.auth.decorators import login_required
from django.db.models import QuerySet
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import render
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string
from bookmarks.models import Bookmark, BookmarkForm, BookmarkSearch, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
@@ -17,6 +19,9 @@ _default_page_size = 30
@login_required
def index(request):
if request.method == 'POST':
return search_action(request)
bookmark_list = contexts.ActiveBookmarkListContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request)
return render(request, 'bookmarks/index.html', {
@@ -27,6 +32,9 @@ def index(request):
@login_required
def archived(request):
if request.method == 'POST':
return search_action(request)
bookmark_list = contexts.ArchivedBookmarkListContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request)
return render(request, 'bookmarks/archive.html', {
@@ -36,11 +44,13 @@ def archived(request):
def shared(request):
filters = BookmarkFilters(request)
if request.method == 'POST':
return search_action(request)
bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request)
public_only = not request.user.is_authenticated
users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only)
users = queries.query_shared_bookmark_users(request.user_profile, bookmark_list.search, public_only)
return render(request, 'bookmarks/shared.html', {
'bookmark_list': bookmark_list,
'tag_cloud': tag_cloud,
@@ -48,6 +58,23 @@ def shared(request):
})
def search_action(request):
if 'save' in request.POST:
if not request.user.is_authenticated:
return HttpResponseForbidden()
search = BookmarkSearch.from_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)
base_url = request.path
query_params = search.query_params
query_string = urllib.parse.urlencode(query_params)
url = base_url if not query_string else base_url + '?' + query_string
return HttpResponseRedirect(url)
def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings
@@ -169,15 +196,15 @@ def mark_as_read(request, bookmark_id: int):
@login_required
def index_action(request):
filters = BookmarkFilters(request)
query = queries.query_bookmarks(request.user, request.user_profile, filters.query)
search = BookmarkSearch.from_request(request.GET)
query = queries.query_bookmarks(request.user, request.user_profile, search)
return action(request, query)
@login_required
def archived_action(request):
filters = BookmarkFilters(request)
query = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query)
search = BookmarkSearch.from_request(request.GET)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
return action(request, query)

View File

@@ -7,8 +7,8 @@ from django.db import models
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkFilters, User, UserProfile, Tag
from bookmarks import utils
from bookmarks.models import Bookmark, BookmarkSearch, BookmarkSearchForm, User, UserProfile, Tag
DEFAULT_PAGE_SIZE = 30
@@ -54,11 +54,12 @@ class BookmarkItem:
class BookmarkListContext:
def __init__(self, request: WSGIRequest) -> None:
self.request = request
self.filters = BookmarkFilters(self.request)
user = request.user
user_profile = request.user_profile
self.request = request
self.search = BookmarkSearch.from_request(self.request.GET, user_profile.search_preferences)
query_set = self.get_bookmark_query_set()
page_number = request.GET.get('page')
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
@@ -71,29 +72,37 @@ class BookmarkListContext:
self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count
self.return_url = self.generate_return_url(page_number)
self.return_url = self.generate_return_url(self.search, self.get_base_url(), page_number)
self.action_url = self.generate_action_url(self.search, self.get_base_action_url(), self.return_url)
self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display
self.show_url = user_profile.display_url
self.show_favicons = user_profile.enable_favicons
self.show_notes = user_profile.permanent_notes
def generate_return_url(self, page: int):
base_url = self.get_base_url()
url_query = {}
if self.filters.query:
url_query['q'] = self.filters.query
if self.filters.user:
url_query['user'] = self.filters.user
@staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
query_params = search.query_params
if page is not None:
url_query['page'] = page
url_params = urllib.parse.urlencode(url_query)
return_url = base_url if url_params == '' else base_url + '?' + url_params
return urllib.parse.quote_plus(return_url)
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
def get_base_url(self):
raise Exception(f'Must be implemented by subclass')
def get_base_action_url(self):
raise Exception(f'Must be implemented by subclass')
def get_bookmark_query_set(self):
raise Exception(f'Must be implemented by subclass')
@@ -102,32 +111,41 @@ class ActiveBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:index')
def get_base_action_url(self):
return reverse('bookmarks:index.action')
def get_bookmark_query_set(self):
return queries.query_bookmarks(self.request.user,
self.request.user_profile,
self.filters.query)
self.search)
class ArchivedBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:archived')
def get_base_action_url(self):
return reverse('bookmarks:archived.action')
def get_bookmark_query_set(self):
return queries.query_archived_bookmarks(self.request.user,
self.request.user_profile,
self.filters.query)
self.search)
class SharedBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:shared')
def get_base_action_url(self):
return reverse('bookmarks:shared.action')
def get_bookmark_query_set(self):
user = User.objects.filter(username=self.filters.user).first()
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(user,
self.request.user_profile,
self.filters.query,
self.search,
public_only)
@@ -158,8 +176,10 @@ class TagGroup:
class TagCloudContext:
def __init__(self, request: WSGIRequest) -> None:
user_profile = request.user_profile
self.request = request
self.filters = BookmarkFilters(self.request)
self.search = BookmarkSearch.from_request(self.request.GET, user_profile.search_preferences)
query_set = self.get_tag_query_set()
tags = list(query_set)
@@ -179,7 +199,7 @@ class TagCloudContext:
raise Exception(f'Must be implemented by subclass')
def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.filters.query)
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']
@@ -192,21 +212,21 @@ class ActiveTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
return queries.query_bookmark_tags(self.request.user,
self.request.user_profile,
self.filters.query)
self.search)
class ArchivedTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
return queries.query_archived_bookmark_tags(self.request.user,
self.request.user_profile,
self.filters.query)
self.search)
class SharedTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
user = User.objects.filter(username=self.filters.user).first()
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(user,
self.request.user_profile,
self.filters.query,
self.search,
public_only)

View File

@@ -12,7 +12,7 @@ from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm, FeedToken
from bookmarks.models import BookmarkSearch, UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
@@ -136,7 +136,7 @@ def bookmark_import(request):
def bookmark_export(request):
# noinspection PyBroadException
try:
bookmarks = list(query_bookmarks(request.user, request.user_profile, ''))
bookmarks = list(query_bookmarks(request.user, request.user_profile, BookmarkSearch()))
# Prefetch tags to prevent n+1 queries
prefetch_related_objects(bookmarks, 'tags')
file_content = exporter.export_netscape_html(bookmarks)

View File

@@ -236,3 +236,35 @@ Example payload:
"name": "example"
}
```
### User
**Profile**
```
GET /api/user/profile/
```
User preferences.
Example response:
```json
{
"theme": "auto",
"bookmark_date_display": "relative",
"bookmark_link_target": "_blank",
"web_archive_integration": "enabled",
"tag_search": "lax",
"enable_sharing": true,
"enable_public_sharing": true,
"enable_favicons": false,
"display_url": false,
"permanent_notes": false,
"search_preferences": {
"sort": "title_asc",
"shared": "off",
"unread": "off"
}
}
```

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.21.0",
"version": "1.22.0",
"description": "",
"main": "index.js",
"scripts": {

29
scripts/run-postgres.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Remove previous container if exists
docker rm -f linkding-postgres-test || true
# Run postgres container
docker run -d \
-e POSTGRES_DB=linkding \
-e POSTGRES_USER=linkding \
-e POSTGRES_PASSWORD=linkding \
-p 5432:5432 \
--name linkding-postgres-test \
postgres
# Wait until postgres has started
echo >&2 "$(date +%Y%m%dt%H%M%S) Waiting for postgres container"
sleep 15
# Start linkding dev server
export LD_DB_ENGINE=postgres
export LD_DB_USER=linkding
export LD_DB_PASSWORD=linkding
export LD_SUPERUSER_NAME=admin
export LD_SUPERUSER_PASSWORD=admin
python manage.py migrate
python manage.py create_initial_superuser
python manage.py runserver

View File

@@ -231,6 +231,10 @@ DATABASES = {
'default': default_database
}
SQLITE_ICU_EXTENSION_PATH = './libicu.so'
USE_SQLITE = default_database['ENGINE'] == 'django.db.backends.sqlite3'
USE_SQLITE_ICU_EXTENSION = USE_SQLITE and os.path.exists(SQLITE_ICU_EXTENSION_PATH)
# Favicons
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32'
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)

View File

@@ -1 +1 @@
1.21.0
1.22.0