mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-01 23:43:14 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7600fe87f9 | ||
|
|
f756e28daf | ||
|
|
1e10d7eb4a | ||
|
|
ccf8e03571 | ||
|
|
30708cc5e3 | ||
|
|
3e4f08f51b | ||
|
|
41f79e35a0 | ||
|
|
4a2642f16c | ||
|
|
e70315ed26 | ||
|
|
3e36f90b38 | ||
|
|
28acf3299c | ||
|
|
ffcc40b227 | ||
|
|
b7ddee2d93 | ||
|
|
d9c4ddb4d7 | ||
|
|
0975914a86 | ||
|
|
0c50906056 | ||
|
|
54c79225ce | ||
|
|
a382e171ad | ||
|
|
9b8929e697 | ||
|
|
5b8ff86029 | ||
|
|
e2e5930985 | ||
|
|
2ceac9a87d | ||
|
|
bca9bf9b11 | ||
|
|
768f1346a3 |
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## v1.22.0 (01/10/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix case-insensitive search for unicode characters in SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/520
|
||||
* Add sort option to bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/522
|
||||
* Add button to show tags on smaller screens by @sissbruecker in https://github.com/sissbruecker/linkding/pull/529
|
||||
* Make code blocks in notes scrollable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/530
|
||||
* Add filter for shared state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/531
|
||||
* Add support for exporting/importing bookmark notes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/532
|
||||
* Add filter for unread state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/535
|
||||
* Allow saving search preferences by @sissbruecker in https://github.com/sissbruecker/linkding/pull/540
|
||||
* Add user profile endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/541
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.22.0
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM node:18.13.0-alpine AS node-build
|
||||
FROM node:18.18.0-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
232
bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
Normal file
232
bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
def setup_test_data(self):
|
||||
self.setup_numbered_bookmarks(50)
|
||||
self.setup_numbered_bookmarks(50, archived=True)
|
||||
self.setup_numbered_bookmarks(50, prefix='foo')
|
||||
self.setup_numbered_bookmarks(50, archived=True, prefix='foo')
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_active_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_active_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index') + '?q=foo', p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived') + '?q=foo', p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_select_all_toggles_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
page = self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
|
||||
self.assertEqual(6, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).to_be_checked()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
def test_select_all_shows_select_across(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_visible()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
def test_select_across_is_unchecked_when_toggling_all(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
# Show select across, check it
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling select all
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_select_across_is_unchecked_when_toggling_bookmark(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
# Show select across, check it
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling a single bookmark
|
||||
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_execute_resets_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
page = self.open(url, p)
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
# Get reference for bookmark list
|
||||
bookmark_list = page.locator('ul[ld-bookmark-list]')
|
||||
|
||||
# Execute bulk action
|
||||
self.select_bulk_action('Mark as unread')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
|
||||
self.assertEqual(31, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
# Toggle select all and verify select across is reset
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_update_select_across_bookmark_count(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (100 bookmarks)')).to_be_visible()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (70 bookmarks)')).to_be_visible()
|
||||
@@ -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='-')
|
||||
@@ -119,10 +134,27 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click()
|
||||
expect(self.locate_bookmark('Bookmark 2')).to_have_class('unread')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Unread').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic')
|
||||
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('unread')
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_unshare(self):
|
||||
self.setup_fixture()
|
||||
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
|
||||
bookmark2.shared = True
|
||||
bookmark2.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).to_have_class('shared')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Shared').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('shared')
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_bulk_archive(self):
|
||||
@@ -133,7 +165,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Archive').click()
|
||||
self.select_bulk_action('Archive')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
@@ -148,7 +181,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Delete').click()
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
@@ -180,7 +214,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_bulk_archive(self):
|
||||
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
@@ -188,7 +222,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Archive').click()
|
||||
self.select_bulk_action('Unarchive')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
@@ -203,7 +238,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Delete').click()
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
|
||||
@@ -41,5 +41,14 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
def locate_bulk_edit_bar(self):
|
||||
return self.page.locator('.bulk-edit-bar')
|
||||
|
||||
def locate_bulk_edit_select_all(self):
|
||||
return self.locate_bulk_edit_bar().locator('label[ld-bulk-edit-checkbox][all]')
|
||||
|
||||
def locate_bulk_edit_select_across(self):
|
||||
return self.locate_bulk_edit_bar().locator('label.select-across')
|
||||
|
||||
def locate_bulk_edit_toggle(self):
|
||||
return self.page.get_by_title('Bulk edit')
|
||||
|
||||
def select_bulk_action(self, value: str):
|
||||
return self.locate_bulk_edit_bar().locator('select[name="bulk_action"]').select_option(value)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,18 @@ class BookmarkPage {
|
||||
swap(this.bookmarkList, bookmarkListHtml);
|
||||
swap(this.tagCloud, tagCloudHtml);
|
||||
|
||||
// Dispatch list updated event
|
||||
const listElement = this.bookmarkList.querySelector(
|
||||
"ul[data-bookmarks-total]",
|
||||
);
|
||||
const bookmarksTotal =
|
||||
(listElement && listElement.dataset.bookmarksTotal) || 0;
|
||||
|
||||
this.bookmarkList.dispatchEvent(
|
||||
new CustomEvent("bookmark-list-updated", { bubbles: true }),
|
||||
new CustomEvent("bookmark-list-updated", {
|
||||
bubbles: true,
|
||||
detail: { bookmarksTotal },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ class BulkEdit {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.active = false;
|
||||
this.actionSelect = element.querySelector("select[name='bulk_action']");
|
||||
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = element.querySelector("label.select-across");
|
||||
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-active",
|
||||
@@ -21,6 +24,11 @@ class BulkEdit {
|
||||
"bookmark-list-updated",
|
||||
this.onListUpdated.bind(this),
|
||||
);
|
||||
|
||||
this.actionSelect.addEventListener(
|
||||
"change",
|
||||
this.onActionSelected.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
get allCheckbox() {
|
||||
@@ -48,23 +56,56 @@ class BulkEdit {
|
||||
}
|
||||
|
||||
onToggleBookmark() {
|
||||
this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.allCheckbox.checked = allChecked;
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onToggleAll() {
|
||||
const checked = this.allCheckbox.checked;
|
||||
const allChecked = this.allCheckbox.checked;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = checked;
|
||||
checkbox.checked = allChecked;
|
||||
});
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onListUpdated() {
|
||||
onActionSelected() {
|
||||
const action = this.actionSelect.value;
|
||||
|
||||
if (action === "bulk_tag" || action === "bulk_untag") {
|
||||
this.tagAutoComplete.classList.remove("d-none");
|
||||
} else {
|
||||
this.tagAutoComplete.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
onListUpdated(event) {
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const total = event.detail.bookmarksTotal;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
} else {
|
||||
this.selectAcross.classList.add("d-none");
|
||||
this.selectAcross.querySelector("input").checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.allCheckbox.checked = false;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.updateSelectAcross(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,31 @@ class ConfirmButtonBehavior {
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.button.getAttribute("confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
);
|
||||
iconElement.style.width = "16px";
|
||||
iconElement.style.height = "16px";
|
||||
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
||||
container.append(iconElement);
|
||||
}
|
||||
|
||||
const question = this.button.getAttribute("confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const cancelButton = document.createElement(this.button.nodeName);
|
||||
cancelButton.type = "button";
|
||||
cancelButton.innerText = "Cancel";
|
||||
cancelButton.innerText = question ? "No" : "Cancel";
|
||||
cancelButton.className = "btn btn-link btn-sm mr-1";
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
@@ -26,12 +48,10 @@ class ConfirmButtonBehavior {
|
||||
confirmButton.type = this.button.dataset.type;
|
||||
confirmButton.name = this.button.dataset.name;
|
||||
confirmButton.value = this.button.dataset.value;
|
||||
confirmButton.innerText = "Confirm";
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = "btn btn-link btn-sm";
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
container.append(cancelButton, confirmButton);
|
||||
this.container = container;
|
||||
|
||||
|
||||
65
bookmarks/frontend/behaviors/modal.js
Normal file
65
bookmarks/frontend/behaviors/modal.js
Normal 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);
|
||||
@@ -14,12 +14,13 @@ class TagAutocomplete {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
value: element.value,
|
||||
placeholder: element.getAttribute("placeholder") || "",
|
||||
apiClient: apiClient,
|
||||
variant: element.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
||||
element.replaceWith(wrapper);
|
||||
element.replaceWith(wrapper.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
export let tags;
|
||||
export let mode = '';
|
||||
export let apiClient;
|
||||
export let filters;
|
||||
export let search;
|
||||
export let linkTarget = '_blank';
|
||||
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
@@ -102,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,
|
||||
@@ -114,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)
|
||||
@@ -131,7 +132,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
updateSuggestions(search, bookmarks, tagSuggestions)
|
||||
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
|
||||
|
||||
if (hasSuggestions()) {
|
||||
open()
|
||||
@@ -142,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,
|
||||
]
|
||||
}
|
||||
@@ -164,7 +165,7 @@
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'bookmark') {
|
||||
window.open(suggestion.bookmark.url, '_blank')
|
||||
window.open(suggestion.bookmark.url, linkTarget)
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'tag') {
|
||||
@@ -209,26 +210,18 @@
|
||||
{#each suggestions.tags as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</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)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -239,11 +232,7 @@
|
||||
{#each suggestions.bookmarks as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let apiClient;
|
||||
export let variant = 'default';
|
||||
|
||||
@@ -118,7 +119,7 @@
|
||||
<!-- autocomplete input container -->
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
@@ -131,11 +132,7 @@
|
||||
{#each suggestions as tag,i}
|
||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{tag.name}
|
||||
</div>
|
||||
</div>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -156,6 +153,7 @@
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
18
bookmarks/migrations/0025_userprofile_search_preferences.py
Normal file
18
bookmarks/migrations/0025_userprofile_search_preferences.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -119,6 +119,34 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
|
||||
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||
|
||||
|
||||
def mark_bookmarks_as_read(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(unread=False, date_modified=timezone.now())
|
||||
|
||||
|
||||
def mark_bookmarks_as_unread(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(unread=True, date_modified=timezone.now())
|
||||
|
||||
|
||||
def share_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(shared=True, date_modified=timezone.now())
|
||||
|
||||
|
||||
def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(shared=False, date_modified=timezone.now())
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
|
||||
@@ -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');")
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.auth-page {
|
||||
> .columns {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: $size-sm) {
|
||||
// High horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 24px;
|
||||
// Horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: $unit-10;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 $unit-3;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
@@ -23,97 +38,107 @@ header .toasts {
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
/* Shared components */
|
||||
|
||||
.navbar-brand {
|
||||
// Content area component
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-6;
|
||||
padding-bottom: $unit-2;
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.8rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
margin: 0 0 0 8px;
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
}
|
||||
}
|
||||
|
||||
/* Overrides */
|
||||
// Confirm button component
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-1;
|
||||
color: $error-color !important;
|
||||
|
||||
// Reduce heading sizes
|
||||
h1 {
|
||||
font-size: inherit;
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: .85rem;
|
||||
/* Additional utilities */
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Increase spacing between columns
|
||||
.container > .columns > .column:not(:first-child) {
|
||||
padding-left: 2rem;
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
.mb-4 {
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.btn-wide {
|
||||
padding-left: $unit-6;
|
||||
padding-right: $unit-6;
|
||||
}
|
||||
|
||||
.btn.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-h;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
bookmarks/styles/bookmark-form.scss
Normal file
50
bookmarks/styles/bookmark-form.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right > input, .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,37 @@
|
||||
/* Bookmark search box */
|
||||
.bookmarks-page .search {
|
||||
$searchbox-width: 180px;
|
||||
$searchbox-width-md: 300px;
|
||||
$searchbox-height: 1.8rem;
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: $unit-10;
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
@@ -30,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,9 +130,14 @@ li[ld-bookmark-item] {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.title img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: $unit-h;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
@@ -80,11 +153,18 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
.actions, .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
column-gap: $unit-2;
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
.extra-actions {
|
||||
width: 100%;
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -108,24 +188,17 @@ li[ld-bookmark-item] {
|
||||
.separator {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.toggle-notes {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: 1rem;
|
||||
margin-top: $unit-4;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
|
||||
.selected-tags {
|
||||
margin-bottom: 0.8rem;
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
a, a:visited:hover {
|
||||
color: $error-color;
|
||||
@@ -139,7 +212,7 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 0.4rem;
|
||||
margin-bottom: $unit-2;
|
||||
}
|
||||
|
||||
.highlight-char {
|
||||
@@ -149,64 +222,13 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right > input, .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
.notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: 4px 0;
|
||||
overflow: auto;
|
||||
margin: $unit-1 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
@@ -218,11 +240,11 @@ ul.bookmark-list {
|
||||
/* Bookmark notes markdown styles */
|
||||
ul.bookmark-list .notes-content {
|
||||
& {
|
||||
padding: 0.4rem 0.6rem;
|
||||
padding: $unit-2 $unit-3;
|
||||
}
|
||||
|
||||
p, ul, ol, pre, blockquote {
|
||||
margin: 0 0 0.4rem 0;
|
||||
margin: 0 0 $unit-2 0;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
@@ -234,17 +256,18 @@ ul.bookmark-list .notes-content {
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 0.8rem;
|
||||
margin-left: $unit-4;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: 0.2rem;
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.2rem 0.4rem;
|
||||
padding: $unit-1 $unit-2;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: 0.2rem;
|
||||
border-radius: $unit-1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
@@ -268,9 +291,9 @@ $bulk-edit-transition-duration: 400ms;
|
||||
|
||||
[ld-bulk-edit] {
|
||||
.bulk-edit-bar {
|
||||
margin-top: -17px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: -1px;
|
||||
margin-left: -$bulk-edit-bar-offset;
|
||||
margin-bottom: $unit-4;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $bulk-edit-transition-duration;
|
||||
@@ -309,7 +332,7 @@ $bulk-edit-transition-duration: 400ms;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
.form-icon {
|
||||
top: 0.2rem;
|
||||
top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,9 +345,9 @@ $bulk-edit-transition-duration: 400ms;
|
||||
.bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
padding: $unit-1 0;
|
||||
border-top: solid 1px $border-color;
|
||||
gap: 8px;
|
||||
gap: $unit-2;
|
||||
|
||||
button {
|
||||
padding: 0 !important;
|
||||
@@ -334,10 +357,15 @@ $bulk-edit-transition-duration: 400ms;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
> input, .form-autocomplete {
|
||||
> input, .form-autocomplete, select {
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/* Dark theme overrides */
|
||||
|
||||
/* Buttons */
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring*/
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: $dt-primary-button-color;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
||||
108
bookmarks/styles/responsive.scss
Normal file
108
bookmarks/styles/responsive.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: $size-lg;
|
||||
}
|
||||
|
||||
.show-sm,
|
||||
.show-md {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.width-25 {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.width-50 {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.width-75 {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.width-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
--grid-columns: 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||
grid-gap: $unit-4;
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col-1 {
|
||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
grid-column: unquote("span min(2, var(--grid-columns))");
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
grid-column: unquote("span min(3, var(--grid-columns))");
|
||||
}
|
||||
|
||||
@media (max-width: $size-md) {
|
||||
.hide-md {
|
||||
display: none !important;
|
||||
}
|
||||
.show-md {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.width-md-25 {
|
||||
width: 25%;
|
||||
}
|
||||
.width-md-50 {
|
||||
width: 50%;
|
||||
}
|
||||
.width-md-75 {
|
||||
width: 75%;
|
||||
}
|
||||
.width-md-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columns-md-1 {
|
||||
--grid-columns: 1;
|
||||
}
|
||||
.columns-md-2 {
|
||||
--grid-columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
.hide-sm {
|
||||
display: none !important;
|
||||
}
|
||||
.show-sm {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.width-sm-25 {
|
||||
width: 25%;
|
||||
}
|
||||
.width-sm-50 {
|
||||
width: 50%;
|
||||
}
|
||||
.width-sm-75 {
|
||||
width: 75%;
|
||||
}
|
||||
.width-sm-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columns-sm-1 {
|
||||
--grid-columns: 1;
|
||||
}
|
||||
.columns-sm-2 {
|
||||
--grid-columns: 2;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: $unit-12;
|
||||
|
||||
h2 {
|
||||
font-size: 1.0rem;
|
||||
margin-bottom: 0.8rem;
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Content area component
|
||||
section.content-area {
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
line-height: 1.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm button component
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
span.confirmation .btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
137
bookmarks/styles/spectre.scss
Normal file
137
bookmarks/styles/spectre.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// Customized Spectre CSS imports, removing modules that are not used
|
||||
// See node_modules/spectre.css/src/spectre.scss for the original version
|
||||
|
||||
// Variables and mixins
|
||||
@import "../../node_modules/spectre.css/src/variables";
|
||||
@import "../../node_modules/spectre.css/src/mixins";
|
||||
|
||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
||||
// Reset and dependencies
|
||||
@import "../../node_modules/spectre.css/src/normalize";
|
||||
@import "../../node_modules/spectre.css/src/base";
|
||||
|
||||
// Elements
|
||||
@import "../../node_modules/spectre.css/src/typography";
|
||||
@import "../../node_modules/spectre.css/src/asian";
|
||||
@import "../../node_modules/spectre.css/src/tables";
|
||||
@import "../../node_modules/spectre.css/src/buttons";
|
||||
@import "../../node_modules/spectre.css/src/forms";
|
||||
@import "../../node_modules/spectre.css/src/labels";
|
||||
@import "../../node_modules/spectre.css/src/codes";
|
||||
@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";
|
||||
@import "../../node_modules/spectre.css/src/tooltips";
|
||||
|
||||
// Utility classes
|
||||
@import "../../node_modules/spectre.css/src/animations";
|
||||
@import "../../node_modules/spectre.css/src/utilities";
|
||||
|
||||
// Auto-complete component
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
|
||||
|
||||
/* Spectre overrides / fixes */
|
||||
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
|
||||
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
|
||||
.btn {
|
||||
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;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix padding for first menu item
|
||||
ul.menu li:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.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
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,53 @@
|
||||
@import "variables-dark";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "responsive";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "auth";
|
||||
|
||||
// Dark theme overrides
|
||||
@import "dark";
|
||||
/* Dark theme overrides */
|
||||
|
||||
// Buttons
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus ring
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-input:not(:placeholder-shown):invalid,
|
||||
.form-input:not(:placeholder-shown):invalid:focus,
|
||||
.has-error .form-input,
|
||||
.form-input.is-error,
|
||||
.has-error .form-select,
|
||||
.form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-button-color;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
@import "variables-light";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "responsive";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "auth";
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -4,25 +4,28 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns"
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bulk-edit
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<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:action' %}?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 mode='archive' %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
@@ -31,7 +34,7 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
|
||||
@@ -5,125 +5,117 @@
|
||||
{% if bookmark_list.is_empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}">
|
||||
{% for bookmark in bookmark_list.bookmarks_page %}
|
||||
<li ld-bookmark-item>
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<label ld-bulk-edit-checkbox class="form-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title">
|
||||
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||
{% if bookmark.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
{{ bookmark.resolved_title }}
|
||||
{{ bookmark_item.title }}
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
{{ bookmark.url }}
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span>
|
||||
{% for tag_name in bookmark.tag_names %}
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||
{% if bookmark.resolved_description %}
|
||||
<span>{{ bookmark.resolved_description }}</span>
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if bookmark.notes %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
{% markdown bookmark.notes %}
|
||||
{% markdown bookmark_item.notes %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray text-sm">
|
||||
{% if bookmark_list.date_display == 'relative' %}
|
||||
<span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span class="separator">|</span>
|
||||
{% endif %}
|
||||
{% if bookmark_list.date_display == 'absolute' %}
|
||||
<span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="separator">|</span>
|
||||
{% endif %}
|
||||
{% if bookmark.owner == request.user %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||
<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
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% if bookmark.unread %}
|
||||
<span class="separator">|</span>
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Mark as read
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.notes and not bookmark_list.show_notes %}
|
||||
<span class="separator">|</span>
|
||||
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16"
|
||||
height="16"
|
||||
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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</svg>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="separator hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -3,32 +3,37 @@
|
||||
<div class="bulk-edit-bar">
|
||||
<div class="bulk-edit-actions bg-gray">
|
||||
<label ld-bulk-edit-checkbox all class="form-checkbox">
|
||||
<input type="checkbox" style="display: none">
|
||||
<input type="checkbox">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if mode == 'archive' %}
|
||||
<button ld-confirm-button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm"
|
||||
title="Unarchive selected bookmarks">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button ld-confirm-button type="submit" name="bulk_archive" class="btn btn-link btn-sm"
|
||||
title="Archive selected bookmarks">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-dark">•</span>
|
||||
<button ld-confirm-button type="submit" name="bulk_delete" class="btn btn-link btn-sm"
|
||||
title="Delete selected bookmarks">Delete
|
||||
</button>
|
||||
<span class="text-sm text-gray-dark">•</span>
|
||||
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
|
||||
<input ld-tag-autocomplete variant="small"
|
||||
name="bulk_tag_string" class="form-input input-sm" placeholder=" ">
|
||||
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
|
||||
title="Add tags to selected bookmarks">Add
|
||||
</button>
|
||||
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
|
||||
title="Remove tags from selected bookmarks">Remove
|
||||
</button>
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
{% if not 'bulk_archive' in disable_actions %}
|
||||
<option value="bulk_archive">Archive</option>
|
||||
{% endif %}
|
||||
{% if not 'bulk_unarchive' in disable_actions %}
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
{% endif %}
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none">
|
||||
<input ld-tag-autocomplete variant="small"
|
||||
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
|
||||
</div>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
|
||||
|
||||
<label class="form-checkbox select-across d-none">
|
||||
<input type="checkbox" name="bulk_select_across">
|
||||
<i class="form-icon"></i>
|
||||
All pages (<span class="total">{{ bookmark_list.bookmarks_total }}</span> bookmarks)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endhtmlmin %}
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
class="width-50 width-md-100" novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,25 +4,28 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns"
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bulk-edit
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<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:action' %}?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 mode='default' %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
@@ -31,8 +34,8 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<div class="content-area-header">
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header mb-4">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div class="tag-cloud-container">
|
||||
|
||||
@@ -30,9 +30,69 @@
|
||||
{% endif %}
|
||||
</head>
|
||||
<body ld-global-shortcuts>
|
||||
<header>
|
||||
|
||||
<div class="d-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-unread" 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="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
|
||||
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
|
||||
<path d="M3 6l0 13"></path>
|
||||
<path d="M12 6l0 13"></path>
|
||||
<path d="M21 6l0 13"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-read" 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="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
|
||||
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
|
||||
<path d="M3 6v13"></path>
|
||||
<path d="M12 6v2m0 4v7"></path>
|
||||
<path d="M21 6v11"></path>
|
||||
<path d="M3 3l18 18"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-share" 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="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M8.7 10.7l6.6 -3.4"></path>
|
||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-unshare" 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="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
|
||||
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
|
||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||
<path d="M3 3l18 18"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-note" 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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts container grid-lg">
|
||||
<div class="toasts">
|
||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
@@ -44,27 +104,21 @@
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="navbar container grid-lg">
|
||||
<section class="navbar-section">
|
||||
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>linkding</h1>
|
||||
</a>
|
||||
</section>
|
||||
<div class="d-flex justify-between">
|
||||
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>LINKDING</h1>
|
||||
</a>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
<section class="navbar-section">
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
</section>
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% elif has_public_shares %}
|
||||
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
|
||||
<section class="navbar-section">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
||||
</section>
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="content container grid-lg">
|
||||
<div class="content container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" class="width-50 width-md-100" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,43 +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>
|
||||
@@ -4,20 +4,22 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns"
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header">
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% 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:action' %}?return_url={{ bookmark_list.return_url }}"
|
||||
<form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -28,12 +30,12 @@
|
||||
</section>
|
||||
|
||||
{# Filters #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>User</h2>
|
||||
</div>
|
||||
<div>
|
||||
{% user_select filters users %}
|
||||
{% user_select bookmark_list.search users %}
|
||||
<br>
|
||||
</div>
|
||||
<div class="content-area-header">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,13 +4,5 @@
|
||||
{% block title %}Registration complete{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<p>Registration complete. You can now use the application.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>Registration complete. You can now use the application.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,41 +4,35 @@
|
||||
{% block title %}Registration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Register</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'django_registration_register' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.username }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
|
||||
{{ form.email|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
|
||||
{{ form.password1|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.password1 }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
|
||||
{{ form.password2|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.password2 }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" value="Register" class="btn btn-primary col-md-12">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Register</h2>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'django_registration_register' %}" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.username }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
|
||||
{{ form.email|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
|
||||
{{ form.password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.password1 }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
|
||||
{{ form.password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.password2 }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" value="Register" class="btn btn-primary btn-wide">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,46 +4,35 @@
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Login</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.errors %}
|
||||
<div class="form-group has-error">
|
||||
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="columns">
|
||||
<div class="column col-3">
|
||||
<input type="submit" value="Login" class="btn btn-primary">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</div>
|
||||
{% if allow_registration %}
|
||||
<div class="column col-auto col-ml-auto">
|
||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Login</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.errors %}
|
||||
<div class="form-group has-error">
|
||||
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="d-flex justify-between">
|
||||
<input type="submit" value="Login" class="btn btn-primary btn-wide">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% if allow_registration %}
|
||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,18 +4,12 @@
|
||||
{% block title %}Password changed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Password Changed</h2>
|
||||
</div>
|
||||
<p class="text-success">
|
||||
Your password was changed successfully.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Password Changed</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-success">
|
||||
Your password was changed successfully.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,52 +4,42 @@
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'change_password' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
|
||||
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.old_password.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.old_password.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password1.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
|
||||
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password2.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="columns">
|
||||
<div class="column col-3">
|
||||
<input type="submit" value="Change Password" class="btn btn-primary">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'change_password' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
|
||||
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.old_password.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.old_password.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password1.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
|
||||
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password2.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<input type="submit" value="Change Password" class="btn btn-primary btn-wide">
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.theme|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
|
||||
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.bookmark_date_display|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can
|
||||
be hidden.
|
||||
@@ -50,14 +50,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
||||
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to open bookmarks a new page or in the same page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
In strict mode, tags must be prefixed with a hash character (#).
|
||||
In lax mode, tags can also be searched without the hash character.
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
|
||||
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
|
||||
@@ -155,9 +155,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group col-8 col-md-12">
|
||||
<div class="input-group width-75 width-md-100">
|
||||
<input class="form-input" type="file" name="import_file">
|
||||
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
|
||||
<input type="submit" class="input-group-btn btn btn-primary" value="Upload">
|
||||
</div>
|
||||
{% if import_success_message %}
|
||||
<div class="has-success">
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="columns">
|
||||
<div class="column col-6 col-md-12">
|
||||
<div class="column width-50 width-md-100">
|
||||
<input class="form-input" value="{{ api_token }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,20 +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 {
|
||||
'filters': filters,
|
||||
'request': context['request'],
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -100,11 +102,21 @@ class BookmarkFactoryMixin:
|
||||
|
||||
for i in range(1, count + 1):
|
||||
title = f'{prefix} {i}{suffix}'
|
||||
url = f'https://example.com/{prefix}/{i}'
|
||||
tags = []
|
||||
if with_tags:
|
||||
tag_name = f'{tag_prefix} {i}{suffix}'
|
||||
tags = [self.setup_tag(name=tag_name)]
|
||||
self.setup_bookmark(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)
|
||||
@@ -127,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):
|
||||
@@ -251,3 +272,8 @@ def disable_logging(f):
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def collapse_whitespace(text: str):
|
||||
text = text.replace('\n', '').replace('\r', '')
|
||||
return ' '.join(text.split())
|
||||
|
||||
@@ -22,7 +22,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_archive_should_archive_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_unarchive_should_unarchive_bookmark(self):
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
@@ -57,7 +57,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
@@ -68,7 +68,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_delete_should_delete_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -87,20 +87,45 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_mark_as_read(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'mark_as_read': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.unread)
|
||||
|
||||
def test_unshare_should_unshare_bookmark(self):
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unshare': [bookmark.id],
|
||||
})
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_can_only_unshare_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unshare': [bookmark.id],
|
||||
})
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
def test_bulk_archive(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -114,8 +139,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -128,8 +154,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_unarchive': [''],
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_unarchive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -143,8 +170,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_unarchive': [''],
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_unarchive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -157,8 +185,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_delete': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -172,8 +201,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_delete': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -188,8 +218,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_tag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_tag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -210,8 +241,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_tag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_tag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -231,8 +263,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_untag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_untag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -253,8 +286,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_untag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_untag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -267,18 +301,255 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_bulk_mark_as_read(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_read'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_can_only_bulk_mark_as_read_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(unread=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_read'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_bulk_mark_as_unread(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unread'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_can_only_bulk_mark_as_unread_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=False, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(unread=False, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(unread=False, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unread'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_bulk_share(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_share'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_can_only_bulk_share_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=False, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(shared=False, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(shared=False, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_share'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_bulk_unshare(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unshare'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_can_only_bulk_unshare_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(shared=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unshare'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_bulk_select_across(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
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_ignores_page(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?page=2', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.count())
|
||||
|
||||
def setup_bulk_edit_scope_test_data(self):
|
||||
# create a number of bookmarks with different states / visibility
|
||||
self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
self.setup_numbered_bookmarks(3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True))
|
||||
|
||||
def test_index_action_bulk_select_across_only_affects_active_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 1').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 2').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 3').first())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(6, Bookmark.objects.count())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 1').first())
|
||||
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()
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(6, Bookmark.objects.count())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
|
||||
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()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:shared.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_handles_empty_bookmark_id(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -290,7 +561,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -301,9 +572,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index')
|
||||
url = reverse('bookmarks:index.action') + '?return_url=' + reverse('bookmarks:settings.index')
|
||||
response = self.client.post(url, {
|
||||
'bulk_archive': [''],
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -315,9 +587,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
url = reverse('bookmarks:action') + f'?return_url={return_url}'
|
||||
url = reverse('bookmarks:index.action') + f'?return_url={return_url}'
|
||||
return self.client.post(url, {
|
||||
'bulk_archive': [''],
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
}, follow=follow)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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, HtmlTestMixin
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
|
||||
|
||||
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -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" class="">{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" class="">{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,13 +69,24 @@ 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),
|
||||
@@ -69,51 +94,28 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
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())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
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'))
|
||||
|
||||
@@ -121,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(),
|
||||
@@ -190,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'))
|
||||
|
||||
@@ -205,12 +214,211 @@ 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)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
</select>
|
||||
''', html)
|
||||
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse('bookmarks:archived')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<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')
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
|
||||
|
||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -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" class="">{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" class="">{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,13 +69,24 @@ 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),
|
||||
@@ -70,51 +94,26 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
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')
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
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'))
|
||||
|
||||
@@ -122,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(),
|
||||
@@ -191,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'))
|
||||
|
||||
@@ -206,37 +210,211 @@ 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
|
||||
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: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')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
return_url = urllib.parse.quote_plus(url)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_archive">Archive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
</select>
|
||||
''', html)
|
||||
|
||||
# with query params
|
||||
url = reverse('bookmarks:index') + '?q=foo&user=user'
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
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)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_archive">Archive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<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')
|
||||
|
||||
74
bookmarks/tests/test_bookmark_search_form.py
Normal file
74
bookmarks/tests/test_bookmark_search_form.py
Normal 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']])
|
||||
162
bookmarks/tests/test_bookmark_search_model.py
Normal file
162
bookmarks/tests/test_bookmark_search_model.py
Normal 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,
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
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()
|
||||
@@ -16,53 +17,74 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html, count=count
|
||||
)
|
||||
|
||||
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.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()
|
||||
@@ -85,7 +107,6 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -112,20 +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')
|
||||
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')
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -133,20 +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'))
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -261,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()
|
||||
@@ -328,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')
|
||||
|
||||
@@ -28,7 +28,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
|
||||
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -41,4 +41,4 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)
|
||||
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks + num_additional_bookmarks)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,21 +10,19 @@ from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.middlewares import UserProfileMiddleware
|
||||
from bookmarks.models import Bookmark, UserProfile, User
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
|
||||
from bookmarks.views.partials import contexts
|
||||
|
||||
|
||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
|
||||
unread = bookmark.unread
|
||||
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
|
||||
self.assertInHTML(
|
||||
f'''
|
||||
<a href="{bookmark.url}"
|
||||
target="{link_target}"
|
||||
rel="noopener"
|
||||
class="{'text-italic' if unread else ''}">
|
||||
rel="noopener">
|
||||
{favicon_img}
|
||||
{bookmark.resolved_title}
|
||||
</a>
|
||||
@@ -34,21 +32,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertDateLabel(self, html: str, label_content: str):
|
||||
self.assertInHTML(f'''
|
||||
<span>
|
||||
<span>{label_content}</span>
|
||||
</span>
|
||||
<span>{label_content}</span>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
|
||||
self.assertInHTML(f'''
|
||||
<span>
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
<span>{label_content}</span>
|
||||
∞
|
||||
</a>
|
||||
</span>
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
{label_content} ∞
|
||||
</a>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
@@ -62,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'''
|
||||
@@ -126,18 +119,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertNotesToggle(self, html: str, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
|
||||
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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</svg>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def assertUnshareButton(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button type="submit" name="unshare" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def assertMarkAsReadButton(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button type="submit" name="mark_as_read" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def render_template(self,
|
||||
@@ -236,11 +247,31 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
|
||||
|
||||
def test_should_respect_unread_flag(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
def test_should_reflect_unread_state_as_css_class(self):
|
||||
self.setup_bookmark(unread=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertBookmarksLink(html, bookmark)
|
||||
self.assertIn('<li ld-bookmark-item class="unread">', html)
|
||||
|
||||
def test_should_reflect_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="shared">', html)
|
||||
|
||||
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(unread=True, shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
|
||||
|
||||
def test_show_bookmark_actions_for_owned_bookmarks(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -333,6 +364,66 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertBookmarkURLHidden(html, bookmark)
|
||||
|
||||
def test_show_mark_as_read_when_unread(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertMarkAsReadButton(html, bookmark)
|
||||
|
||||
def test_hide_mark_as_read_when_read(self):
|
||||
bookmark = self.setup_bookmark(unread=False)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
|
||||
def test_hide_mark_as_read_for_non_owned_bookmarks(self):
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True, unread=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertBookmarksLink(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
|
||||
def test_show_unshare_button_when_shared(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertUnshareButton(html, bookmark)
|
||||
|
||||
def test_hide_unshare_button_when_not_shared(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(shared=False)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
|
||||
def test_hide_unshare_button_when_sharing_is_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
|
||||
def test_hide_unshare_for_non_owned_bookmarks(self):
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertBookmarksLink(html, bookmark)
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
|
||||
def test_without_notes(self):
|
||||
self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
@@ -363,9 +454,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_notes_are_hidden_initially_by_default(self):
|
||||
self.setup_bookmark(notes='Test note')
|
||||
html = self.render_template()
|
||||
html = collapse_whitespace(self.render_template())
|
||||
|
||||
self.assertIn('<ul class="bookmark-list">', html)
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
|
||||
|
||||
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -373,9 +464,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(notes='Test note')
|
||||
html = self.render_template()
|
||||
html = collapse_whitespace(self.render_template())
|
||||
|
||||
self.assertIn('<ul class="bookmark-list">', html)
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
|
||||
|
||||
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -383,9 +474,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(notes='Test note')
|
||||
html = self.render_template()
|
||||
html = collapse_whitespace(self.render_template())
|
||||
|
||||
self.assertIn('<ul class="bookmark-list show-notes">', html)
|
||||
self.assertIn('<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html)
|
||||
|
||||
def test_toggle_notes_is_visible_by_default(self):
|
||||
self.setup_bookmark(notes='Test note')
|
||||
@@ -425,6 +516,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
bookmark.notes = '**Example:** `print("Hello world!")`'
|
||||
bookmark.favicon_file = 'https_example_com.png'
|
||||
bookmark.shared = True
|
||||
bookmark.unread = True
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
|
||||
@@ -432,6 +524,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertShareInfo(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
self.assertFaviconVisible(html, bookmark)
|
||||
|
||||
@@ -8,7 +8,8 @@ from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
|
||||
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
@@ -452,3 +453,183 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_mark_bookmarks_as_read(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_read_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
mark_bookmarks_as_read([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_read_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
inaccessible_bookmark = self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_read_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
mark_bookmarks_as_read([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
mark_bookmarks_as_unread([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
inaccessible_bookmark = self.setup_bookmark(unread=False, user=other_user)
|
||||
|
||||
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
mark_bookmarks_as_unread([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_share_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
share_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_share_bookmarks_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
share_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_share_bookmarks_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
inaccessible_bookmark = self.setup_bookmark(shared=False, user=other_user)
|
||||
|
||||
share_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
|
||||
|
||||
def test_share_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
share_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_unshare_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
unshare_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_unshare_bookmarks_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
unshare_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_unshare_bookmarks_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
inaccessible_bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
unshare_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
|
||||
|
||||
def test_unshare_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
unshare_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
@@ -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 <style> HTML element contains style information for a document, or part of a document.',
|
||||
html
|
||||
)
|
||||
self.assertIn(
|
||||
'Interesting notes about the <style> HTML element.',
|
||||
html
|
||||
)
|
||||
|
||||
def test_handle_empty_values(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -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='')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"><style>: The Style Information element</A>
|
||||
<DD>The <style> HTML element contains style information for a document, or part of a document.[linkding-notes]Interesting notes about the <style> 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.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -30,7 +30,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
# Should render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">')
|
||||
self.assertContains(response, '<div class="toasts">')
|
||||
# Should render two toasts
|
||||
self.assertContains(response, '<div class="toast">', count=2)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -13,12 +13,14 @@ urlpatterns = [
|
||||
re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
|
||||
# Bookmarks
|
||||
path('bookmarks', views.bookmarks.index, name='index'),
|
||||
path('bookmarks/action', views.bookmarks.index_action, name='index.action'),
|
||||
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
|
||||
path('bookmarks/archived/action', views.bookmarks.archived_action, name='archived.action'),
|
||||
path('bookmarks/shared', views.bookmarks.shared, name='shared'),
|
||||
path('bookmarks/shared/action', views.bookmarks.shared_action, name='shared.action'),
|
||||
path('bookmarks/new', views.bookmarks.new, name='new'),
|
||||
path('bookmarks/close', views.bookmarks.close, name='close'),
|
||||
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
|
||||
path('bookmarks/action', views.bookmarks.action, name='action'),
|
||||
# Partials
|
||||
path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list,
|
||||
name='partials.bookmark_list.active'),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
from django.db.models import QuerySet
|
||||
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
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
|
||||
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
|
||||
from bookmarks.utils import get_safe_return_url
|
||||
from bookmarks.views.partials import contexts
|
||||
|
||||
@@ -15,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', {
|
||||
@@ -25,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', {
|
||||
@@ -34,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,
|
||||
@@ -46,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
|
||||
@@ -145,6 +174,16 @@ def unarchive(request, bookmark_id: int):
|
||||
unarchive_bookmark(bookmark)
|
||||
|
||||
|
||||
def unshare(request, bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
raise Http404('Bookmark does not exist')
|
||||
|
||||
bookmark.shared = False
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def mark_as_read(request, bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
@@ -156,8 +195,26 @@ def mark_as_read(request, bookmark_id: int):
|
||||
|
||||
|
||||
@login_required
|
||||
def action(request):
|
||||
# Determine action
|
||||
def index_action(request):
|
||||
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):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||
return action(request, query)
|
||||
|
||||
|
||||
@login_required
|
||||
def shared_action(request):
|
||||
return action(request)
|
||||
|
||||
|
||||
def action(request, query: QuerySet[Bookmark] = None):
|
||||
# Single bookmark actions
|
||||
if 'archive' in request.POST:
|
||||
archive(request, request.POST['archive'])
|
||||
if 'unarchive' in request.POST:
|
||||
@@ -166,23 +223,44 @@ def action(request):
|
||||
remove(request, request.POST['remove'])
|
||||
if 'mark_as_read' in request.POST:
|
||||
mark_as_read(request, request.POST['mark_as_read'])
|
||||
if 'bulk_archive' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unarchive' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_delete' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_tag' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_untag' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'unshare' in request.POST:
|
||||
unshare(request, request.POST['unshare'])
|
||||
|
||||
# Bulk actions
|
||||
if 'bulk_execute' in request.POST:
|
||||
if query is None:
|
||||
return HttpResponseBadRequest('View does not support bulk actions')
|
||||
|
||||
bulk_action = request.POST['bulk_action']
|
||||
|
||||
# Determine set of bookmarks
|
||||
if request.POST.get('bulk_select_across') == 'on':
|
||||
# Query full list of bookmarks across all pages
|
||||
bookmark_ids = query.only('id').values_list('id', flat=True)
|
||||
else:
|
||||
# Use only selected bookmarks
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
|
||||
if 'bulk_archive' == bulk_action:
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unarchive' == bulk_action:
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_delete' == bulk_action:
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_tag' == bulk_action:
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_untag' == bulk_action:
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_read' == bulk_action:
|
||||
mark_bookmarks_as_read(bookmark_ids, request.user)
|
||||
if 'bulk_unread' == bulk_action:
|
||||
mark_bookmarks_as_unread(bookmark_ids, request.user)
|
||||
if 'bulk_share' == bulk_action:
|
||||
share_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unshare' == bulk_action:
|
||||
unshare_bookmarks(bookmark_ids, request.user)
|
||||
|
||||
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
@@ -7,16 +7,58 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import BookmarkFilters, User, UserProfile, Tag
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, BookmarkSearchForm, User, UserProfile, Tag
|
||||
|
||||
DEFAULT_PAGE_SIZE = 30
|
||||
|
||||
|
||||
class BookmarkItem:
|
||||
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
|
||||
self.bookmark = bookmark
|
||||
|
||||
is_editable = bookmark.owner == user
|
||||
self.is_editable = is_editable
|
||||
|
||||
self.id = bookmark.id
|
||||
self.url = bookmark.url
|
||||
self.title = bookmark.resolved_title
|
||||
self.description = bookmark.resolved_description
|
||||
self.notes = bookmark.notes
|
||||
self.tag_names = bookmark.tag_names
|
||||
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
|
||||
self.favicon_file = bookmark.favicon_file
|
||||
self.is_archived = bookmark.is_archived
|
||||
self.unread = bookmark.unread
|
||||
self.owner = bookmark.owner
|
||||
|
||||
css_classes = []
|
||||
if bookmark.unread:
|
||||
css_classes.append('unread')
|
||||
if bookmark.shared:
|
||||
css_classes.append('shared')
|
||||
|
||||
self.css_classes = ' '.join(css_classes)
|
||||
|
||||
if profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE:
|
||||
self.display_date = utils.humanize_relative_date(bookmark.date_added)
|
||||
elif profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE:
|
||||
self.display_date = utils.humanize_absolute_date(bookmark.date_added)
|
||||
|
||||
self.show_notes_button = bookmark.notes and not profile.permanent_notes
|
||||
self.show_mark_as_read = is_editable and bookmark.unread
|
||||
self.show_unshare = is_editable and bookmark.shared and profile.enable_sharing
|
||||
|
||||
self.has_extra_actions = self.show_notes_button or self.show_mark_as_read or self.show_unshare
|
||||
|
||||
|
||||
class BookmarkListContext:
|
||||
def __init__(self, request: WSGIRequest) -> None:
|
||||
user = request.user
|
||||
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_bookmark_query_set()
|
||||
page_number = request.GET.get('page')
|
||||
@@ -25,31 +67,42 @@ class BookmarkListContext:
|
||||
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
|
||||
models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags')
|
||||
|
||||
self.items = [BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page]
|
||||
|
||||
self.is_empty = paginator.count == 0
|
||||
self.bookmarks_page = bookmarks_page
|
||||
self.return_url = self.generate_return_url(page_number)
|
||||
self.link_target = request.user_profile.bookmark_link_target
|
||||
self.date_display = request.user_profile.bookmark_date_display
|
||||
self.show_url = request.user_profile.display_url
|
||||
self.show_favicons = request.user_profile.enable_favicons
|
||||
self.show_notes = request.user_profile.permanent_notes
|
||||
self.bookmarks_total = paginator.count
|
||||
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')
|
||||
|
||||
@@ -58,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)
|
||||
|
||||
|
||||
@@ -114,14 +176,16 @@ 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)
|
||||
selected_tags = self.get_selected_tags(tags)
|
||||
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
|
||||
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
|
||||
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
|
||||
unique_selected_tags = utils.unique(selected_tags, key=lambda x: str.lower(x.name))
|
||||
has_selected_tags = len(unique_selected_tags) > 0
|
||||
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
|
||||
groups = TagGroup.create_tag_groups(unselected_tags)
|
||||
@@ -135,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']
|
||||
@@ -148,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
32
docs/API.md
32
docs/API.md
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.20.1",
|
||||
"version": "1.22.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -24,6 +24,6 @@ sqlparse==0.4.4
|
||||
supervisor==4.2.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
uWSGI==2.0.20
|
||||
uWSGI==2.0.22
|
||||
waybackpy==3.0.6
|
||||
webencodings==0.5.1
|
||||
|
||||
29
scripts/run-postgres.sh
Executable file
29
scripts/run-postgres.sh
Executable 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
|
||||
@@ -225,12 +225,21 @@ else:
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
|
||||
'OPTIONS': LD_DB_OPTIONS,
|
||||
# Creating a connection loads the ICU extension into the SQLite
|
||||
# connection, and also loads an ICU collation. The latter causes a
|
||||
# memory leak, so try to counter that by making connections indefinitely
|
||||
# persistent.
|
||||
'CONN_MAX_AGE': None
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.20.1
|
||||
1.22.1
|
||||
|
||||
Reference in New Issue
Block a user