mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 06:29:21 +02:00
Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6775633be5 | ||
![]() |
150dfecc6f | ||
![]() |
81ae55bc1c | ||
![]() |
935189ecc2 | ||
![]() |
7997f20d89 | ||
![]() |
ae27500cde | ||
![]() |
71d853999e | ||
![]() |
70288d6865 | ||
![]() |
e83d519cab | ||
![]() |
6355d8dff1 | ||
![]() |
227cfdb063 | ||
![]() |
2d4da099c7 | ||
![]() |
a9512b2333 | ||
![]() |
47e944e6c5 | ||
![]() |
6c7ce91d53 | ||
![]() |
87020de917 | ||
![]() |
a130daa0f0 | ||
![]() |
d7c68c2818 | ||
![]() |
1daad2c86c | ||
![]() |
251def2583 | ||
![]() |
560769f068 | ||
![]() |
dc9799cc53 | ||
![]() |
41c1b9ab84 | ||
![]() |
2396c8fe99 | ||
![]() |
de328c78e2 | ||
![]() |
314e4a9b74 | ||
![]() |
ff400a79ec | ||
![]() |
f4fcb96b5e | ||
![]() |
daab772971 | ||
![]() |
64c81ea565 | ||
![]() |
1dd19e8fa2 | ||
![]() |
dd3699cdeb | ||
![]() |
f9c9d17873 | ||
![]() |
5c9f03a715 | ||
![]() |
7600fe87f9 | ||
![]() |
f756e28daf | ||
![]() |
1e10d7eb4a | ||
![]() |
ccf8e03571 | ||
![]() |
30708cc5e3 | ||
![]() |
3e4f08f51b | ||
![]() |
41f79e35a0 | ||
![]() |
4a2642f16c | ||
![]() |
e70315ed26 | ||
![]() |
3e36f90b38 | ||
![]() |
28acf3299c | ||
![]() |
ffcc40b227 | ||
![]() |
b7ddee2d93 | ||
![]() |
d9c4ddb4d7 | ||
![]() |
0975914a86 | ||
![]() |
0c50906056 | ||
![]() |
54c79225ce | ||
![]() |
a382e171ad |
@@ -1,34 +1,22 @@
|
||||
# Remove project files, data, tmp files, build files
|
||||
/.env
|
||||
/.idea
|
||||
/data
|
||||
/node_modules
|
||||
/tmp
|
||||
/docs
|
||||
/static
|
||||
/scripts
|
||||
/build
|
||||
/out
|
||||
/.git
|
||||
/.devcontainer
|
||||
# Ignore everything
|
||||
*
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
/.gitattributes
|
||||
/Dockerfile
|
||||
/docker-compose.yml
|
||||
/*.sh
|
||||
/*.iml
|
||||
/*.patch
|
||||
/*.md
|
||||
/*.js
|
||||
/*.log
|
||||
/*.pid
|
||||
# Include files required for build or at runtime
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
# Whitelist files needed in build or prod image
|
||||
!/rollup.config.js
|
||||
!/bootstrap.sh
|
||||
!/background-tasks-wrapper.sh
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
!/manage.py
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/requirements.prod.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.js
|
||||
!/supervisord.conf
|
||||
!/uwsgi.ini
|
||||
!/version.txt
|
||||
|
||||
# Remove development settings
|
||||
# Remove dev settings
|
||||
/siteroot/settings/dev.py
|
||||
|
109
CHANGELOG.md
109
CHANGELOG.md
@@ -1,5 +1,114 @@
|
||||
# Changelog
|
||||
|
||||
## v1.23.0 (24/11/2023)
|
||||
|
||||
### What's Changed
|
||||
* Add Alpine based Docker image (experimental) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/570
|
||||
* Add backup CLI command by @sissbruecker in https://github.com/sissbruecker/linkding/pull/571
|
||||
* Update browser extension links by @OPerepadia in https://github.com/sissbruecker/linkding/pull/574
|
||||
* Include archived bookmarks in export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/579
|
||||
|
||||
### New Contributors
|
||||
* @OPerepadia made their first contribution in https://github.com/sissbruecker/linkding/pull/574
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.3...v1.23.0
|
||||
|
||||
---
|
||||
|
||||
## v1.22.3 (04/11/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix RSS feed not handling None values by @vitormarcal in https://github.com/sissbruecker/linkding/pull/569
|
||||
* Bump django from 4.1.10 to 4.1.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/567
|
||||
|
||||
### New Contributors
|
||||
* @vitormarcal made their first contribution in https://github.com/sissbruecker/linkding/pull/569
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.2...v1.22.3
|
||||
|
||||
---
|
||||
|
||||
## v1.22.2 (27/10/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix search options not opening on iOS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/549
|
||||
* Bump urllib3 from 1.26.11 to 1.26.17 by @dependabot in https://github.com/sissbruecker/linkding/pull/542
|
||||
* Add iOS shortcut to community section by @andrewdolphin in https://github.com/sissbruecker/linkding/pull/550
|
||||
* Disable editing of search preferences in user admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/555
|
||||
* Add feed2linkding to community section by @Strubbl in https://github.com/sissbruecker/linkding/pull/544
|
||||
* Sanitize RSS feed to remove control characters by @sissbruecker in https://github.com/sissbruecker/linkding/pull/565
|
||||
* Bump urllib3 from 1.26.17 to 1.26.18 by @dependabot in https://github.com/sissbruecker/linkding/pull/560
|
||||
|
||||
### New Contributors
|
||||
* @andrewdolphin made their first contribution in https://github.com/sissbruecker/linkding/pull/550
|
||||
* @Strubbl made their first contribution in https://github.com/sissbruecker/linkding/pull/544
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.1...v1.22.2
|
||||
|
||||
---
|
||||
|
||||
## v1.22.1 (06/10/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix memory leak with SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/548
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.0...v1.22.1
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
68
README.md
68
README.md
@@ -9,15 +9,15 @@
|
||||
## Overview
|
||||
- [Introduction](#introduction)
|
||||
- [Installation](#installation)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Docker Compose](#using-docker-compose)
|
||||
- [User Setup](#user-setup)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Managed Hosting Options](#managed-hosting-options)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Docker Compose](#using-docker-compose)
|
||||
- [User Setup](#user-setup)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Managed Hosting Options](#managed-hosting-options)
|
||||
- [Documentation](#documentation)
|
||||
- [Browser Extension](#browser-extension)
|
||||
- [Community](#community)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Acknowledgements + Donations](#acknowledgements--donations)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
@@ -40,7 +40,7 @@ The name comes from:
|
||||
- Automatically provides titles, descriptions and icons of bookmarked websites
|
||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- Light and dark themes
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
@@ -58,9 +58,27 @@ The name comes from:
|
||||
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
|
||||
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
|
||||
By default, linkding uses SQLite as a database.
|
||||
linkding uses an SQLite database by default.
|
||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>🧪 Alpine-based image</summary>
|
||||
|
||||
The default Docker image (`latest` tag) is based on a slim variant of Debian Linux.
|
||||
Alternatively, there is an image based on Alpine Linux (`latest-alpine` tag) which has a smaller size, resulting in a smaller download and less disk space required.
|
||||
The Alpine image is currently about 45 MB in compressed size, compared to about 130 MB for the Debian image.
|
||||
|
||||
To use it, replace the `latest` tag with `latest-alpine`, either in the CLI command below when using Docker, or in the `docker-compose.yml` file when using docker-compose.
|
||||
|
||||
> [!WARNING]
|
||||
> The image is currently considered experimental in order to gather feedback and iron out any issues.
|
||||
> Only use it if you are comfortable running experimental software or want to help out with testing.
|
||||
> While there should be no issues with creating new installations, there might be issues when migrating existing installations.
|
||||
> If you plan to migrate your existing installation, make sure to create proper [backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) first.
|
||||
|
||||
</details>
|
||||
|
||||
### Using Docker
|
||||
|
||||
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
||||
@@ -85,7 +103,7 @@ docker-compose up -d
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### User setup
|
||||
### User Setup
|
||||
|
||||
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
|
||||
|
||||
@@ -101,7 +119,7 @@ docker-compose exec linkding python manage.py createsuperuser --username=joe --e
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
@@ -160,10 +178,11 @@ Instead of configuring header forwarding in your proxy, you can also configure t
|
||||
|
||||
### Managed Hosting Options
|
||||
|
||||
Self-hosting web applications still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
|
||||
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
|
||||
|
||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
||||
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -180,7 +199,7 @@ Self-hosting web applications still requires a lot of technical know-how, and co
|
||||
## Browser Extension
|
||||
|
||||
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
|
||||
- [Mozilla Addon Store](https://addons.mozilla.org/de/firefox/addon/linkding-extension/)
|
||||
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
|
||||
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
|
||||
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
||||
@@ -190,7 +209,9 @@ The extension is open-source as well, and can be found [here](https://github.com
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
@@ -199,9 +220,21 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements
|
||||
## Acknowledgements + Donations
|
||||
|
||||
JetBrains provides an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||
### PikaPods
|
||||
|
||||
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
|
||||
|
||||
See the table below for a list of donations.
|
||||
|
||||
| Source | Description | Amount | Donated to |
|
||||
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
|
||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
|
||||
|
||||
### JetBrains
|
||||
|
||||
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -267,3 +300,8 @@ Start the Django development server with:
|
||||
python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
Run all tests with pytest
|
||||
```
|
||||
pytest
|
||||
```
|
||||
|
@@ -122,7 +122,7 @@ class AdminUserProfileInline(admin.StackedInline):
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile'
|
||||
fk_name = 'user'
|
||||
|
||||
readonly_fields = ('search_preferences', )
|
||||
|
||||
class AdminCustomUser(UserAdmin):
|
||||
inlines = (AdminUserProfileInline,)
|
||||
|
@@ -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,
|
||||
|
@@ -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='-')
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
|
||||
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 import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -14,18 +15,26 @@ class FeedContext:
|
||||
query_set: QuerySet[Bookmark]
|
||||
|
||||
|
||||
def sanitize(text: str):
|
||||
if not text:
|
||||
return ''
|
||||
# remove control characters
|
||||
valid_chars = ['\n', '\r', '\t']
|
||||
return ''.join(ch for ch in text if ch in valid_chars or unicodedata.category(ch)[0] != 'C')
|
||||
|
||||
|
||||
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):
|
||||
return item.resolved_title
|
||||
return sanitize(item.resolved_title)
|
||||
|
||||
def item_description(self, item: Bookmark):
|
||||
return item.resolved_description
|
||||
return sanitize(item.resolved_description)
|
||||
|
||||
def item_link(self, item: Bookmark):
|
||||
return item.url
|
||||
|
@@ -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)}`);
|
||||
}
|
||||
|
@@ -59,10 +59,18 @@ class BookmarkItem {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
|
||||
// Toggle notes
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
||||
}
|
||||
|
||||
// Add tooltip to title if it is truncated
|
||||
const titleAnchor = element.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
|
36
bookmarks/frontend/behaviors/dropdown.js
Normal file
36
bookmarks/frontend/behaviors/dropdown.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { registerBehavior } from "./index";
|
||||
|
||||
class DropdownBehavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.opened = false;
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
|
||||
const toggle = element.querySelector(".dropdown-toggle");
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
this.element.classList.add("active");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.classList.remove("active");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
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);
|
@@ -10,7 +10,7 @@
|
||||
export let tags;
|
||||
export let mode = '';
|
||||
export let apiClient;
|
||||
export let filters;
|
||||
export let search;
|
||||
export let linkTarget = '_blank';
|
||||
|
||||
let isFocus = false;
|
||||
@@ -103,7 +103,7 @@
|
||||
}
|
||||
|
||||
// Recent search suggestions
|
||||
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
|
||||
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
|
||||
type: 'search',
|
||||
index: nextIndex(),
|
||||
label: value,
|
||||
@@ -115,11 +115,11 @@
|
||||
|
||||
if (value && value.length >= 3) {
|
||||
const path = mode ? `/${mode}` : ''
|
||||
const suggestionFilters = {
|
||||
...filters,
|
||||
const suggestionSearch = {
|
||||
...search,
|
||||
q: value
|
||||
}
|
||||
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
|
||||
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
@@ -132,7 +132,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
updateSuggestions(search, bookmarks, tagSuggestions)
|
||||
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
|
||||
|
||||
if (hasSuggestions()) {
|
||||
open()
|
||||
@@ -143,17 +143,17 @@
|
||||
|
||||
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
||||
|
||||
function updateSuggestions(search, bookmarks, tagSuggestions) {
|
||||
search = search || []
|
||||
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
||||
recentSearches = recentSearches || []
|
||||
bookmarks = bookmarks || []
|
||||
tagSuggestions = tagSuggestions || []
|
||||
suggestions = {
|
||||
search,
|
||||
recentSearches,
|
||||
bookmarks,
|
||||
tags: tagSuggestions,
|
||||
total: [
|
||||
...tagSuggestions,
|
||||
...search,
|
||||
...recentSearches,
|
||||
...bookmarks,
|
||||
]
|
||||
}
|
||||
@@ -215,10 +215,10 @@
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if suggestions.search.length > 0}
|
||||
{#if suggestions.recentSearches.length > 0}
|
||||
<li class="menu-item group-item">Recent Searches</li>
|
||||
{/if}
|
||||
{#each suggestions.search as suggestion}
|
||||
{#each suggestions.recentSearches as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
{suggestion.label}
|
||||
|
@@ -22,7 +22,7 @@
|
||||
async function init() {
|
||||
// For now we cache all tags on load as the template did before
|
||||
try {
|
||||
tags = await apiClient.getTags({limit: 1000, offset: 0});
|
||||
tags = await apiClient.getTags({limit: 5000, offset: 0});
|
||||
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
||||
} catch (e) {
|
||||
console.warn('TagAutocomplete: Error loading tag list');
|
||||
|
@@ -4,6 +4,8 @@ import { ApiClient } from "./api";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
|
||||
|
26
bookmarks/management/commands/backup.py
Normal file
26
bookmarks/management/commands/backup.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates a backup of the linkding database"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('destination', type=str, help='Backup file destination')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
destination = options['destination']
|
||||
|
||||
def progress(status, remaining, total):
|
||||
self.stdout.write(f'Copied {total-remaining} of {total} pages...')
|
||||
|
||||
source_db = sqlite3.connect(os.path.join('data', 'db.sqlite3'))
|
||||
backup_db = sqlite3.connect(destination)
|
||||
with backup_db:
|
||||
source_db.backup(backup_db, pages=50, progress=progress)
|
||||
backup_db.close()
|
||||
source_db.close()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Backup created at {destination}'))
|
@@ -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)
|
||||
|
||||
|
@@ -67,9 +67,9 @@ def archive_bookmark(bookmark: Bookmark):
|
||||
|
||||
def archive_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(is_archived=True, date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(is_archived=True,
|
||||
date_modified=timezone.now())
|
||||
|
||||
|
||||
def unarchive_bookmark(bookmark: Bookmark):
|
||||
@@ -81,70 +81,76 @@ def unarchive_bookmark(bookmark: Bookmark):
|
||||
|
||||
def unarchive_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(is_archived=False, date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(is_archived=False,
|
||||
date_modified=timezone.now())
|
||||
|
||||
|
||||
def delete_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.delete()
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).delete()
|
||||
|
||||
|
||||
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
owned_bookmark_ids = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).values_list('id',
|
||||
flat=True)
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags.add(*tags)
|
||||
bookmark.date_modified = timezone.now()
|
||||
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||
relationships = []
|
||||
for tag in tags:
|
||||
for bookmark_id in owned_bookmark_ids:
|
||||
relationships.append(BookmarkToTagRelationShip(bookmark_id=bookmark_id, tag=tag))
|
||||
|
||||
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||
# Insert all bookmark -> tag associations at once, should ignore errors if association already exists
|
||||
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
||||
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(date_modified=timezone.now())
|
||||
|
||||
|
||||
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
owned_bookmark_ids = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).values_list('id',
|
||||
flat=True)
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags.remove(*tags)
|
||||
bookmark.date_modified = timezone.now()
|
||||
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||
for tag in tags:
|
||||
# Remove all bookmark -> tag associations for the owned bookmarks and the current tag
|
||||
BookmarkToTagRelationShip.objects.filter(bookmark_id__in=owned_bookmark_ids, tag=tag).delete()
|
||||
|
||||
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(date_modified=timezone.now())
|
||||
|
||||
|
||||
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())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).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())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).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())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).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())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(shared=False,
|
||||
date_modified=timezone.now())
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
|
@@ -31,12 +31,18 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
url = bookmark.url
|
||||
title = html.escape(bookmark.resolved_title or '')
|
||||
desc = html.escape(bookmark.resolved_description or '')
|
||||
tags = ','.join(bookmark.tag_names)
|
||||
if bookmark.notes:
|
||||
desc += f'[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]'
|
||||
tag_names = bookmark.tag_names
|
||||
if bookmark.is_archived:
|
||||
tag_names.append('linkding:archived')
|
||||
tags = ','.join(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}')
|
||||
|
@@ -5,7 +5,7 @@ from typing import List
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, parse_tag_string
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services.parser import parse, NetscapeBookmark
|
||||
from bookmarks.utils import parse_timestamp
|
||||
@@ -93,8 +93,7 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
|
||||
tags_to_create = []
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
for tag_name in tag_names:
|
||||
for tag_name in netscape_bookmark.tag_names:
|
||||
tag = tag_cache.get(tag_name)
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name, owner=user)
|
||||
@@ -168,6 +167,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)
|
||||
@@ -193,8 +193,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
continue
|
||||
|
||||
# Get tag models by string, schedule inserts for bookmark -> tag associations
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
tags = tag_cache.get_all(tag_names)
|
||||
tags = tag_cache.get_all(netscape_bookmark.tag_names)
|
||||
for tag in tags:
|
||||
relationships.append(BookmarkToTagRelationShip(bookmark=bookmark, tag=tag))
|
||||
|
||||
@@ -214,5 +213,9 @@ 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
|
||||
if netscape_bookmark.archived:
|
||||
bookmark.is_archived = True
|
||||
|
@@ -2,16 +2,20 @@ from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
from typing import Dict, List
|
||||
|
||||
from bookmarks.models import parse_tag_string
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetscapeBookmark:
|
||||
href: str
|
||||
title: str
|
||||
description: str
|
||||
notes: str
|
||||
date_added: str
|
||||
tag_string: str
|
||||
tag_names: List[str]
|
||||
to_read: bool
|
||||
private: bool
|
||||
archived: bool
|
||||
|
||||
|
||||
class BookmarkParser(HTMLParser):
|
||||
@@ -26,6 +30,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.tags = ''
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
self.notes = ''
|
||||
self.toread = ''
|
||||
self.private = ''
|
||||
|
||||
@@ -54,27 +59,40 @@ class BookmarkParser(HTMLParser):
|
||||
|
||||
def handle_start_a(self, attrs: Dict[str, str]):
|
||||
vars(self).update(attrs)
|
||||
tag_names = parse_tag_string(self.tags)
|
||||
archived = 'linkding:archived' in self.tags
|
||||
try:
|
||||
tag_names.remove('linkding:archived')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.bookmark = NetscapeBookmark(
|
||||
href=self.href,
|
||||
title='',
|
||||
description='',
|
||||
notes='',
|
||||
date_added=self.add_date,
|
||||
tag_string=self.tags,
|
||||
tag_names=tag_names,
|
||||
to_read=self.toread == '1',
|
||||
# Mark as private by default, also when attribute is not specified
|
||||
private=self.private != '0',
|
||||
archived=archived,
|
||||
)
|
||||
|
||||
def handle_a_data(self, data):
|
||||
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 +100,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.tags = ''
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
self.notes = ''
|
||||
self.toread = ''
|
||||
self.private = ''
|
||||
|
||||
|
@@ -41,8 +41,13 @@ def load_website_metadata(url: str):
|
||||
|
||||
title = soup.title.string.strip() if soup.title is not None else None
|
||||
description_tag = soup.find('meta', attrs={'name': 'description'})
|
||||
description = description = description_tag['content'].strip() if description_tag and description_tag[
|
||||
description = description_tag['content'].strip() if description_tag and description_tag[
|
||||
'content'] else None
|
||||
|
||||
if not description:
|
||||
description_tag = soup.find('meta', attrs={'property': 'og:description'})
|
||||
description = description_tag['content'].strip() if description_tag and description_tag['content'] else None
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f'Parsing duration: {end - start}')
|
||||
finally:
|
||||
|
@@ -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');")
|
||||
|
@@ -50,14 +50,20 @@ section.content-area {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-6;
|
||||
padding-bottom: $unit-2;
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.8rem;
|
||||
margin-right: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,31 +2,36 @@
|
||||
grid-gap: $unit-10;
|
||||
}
|
||||
|
||||
/* Bookmark search box */
|
||||
.bookmarks-page .search {
|
||||
$searchbox-width: 180px;
|
||||
$searchbox-width-md: 300px;
|
||||
$searchbox-height: 1.8rem;
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .content-area-header {
|
||||
--searchbox-max-width: 350px;
|
||||
--searchbox-height: 1.8rem;
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
--searchbox-max-width: initial;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-page .search-container {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
// Regular input
|
||||
input[type='search'] {
|
||||
width: $searchbox-width;
|
||||
height: $searchbox-height;
|
||||
height: var(--searchbox-height);
|
||||
-webkit-appearance: none;
|
||||
|
||||
@media (min-width: $control-width-md) {
|
||||
width: $searchbox-width-md;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced auto-complete input
|
||||
// This needs a bit more wrangling to make the CSS component align with the attached button
|
||||
.form-autocomplete {
|
||||
height: $searchbox-height;
|
||||
height: var(--searchbox-height);
|
||||
|
||||
.form-autocomplete-input {
|
||||
width: $searchbox-width;
|
||||
height: $searchbox-height;
|
||||
width: 100%;
|
||||
height: var(--searchbox-height);
|
||||
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
@@ -34,9 +39,62 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +107,18 @@ ul.bookmark-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
@@ -64,6 +134,27 @@ li[ld-bookmark-item] {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: $unit-1;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid #424a8c;
|
||||
font-size: $font-size-sm;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
@@ -164,7 +255,7 @@ ul.bookmark-list {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: $unit-1 0;
|
||||
overflow: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
@@ -203,6 +294,7 @@ ul.bookmark-list .notes-content {
|
||||
padding: $unit-1 $unit-2;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: $unit-1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
|
@@ -21,9 +21,11 @@
|
||||
@import "../../node_modules/spectre.css/src/media";
|
||||
|
||||
// Components
|
||||
@import "../../node_modules/spectre.css/src/badges";
|
||||
@import "../../node_modules/spectre.css/src/dropdowns";
|
||||
@import "../../node_modules/spectre.css/src/empty";
|
||||
@import "../../node_modules/spectre.css/src/menus";
|
||||
@import "../../node_modules/spectre.css/src/modals";
|
||||
@import "../../node_modules/spectre.css/src/pagination";
|
||||
@import "../../node_modules/spectre.css/src/tabs";
|
||||
@import "../../node_modules/spectre.css/src/toasts";
|
||||
@@ -62,6 +64,19 @@ a:visited:hover {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Fix radio button sub-pixel size
|
||||
.form-radio .form-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.form-radio input:checked + .form-icon::before {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transform: unset;
|
||||
}
|
||||
|
||||
// Make code work with light and dark theme
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
@@ -100,6 +115,18 @@ ul.menu li:first-child {
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
// Add border to separate from background in dark mode
|
||||
.modal-container {
|
||||
border: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix modal header to use default color
|
||||
.modal-header {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
|
@@ -44,6 +44,10 @@ a:focus, .btn:focus {
|
||||
border-color: $dt-primary-button-color;
|
||||
}
|
||||
|
||||
.form-radio input:checked + .form-icon::before {
|
||||
background: $light-color;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
|
@@ -14,14 +14,16 @@
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="d-flex">
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query|urlencode }}&return_url={{ bookmark_list.return_url }}"
|
||||
method="post">
|
||||
<form class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||
|
||||
|
@@ -14,11 +14,11 @@
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<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_item.title }}
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
@@ -65,7 +65,7 @@
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
|
@@ -14,13 +14,15 @@
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="d-flex">
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags %}
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query|urlencode }}&return_url={{ bookmark_list.return_url }}"
|
||||
<form class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
|
||||
|
@@ -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>
|
||||
@@ -44,8 +44,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="dropdown dropdown-right">
|
||||
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
@@ -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>
|
||||
@@ -80,22 +80,3 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endhtmlmin %}
|
||||
<script>
|
||||
// Hide mobile menu on outside click
|
||||
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
|
||||
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
|
||||
// behaviour through Javascript
|
||||
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
|
||||
|
||||
function mobileNavMenuOutsideClickHandler(clickEvent) {
|
||||
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
|
||||
mobileNavMenuTrigger.blur();
|
||||
}
|
||||
|
||||
mobileNavMenuTrigger.addEventListener('focus', function () {
|
||||
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||
})
|
||||
mobileNavMenuTrigger.addEventListener('blur', function () {
|
||||
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||
})
|
||||
</script>
|
||||
|
@@ -1,44 +1,108 @@
|
||||
<div class="search">
|
||||
<form action="" method="get" role="search">
|
||||
<div class="input-group">
|
||||
<span id="search-input-wrap">
|
||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||
value="{{ filters.query }}">
|
||||
</span>
|
||||
<input type="submit" value="Search" class="btn input-group-btn">
|
||||
</div>
|
||||
{% if filters.user %}
|
||||
<input type="hidden" name="user" value="{{ filters.user }}">
|
||||
{% endif %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="search-container">
|
||||
<form id="search" class="input-group" action="" method="get" role="search">
|
||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||
value="{{ search.q }}">
|
||||
<input type="submit" value="Search" class="btn input-group-btn">
|
||||
{% for hidden_field in search_form.hidden_fields %}
|
||||
{{ hidden_field }}
|
||||
{% endfor %}
|
||||
</form>
|
||||
<div ld-dropdown 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: input.value,
|
||||
tags: uniqueTags,
|
||||
mode: '{{ mode }}',
|
||||
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
|
||||
apiClient,
|
||||
filters,
|
||||
search,
|
||||
}
|
||||
})
|
||||
wrapper.parentElement.replaceChild(newWrapper, wrapper)
|
||||
input.replaceWith(wrapper.firstElementChild);
|
||||
});
|
||||
</script>
|
@@ -13,10 +13,13 @@
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header">
|
||||
<h2>Shared bookmarks</h2>
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
|
||||
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?q={{ bookmark_list.filters.query|urlencode }}&return_url={{ bookmark_list.return_url }}"
|
||||
<form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -32,7 +35,7 @@
|
||||
<h2>User</h2>
|
||||
</div>
|
||||
<div>
|
||||
{% user_select filters users %}
|
||||
{% user_select bookmark_list.search users %}
|
||||
<br>
|
||||
</div>
|
||||
<div class="content-area-header">
|
||||
|
@@ -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>
|
||||
|
@@ -99,7 +99,7 @@
|
||||
Machine</a>.
|
||||
This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in
|
||||
case it goes offline or its content is modified.
|
||||
Please consider donating to the <a href="https://archive.org/donate/index.php" target="_blank"
|
||||
Please consider donating to the <a href="https://archive.org/donate" target="_blank"
|
||||
rel="noopener">Internet Archive</a> if you make use of this feature.
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -9,7 +9,7 @@
|
||||
<h2>Browser Extension</h2>
|
||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
|
||||
<ul>
|
||||
<li><a href="https://addons.mozilla.org/de/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
|
||||
</ul>
|
||||
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
||||
|
@@ -2,7 +2,7 @@ from typing import List
|
||||
|
||||
from django import template
|
||||
|
||||
from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User
|
||||
from bookmarks.models import BookmarkForm, BookmarkSearch, BookmarkSearchForm, Tag, build_tag_string, User
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -19,21 +19,31 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
|
||||
def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''):
|
||||
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ''):
|
||||
tag_names = [tag.name for tag in tags]
|
||||
tags_string = build_tag_string(tag_names, ' ')
|
||||
search_form = BookmarkSearchForm(search, editable_fields=['q'])
|
||||
|
||||
if mode == 'shared':
|
||||
preferences_form = BookmarkSearchForm(search, editable_fields=['sort'])
|
||||
else:
|
||||
preferences_form = BookmarkSearchForm(search, editable_fields=['sort', 'shared', 'unread'])
|
||||
return {
|
||||
'request': context['request'],
|
||||
'filters': filters,
|
||||
'search': search,
|
||||
'search_form': search_form,
|
||||
'preferences_form': preferences_form,
|
||||
'tags_string': tags_string,
|
||||
'mode': mode,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True)
|
||||
def user_select(context, filters: BookmarkFilters, users: List[User]):
|
||||
def user_select(context, search: BookmarkSearch, users: List[User]):
|
||||
sorted_users = sorted(users, key=lambda x: str.lower(x.username))
|
||||
form = BookmarkSearchForm(search, editable_fields=['user'], users=sorted_users)
|
||||
return {
|
||||
'filters': filters,
|
||||
'search': search,
|
||||
'users': sorted_users,
|
||||
'form': form,
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@ class BookmarkFactoryMixin:
|
||||
tags=None,
|
||||
user: User = None,
|
||||
url: str = '',
|
||||
title: str = '',
|
||||
title: str = None,
|
||||
description: str = '',
|
||||
notes: str = '',
|
||||
website_title: str = '',
|
||||
@@ -38,7 +38,7 @@ class BookmarkFactoryMixin:
|
||||
favicon_file: str = '',
|
||||
added: datetime = None,
|
||||
):
|
||||
if not title:
|
||||
if title is None:
|
||||
title = get_random_string(length=32)
|
||||
if tags is None:
|
||||
tags = []
|
||||
@@ -77,10 +77,12 @@ class BookmarkFactoryMixin:
|
||||
suffix: str = '',
|
||||
tag_prefix: str = '',
|
||||
archived: bool = False,
|
||||
unread: bool = False,
|
||||
shared: bool = False,
|
||||
with_tags: bool = False,
|
||||
user: User = None):
|
||||
user = user or self.get_or_create_test_user()
|
||||
bookmarks = []
|
||||
|
||||
if not prefix:
|
||||
if archived:
|
||||
@@ -104,8 +106,17 @@ class BookmarkFactoryMixin:
|
||||
tags = []
|
||||
if with_tags:
|
||||
tag_name = f'{tag_prefix} {i}{suffix}'
|
||||
tags = [self.setup_tag(name=tag_name)]
|
||||
self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags, user=user)
|
||||
tags = [self.setup_tag(name=tag_name, user=user)]
|
||||
bookmark = self.setup_bookmark(url=url,
|
||||
title=title,
|
||||
is_archived=archived,
|
||||
unread=unread,
|
||||
shared=shared,
|
||||
tags=tags,
|
||||
user=user)
|
||||
bookmarks.append(bookmark)
|
||||
|
||||
return bookmarks
|
||||
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
return Bookmark.objects.get(title=title)
|
||||
@@ -128,6 +139,15 @@ class BookmarkFactoryMixin:
|
||||
user.profile.save()
|
||||
return user
|
||||
|
||||
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
|
||||
all_tags = []
|
||||
for bookmark in bookmarks:
|
||||
all_tags = all_tags + list(bookmark.tags.all())
|
||||
return all_tags
|
||||
|
||||
def get_random_string(self, length: int = 32):
|
||||
return get_random_string(length=length)
|
||||
|
||||
|
||||
class HtmlTestMixin:
|
||||
def make_soup(self, html: str):
|
||||
|
@@ -440,21 +440,6 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_select_across_respects_query(self):
|
||||
self.setup_numbered_bookmarks(3, prefix='foo')
|
||||
self.setup_numbered_bookmarks(3, prefix='bar')
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
|
||||
|
||||
def test_bulk_select_across_ignores_page(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
@@ -493,6 +478,21 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 2').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 3').first())
|
||||
|
||||
def test_index_action_bulk_select_across_respects_query(self):
|
||||
self.setup_numbered_bookmarks(3, prefix='foo')
|
||||
self.setup_numbered_bookmarks(3, prefix='bar')
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
|
||||
|
||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
@@ -511,6 +511,21 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
|
||||
|
||||
def test_archived_action_bulk_select_across_respects_query(self):
|
||||
self.setup_numbered_bookmarks(3, prefix='foo', archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix='bar', archived=True)
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action') + '?q=foo', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
|
||||
|
||||
def test_shared_action_bulk_select_across_not_supported(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
|
||||
|
||||
@@ -15,38 +16,51 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
|
||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
bookmark_item = bookmark_list.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
self.assertIsNotNone(bookmark_item)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html,
|
||||
count=0
|
||||
)
|
||||
bookmark_item = soup.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
self.assertIsNone(bookmark_item)
|
||||
|
||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
||||
self.assertContains(response, 'data-is-tag-item', count=len(tags))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_cloud = soup.select_one('div.tag-cloud')
|
||||
self.assertIsNotNone(tag_cloud)
|
||||
|
||||
tag_items = tag_cloud.select('a[data-is-tag-item]')
|
||||
self.assertEqual(len(tag_items), len(tags))
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
for tag in tags:
|
||||
self.assertContains(response, tag.name)
|
||||
self.assertTrue(tag.name in tag_item_names)
|
||||
|
||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_items = soup.select('a[data-is-tag-item]')
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
for tag in tags:
|
||||
self.assertNotContains(response, tag.name)
|
||||
self.assertFalse(tag.name in tag_item_names)
|
||||
|
||||
def assertSelectedTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
selected_tags = soup.select('p.selected-tags')[0]
|
||||
selected_tags = soup.select_one('p.selected-tags')
|
||||
self.assertIsNotNone(selected_tags)
|
||||
|
||||
tag_list = selected_tags.select('a')
|
||||
@@ -55,69 +69,53 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
for tag in tags:
|
||||
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
|
||||
|
||||
def assertEditLink(self, response, url):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(f'''
|
||||
<a href="{url}">Edit</a>
|
||||
''', html)
|
||||
|
||||
def assertBulkActionForm(self, response, url: str):
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
needle = collapse_whitespace(f'''
|
||||
<form class="bookmark-actions"
|
||||
action="{url}"
|
||||
method="post" autocomplete="off">
|
||||
''')
|
||||
self.assertIn(needle, html)
|
||||
|
||||
def test_should_list_archived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True)
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=False),
|
||||
self.setup_bookmark(is_archived=True, user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_query(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True, title='searchvalue'),
|
||||
self.setup_bookmark(is_archived=True, title='searchvalue'),
|
||||
self.setup_bookmark(is_archived=True, title='searchvalue')
|
||||
]
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True)
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix='foo', archived=True)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix='bar', archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=foo')
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
visible_tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
invisible_tags = [
|
||||
self.setup_tag(), # unused tag
|
||||
self.setup_tag(), # used in archived bookmark
|
||||
self.setup_tag(user=other_user), # belongs to other user
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
unarchived_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=False, tag_prefix='unarchived')
|
||||
other_user_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, user=other_user,
|
||||
tag_prefix='otheruser')
|
||||
|
||||
# Assign tags to some bookmarks with duplicates
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
|
||||
|
||||
self.setup_bookmark(is_archived=False, tags=[invisible_tags[1]])
|
||||
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]], user=other_user)
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(unarchived_bookmarks + other_user_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
|
||||
@@ -125,29 +123,40 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_query(self):
|
||||
visible_tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
invisible_tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, prefix='foo',
|
||||
tag_prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, prefix='bar',
|
||||
tag_prefix='bar')
|
||||
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue')
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue')
|
||||
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue')
|
||||
self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]])
|
||||
self.setup_bookmark(is_archived=True, tags=[invisible_tags[1]])
|
||||
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]])
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=foo')
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
user_profile.save()
|
||||
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(3, archived=True, unread=True, with_tags=True, prefix='unread',
|
||||
tag_prefix='unread')
|
||||
read_bookmarks = self.setup_numbered_bookmarks(3, archived=True, unread=False, with_tags=True, prefix='read',
|
||||
tag_prefix='read')
|
||||
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
self.assertInvisibleTags(response, read_tags)
|
||||
|
||||
def test_should_display_selected_tags_from_query(self):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
@@ -194,11 +203,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True)
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
|
||||
@@ -209,16 +214,67 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
user.profile.save()
|
||||
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(is_archived=True)
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
|
||||
def test_edit_link_return_url_respects_search_options(self):
|
||||
bookmark = self.setup_bookmark(title='foo', is_archived=True)
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
base_url = reverse('bookmarks:archived')
|
||||
|
||||
# without query params
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query and sort and page
|
||||
url_params = '?q=foo&sort=title_asc&page=2'
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
def test_bulk_edit_respects_search_options(self):
|
||||
action_url = reverse('bookmarks:archived.action')
|
||||
base_url = reverse('bookmarks:archived')
|
||||
|
||||
# without params
|
||||
return_url = urllib.parse.quote_plus(base_url)
|
||||
url = f'{action_url}?return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query and sort
|
||||
url_params = '?q=foo&sort=title_asc'
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse('bookmarks:archived')
|
||||
response = self.client.get(url)
|
||||
@@ -257,6 +313,106 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
</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)
|
||||
@@ -266,3 +422,31 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
self.assertEqual(actions_form.attrs['action'],
|
||||
'/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo')
|
||||
|
||||
def test_encode_search_params(self):
|
||||
bookmark = self.setup_bookmark(description='alert(\'xss\')', is_archived=True)
|
||||
|
||||
url = reverse('bookmarks:archived') + '?q=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse('bookmarks:archived') + '?sort=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:archived') + '?unread=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:archived') + '?shared=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:archived') + '?user=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:archived') + '?page=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
@@ -1,11 +1,11 @@
|
||||
from typing import List
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
|
||||
|
||||
@@ -16,38 +16,51 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
|
||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
bookmark_item = bookmark_list.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
self.assertIsNotNone(bookmark_item)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html,
|
||||
count=0
|
||||
)
|
||||
bookmark_item = soup.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
self.assertIsNone(bookmark_item)
|
||||
|
||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
||||
self.assertContains(response, 'data-is-tag-item', count=len(tags))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_cloud = soup.select_one('div.tag-cloud')
|
||||
self.assertIsNotNone(tag_cloud)
|
||||
|
||||
tag_items = tag_cloud.select('a[data-is-tag-item]')
|
||||
self.assertEqual(len(tag_items), len(tags))
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
for tag in tags:
|
||||
self.assertContains(response, tag.name)
|
||||
self.assertTrue(tag.name in tag_item_names)
|
||||
|
||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_items = soup.select('a[data-is-tag-item]')
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
for tag in tags:
|
||||
self.assertNotContains(response, tag.name)
|
||||
self.assertFalse(tag.name in tag_item_names)
|
||||
|
||||
def assertSelectedTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
selected_tags = soup.select('p.selected-tags')[0]
|
||||
selected_tags = soup.select_one('p.selected-tags')
|
||||
self.assertIsNotNone(selected_tags)
|
||||
|
||||
tag_list = selected_tags.select('a')
|
||||
@@ -56,69 +69,51 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
for tag in tags:
|
||||
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
|
||||
|
||||
def assertEditLink(self, response, url):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(f'''
|
||||
<a href="{url}">Edit</a>
|
||||
''', html)
|
||||
|
||||
def assertBulkActionForm(self, response, url: str):
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
needle = collapse_whitespace(f'''
|
||||
<form class="bookmark-actions"
|
||||
action="{url}"
|
||||
method="post" autocomplete="off">
|
||||
''')
|
||||
self.assertIn(needle, html)
|
||||
|
||||
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark()
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_query(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(title='searchvalue'),
|
||||
self.setup_bookmark(title='searchvalue'),
|
||||
self.setup_bookmark(title='searchvalue')
|
||||
]
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark()
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix='bar')
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
visible_tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
invisible_tags = [
|
||||
self.setup_tag(), # unused tag
|
||||
self.setup_tag(), # used in archived bookmark
|
||||
self.setup_tag(user=other_user), # belongs to other user
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
archived_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, tag_prefix='archived')
|
||||
other_user_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, user=other_user, tag_prefix='otheruser')
|
||||
|
||||
# Assign tags to some bookmarks with duplicates
|
||||
self.setup_bookmark(tags=[visible_tags[0]])
|
||||
self.setup_bookmark(tags=[visible_tags[0]])
|
||||
self.setup_bookmark(tags=[visible_tags[1]])
|
||||
self.setup_bookmark(tags=[visible_tags[1]])
|
||||
self.setup_bookmark(tags=[visible_tags[2]])
|
||||
self.setup_bookmark(tags=[visible_tags[2]])
|
||||
|
||||
self.setup_bookmark(tags=[invisible_tags[1]], is_archived=True)
|
||||
self.setup_bookmark(tags=[invisible_tags[2]], user=other_user)
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(archived_bookmarks + other_user_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
@@ -126,29 +121,38 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_query(self):
|
||||
visible_tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
invisible_tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, prefix='foo', tag_prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, prefix='bar', tag_prefix='bar')
|
||||
|
||||
self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue')
|
||||
self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue')
|
||||
self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue')
|
||||
self.setup_bookmark(tags=[invisible_tags[0]])
|
||||
self.setup_bookmark(tags=[invisible_tags[1]])
|
||||
self.setup_bookmark(tags=[invisible_tags[2]])
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
user_profile.save()
|
||||
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(3, unread=True, with_tags=True, prefix='unread',
|
||||
tag_prefix='unread')
|
||||
read_bookmarks = self.setup_numbered_bookmarks(3, unread=False, with_tags=True, prefix='read',
|
||||
tag_prefix='read')
|
||||
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
self.assertInvisibleTags(response, read_tags)
|
||||
|
||||
def test_should_display_selected_tags_from_query(self):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
@@ -195,11 +199,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark()
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
@@ -210,40 +210,66 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
user.profile.save()
|
||||
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark()
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
|
||||
def test_edit_link_return_url_should_contain_query_params(self):
|
||||
def test_edit_link_return_url_respects_search_options(self):
|
||||
bookmark = self.setup_bookmark(title='foo')
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
base_url = reverse('bookmarks:index')
|
||||
|
||||
# without query params
|
||||
url = reverse('bookmarks:index')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
return_url = urllib.parse.quote_plus(url)
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
''', html)
|
||||
response = self.client.get(base_url)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query params
|
||||
url = reverse('bookmarks:index') + '?q=foo&user=user'
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
return_url = urllib.parse.quote_plus(url)
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
''', html)
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query and sort and page
|
||||
url_params = '?q=foo&sort=title_asc&page=2'
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
def test_bulk_edit_respects_search_options(self):
|
||||
action_url = reverse('bookmarks:index.action')
|
||||
base_url = reverse('bookmarks:index')
|
||||
|
||||
# without params
|
||||
return_url = urllib.parse.quote_plus(base_url)
|
||||
url = f'{action_url}?return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query and sort
|
||||
url_params = '?q=foo&sort=title_asc'
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse('bookmarks:index')
|
||||
@@ -283,6 +309,106 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
</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)
|
||||
@@ -292,3 +418,31 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.assertEqual(actions_form.attrs['action'],
|
||||
'/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo')
|
||||
|
||||
def test_encode_search_params(self):
|
||||
bookmark = self.setup_bookmark(description='alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:index') + '?q=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse('bookmarks:index') + '?sort=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:index') + '?unread=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:index') + '?shared=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:index') + '?user=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:index') + '?page=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
@@ -183,9 +183,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
</div>
|
||||
''', html)
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
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,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, collapse_whitespace, HtmlTestMixin
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -21,48 +22,69 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
self.assertContains(response, '<li ld-bookmark-item class="shared">', count=len(bookmarks))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
|
||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertBookmarkCount(html, bookmark, 1, link_target)
|
||||
bookmark_item = bookmark_list.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
self.assertIsNotNone(bookmark_item)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertBookmarkCount(html, bookmark, 0, link_target)
|
||||
bookmark_item = soup.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
self.assertIsNone(bookmark_item)
|
||||
|
||||
def assertVisibleTags(self, response, tags: [Tag]):
|
||||
self.assertContains(response, 'data-is-tag-item', count=len(tags))
|
||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_cloud = soup.select_one('div.tag-cloud')
|
||||
self.assertIsNotNone(tag_cloud)
|
||||
|
||||
tag_items = tag_cloud.select('a[data-is-tag-item]')
|
||||
self.assertEqual(len(tag_items), len(tags))
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
for tag in tags:
|
||||
self.assertContains(response, tag.name)
|
||||
self.assertTrue(tag.name in tag_item_names)
|
||||
|
||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_items = soup.select('a[data-is-tag-item]')
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
def assertInvisibleTags(self, response, tags: [Tag]):
|
||||
for tag in tags:
|
||||
self.assertNotContains(response, tag.name)
|
||||
self.assertFalse(tag.name in tag_item_names)
|
||||
|
||||
def assertVisibleUserOptions(self, response, users: List[User]):
|
||||
html = response.content.decode()
|
||||
self.assertContains(response, 'data-is-user-option', count=len(users))
|
||||
|
||||
user_options = [
|
||||
'<option value="" selected="">Everyone</option>'
|
||||
]
|
||||
for user in users:
|
||||
self.assertInHTML(f'''
|
||||
<option value="{user.username}" data-is-user-option>
|
||||
{user.username}
|
||||
</option>
|
||||
''', html)
|
||||
user_options.append(f'<option value="{user.username}">{user.username}</option>')
|
||||
user_select_html = f'''
|
||||
<select name="user" class="form-select" required="" id="id_user">
|
||||
{''.join(user_options)}
|
||||
</select>
|
||||
'''
|
||||
|
||||
def assertInvisibleUserOptions(self, response, users: List[User]):
|
||||
self.assertInHTML(user_select_html, html)
|
||||
|
||||
def assertEditLink(self, response, url):
|
||||
html = response.content.decode()
|
||||
|
||||
for user in users:
|
||||
self.assertInHTML(f'''
|
||||
<option value="{user.username}" data-is-user-option>
|
||||
{user.username}
|
||||
</option>
|
||||
''', html, count=0)
|
||||
self.assertInHTML(f'''
|
||||
<a href="{url}">Edit</a>
|
||||
''', html)
|
||||
|
||||
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
|
||||
self.authenticate()
|
||||
@@ -84,10 +106,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -114,22 +133,12 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def test_should_list_bookmarks_matching_query(self):
|
||||
self.authenticate()
|
||||
user = self.setup_user(enable_sharing=True)
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(shared=True, title='searchvalue', user=user),
|
||||
self.setup_bookmark(shared=True, title='searchvalue', user=user),
|
||||
self.setup_bookmark(shared=True, title='searchvalue', user=user)
|
||||
]
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(shared=True, user=user),
|
||||
self.setup_bookmark(shared=True, user=user),
|
||||
self.setup_bookmark(shared=True, user=user)
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user, prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared') + '?q=foo')
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -137,22 +146,11 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(shared=True, user=user1),
|
||||
self.setup_bookmark(shared=True, user=user1),
|
||||
self.setup_bookmark(shared=True, user=user1),
|
||||
]
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(shared=True, user=user2),
|
||||
self.setup_bookmark(shared=True, user=user2),
|
||||
self.setup_bookmark(shared=True, user=user2),
|
||||
]
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user1, prefix='user1')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user2, prefix='user2')
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -267,41 +265,57 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
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()
|
||||
@@ -335,6 +349,148 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
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)
|
||||
@@ -344,3 +500,35 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.assertEqual(actions_form.attrs['action'],
|
||||
'/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo')
|
||||
|
||||
def test_encode_search_params(self):
|
||||
self.authenticate()
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.enable_sharing = True
|
||||
user.profile.save()
|
||||
bookmark = self.setup_bookmark(description='alert(\'xss\')', shared=True)
|
||||
|
||||
url = reverse('bookmarks:shared') + '?q=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse('bookmarks:shared') + '?sort=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:shared') + '?unread=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:shared') + '?shared=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:shared') + '?user=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
||||
url = reverse('bookmarks:shared') + '?page=alert(%27xss%27)'
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
|
@@ -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)
|
||||
|
@@ -24,7 +24,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
target="{link_target}"
|
||||
rel="noopener">
|
||||
{favicon_img}
|
||||
{bookmark.resolved_title}
|
||||
<span>{bookmark.resolved_title}</span>
|
||||
</a>
|
||||
''',
|
||||
html
|
||||
@@ -55,7 +55,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
# Edit link
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url=%2Fbookmarks">Edit</a>
|
||||
<a href="{edit_url}?return_url=/bookmarks">Edit</a>
|
||||
''', html, count=count)
|
||||
# Archive link
|
||||
self.assertInHTML(f'''
|
||||
|
@@ -336,6 +336,28 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_tag_bookmarks_should_handle_existing_relationships(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1])
|
||||
|
||||
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||
self.assertEqual(3, BookmarkToTagRelationShip.objects.count())
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
self.assertEqual(6, BookmarkToTagRelationShip.objects.count())
|
||||
|
||||
def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
|
@@ -18,7 +18,13 @@ 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'),
|
||||
self.setup_bookmark(url='https://example.com/7', title='Title 7', added=added, is_archived=True),
|
||||
self.setup_bookmark(url='https://example.com/8', title='Title 8', added=added,
|
||||
tags=[self.setup_tag(name='tag4'), self.setup_tag(name='tag5')], is_archived=True),
|
||||
]
|
||||
html = exporter.export_netscape_html(bookmarks)
|
||||
|
||||
@@ -28,13 +34,20 @@ 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]',
|
||||
f'<DT><A HREF="https://example.com/7" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
|
||||
f'<DT><A HREF="https://example.com/8" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
|
||||
]
|
||||
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 +56,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()
|
||||
|
@@ -7,6 +7,8 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.models import FeedToken, User
|
||||
from bookmarks.feeds import sanitize
|
||||
|
||||
|
||||
|
||||
def rfc2822_date(date):
|
||||
@@ -104,6 +106,17 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertContains(response, '<item>', count=0)
|
||||
|
||||
def test_strip_control_characters(self):
|
||||
self.setup_bookmark(title='test\n\r\t\0\x08title', description='test\n\r\t\0\x08description')
|
||||
response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=1)
|
||||
self.assertContains(response, f'<title>test\n\r\ttitle</title>', count=1)
|
||||
self.assertContains(response, f'<description>test\n\r\tdescription</description>', count=1)
|
||||
|
||||
def test_sanitize_with_none_text(self):
|
||||
self.assertEqual('', sanitize(None))
|
||||
|
||||
def test_unread_returns_404_for_unknown_feed_token(self):
|
||||
response = self.client.get(reverse('bookmarks:feeds.unread', args=['foo']))
|
||||
|
||||
|
@@ -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,61 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
self.assertEqual(bookmark2.shared, False)
|
||||
self.assertEqual(bookmark3.shared, True)
|
||||
|
||||
def test_archived_state(self):
|
||||
test_html = self.render_html(tags_html='''
|
||||
<DT><A HREF="https://example.com/1" ADD_DATE="1" TAGS="tag1,tag2,linkding:archived">Example title 1</A>
|
||||
<DD>Example description 1</DD>
|
||||
<DT><A HREF="https://example.com/2" ADD_DATE="1" PRIVATE="1" TAGS="tag1,tag2">Example title 2</A>
|
||||
<DD>Example description 2</DD>
|
||||
<DT><A HREF="https://example.com/3" ADD_DATE="1" PRIVATE="0">Example title 3</A>
|
||||
<DD>Example description 3</DD>
|
||||
''')
|
||||
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 3)
|
||||
self.assertEqual(Bookmark.objects.all()[0].is_archived, True)
|
||||
self.assertEqual(Bookmark.objects.all()[1].is_archived, False)
|
||||
self.assertEqual(Bookmark.objects.all()[2].is_archived, False)
|
||||
|
||||
tags = Tag.objects.all()
|
||||
self.assertEqual(len(tags), 2)
|
||||
self.assertEqual(tags[0].name, 'tag1')
|
||||
self.assertEqual(tags[1].name, 'tag2')
|
||||
|
||||
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')
|
||||
|
@@ -2,6 +2,7 @@ from typing import List
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.models import parse_tag_string
|
||||
from bookmarks.services.parser import NetscapeBookmark
|
||||
from bookmarks.services.parser import parse
|
||||
from bookmarks.tests.helpers import ImportTestMixin, BookmarkHtmlTag
|
||||
@@ -16,7 +17,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
self.assertEqual(bookmark.title, html_tag.title)
|
||||
self.assertEqual(bookmark.date_added, html_tag.add_date)
|
||||
self.assertEqual(bookmark.description, html_tag.description)
|
||||
self.assertEqual(bookmark.tag_string, html_tag.tags)
|
||||
self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))
|
||||
self.assertEqual(bookmark.to_read, html_tag.to_read)
|
||||
self.assertEqual(bookmark.private, html_tag.private)
|
||||
|
||||
@@ -149,3 +150,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)
|
||||
|
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
@@ -20,6 +21,9 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[self.setup_tag()])
|
||||
self.setup_bookmark(tags=[self.setup_tag()])
|
||||
self.setup_bookmark(tags=[self.setup_tag()])
|
||||
self.setup_bookmark(tags=[self.setup_tag()], is_archived=True)
|
||||
self.setup_bookmark(tags=[self.setup_tag()], is_archived=True)
|
||||
self.setup_bookmark(tags=[self.setup_tag()], is_archived=True)
|
||||
|
||||
response = self.client.get(
|
||||
reverse('bookmarks:settings.export'),
|
||||
@@ -30,6 +34,35 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(response['content-type'], 'text/plain; charset=UTF-8')
|
||||
self.assertEqual(response['Content-Disposition'], 'attachment; filename="bookmarks.html"')
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
def test_should_only_export_user_bookmarks(self):
|
||||
other_user = self.setup_user()
|
||||
owned_bookmarks = [
|
||||
self.setup_bookmark(tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(tags=[self.setup_tag()]),
|
||||
]
|
||||
non_owned_bookmarks = [
|
||||
self.setup_bookmark(tags=[self.setup_tag()], user=other_user),
|
||||
self.setup_bookmark(tags=[self.setup_tag()], user=other_user),
|
||||
self.setup_bookmark(tags=[self.setup_tag()], user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(
|
||||
reverse('bookmarks:settings.export'),
|
||||
follow=True
|
||||
)
|
||||
|
||||
text = response.content.decode('utf-8')
|
||||
|
||||
for bookmark in owned_bookmarks:
|
||||
self.assertIn(bookmark.url, text)
|
||||
|
||||
for bookmark in non_owned_bookmarks:
|
||||
self.assertNotIn(bookmark.url, text)
|
||||
|
||||
def test_should_check_authentication(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse('bookmarks:settings.export'), follow=True)
|
||||
|
@@ -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'),
|
||||
|
@@ -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')
|
||||
|
@@ -29,14 +29,17 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
# clear cached metadata before test run
|
||||
website_loader.load_website_metadata.cache_clear()
|
||||
|
||||
def render_html_document(self, title, description):
|
||||
def render_html_document(self, title, description='', og_description=''):
|
||||
meta_description = f'<meta name="description" content="{description}">' if description else ''
|
||||
meta_og_description = f'<meta property="og:description" content="{og_description}">' if og_description else ''
|
||||
return f'''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content="{description}">
|
||||
{meta_description}
|
||||
{meta_og_description}
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -94,3 +97,19 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
metadata = website_loader.load_website_metadata('https://example.com')
|
||||
self.assertEqual('test title', metadata.title)
|
||||
self.assertEqual('test description', metadata.description)
|
||||
|
||||
def test_load_website_metadata_using_og_description(self):
|
||||
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
|
||||
mock_load_page.return_value = self.render_html_document('test title', '',
|
||||
og_description='test og description')
|
||||
metadata = website_loader.load_website_metadata('https://example.com')
|
||||
self.assertEqual('test title', metadata.title)
|
||||
self.assertEqual('test og description', metadata.description)
|
||||
|
||||
def test_load_website_metadata_prefers_description_over_og_description(self):
|
||||
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
|
||||
mock_load_page.return_value = self.render_html_document('test title', 'test description',
|
||||
og_description='test og description')
|
||||
metadata = website_loader.load_website_metadata('https://example.com')
|
||||
self.assertEqual('test title', metadata.title)
|
||||
self.assertEqual('test description', metadata.description)
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest
|
||||
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkSearch, build_tag_string
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
|
||||
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
|
||||
@@ -17,6 +19,9 @@ _default_page_size = 30
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
if request.method == 'POST':
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||
return render(request, 'bookmarks/index.html', {
|
||||
@@ -27,6 +32,9 @@ def index(request):
|
||||
|
||||
@login_required
|
||||
def archived(request):
|
||||
if request.method == 'POST':
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||
return render(request, 'bookmarks/archive.html', {
|
||||
@@ -36,11 +44,13 @@ def archived(request):
|
||||
|
||||
|
||||
def shared(request):
|
||||
filters = BookmarkFilters(request)
|
||||
if request.method == 'POST':
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||
public_only = not request.user.is_authenticated
|
||||
users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only)
|
||||
users = queries.query_shared_bookmark_users(request.user_profile, bookmark_list.search, public_only)
|
||||
return render(request, 'bookmarks/shared.html', {
|
||||
'bookmark_list': bookmark_list,
|
||||
'tag_cloud': tag_cloud,
|
||||
@@ -48,6 +58,23 @@ def shared(request):
|
||||
})
|
||||
|
||||
|
||||
def search_action(request):
|
||||
if 'save' in request.POST:
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseForbidden()
|
||||
search = BookmarkSearch.from_request(request.POST)
|
||||
request.user_profile.search_preferences = search.preferences_dict
|
||||
request.user_profile.save()
|
||||
|
||||
# redirect to base url including new query params
|
||||
search = BookmarkSearch.from_request(request.POST, request.user_profile.search_preferences)
|
||||
base_url = request.path
|
||||
query_params = search.query_params
|
||||
query_string = urllib.parse.urlencode(query_params)
|
||||
url = base_url if not query_string else base_url + '?' + query_string
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
@@ -169,15 +196,15 @@ def mark_as_read(request, bookmark_id: int):
|
||||
|
||||
@login_required
|
||||
def index_action(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query = queries.query_bookmarks(request.user, request.user_profile, filters.query)
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
||||
return action(request, query)
|
||||
|
||||
|
||||
@login_required
|
||||
def archived_action(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query)
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||
return action(request, query)
|
||||
|
||||
|
||||
|
@@ -7,8 +7,8 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkFilters, User, UserProfile, Tag
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, BookmarkSearchForm, User, UserProfile, Tag
|
||||
|
||||
DEFAULT_PAGE_SIZE = 30
|
||||
|
||||
@@ -54,11 +54,12 @@ class BookmarkItem:
|
||||
|
||||
class BookmarkListContext:
|
||||
def __init__(self, request: WSGIRequest) -> None:
|
||||
self.request = request
|
||||
self.filters = BookmarkFilters(self.request)
|
||||
|
||||
user = request.user
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
self.search = BookmarkSearch.from_request(self.request.GET, user_profile.search_preferences)
|
||||
|
||||
query_set = self.get_bookmark_query_set()
|
||||
page_number = request.GET.get('page')
|
||||
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
|
||||
@@ -71,29 +72,37 @@ class BookmarkListContext:
|
||||
self.is_empty = paginator.count == 0
|
||||
self.bookmarks_page = bookmarks_page
|
||||
self.bookmarks_total = paginator.count
|
||||
self.return_url = self.generate_return_url(page_number)
|
||||
self.return_url = self.generate_return_url(self.search, self.get_base_url(), page_number)
|
||||
self.action_url = self.generate_action_url(self.search, self.get_base_action_url(), self.return_url)
|
||||
self.link_target = user_profile.bookmark_link_target
|
||||
self.date_display = user_profile.bookmark_date_display
|
||||
self.show_url = user_profile.display_url
|
||||
self.show_favicons = user_profile.enable_favicons
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
|
||||
def generate_return_url(self, page: int):
|
||||
base_url = self.get_base_url()
|
||||
url_query = {}
|
||||
if self.filters.query:
|
||||
url_query['q'] = self.filters.query
|
||||
if self.filters.user:
|
||||
url_query['user'] = self.filters.user
|
||||
@staticmethod
|
||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
||||
query_params = search.query_params
|
||||
if page is not None:
|
||||
url_query['page'] = page
|
||||
url_params = urllib.parse.urlencode(url_query)
|
||||
return_url = base_url if url_params == '' else base_url + '?' + url_params
|
||||
return urllib.parse.quote_plus(return_url)
|
||||
query_params['page'] = page
|
||||
query_string = urllib.parse.urlencode(query_params)
|
||||
|
||||
return base_url if query_string == '' else base_url + '?' + query_string
|
||||
|
||||
@staticmethod
|
||||
def generate_action_url(search: BookmarkSearch, base_action_url: str, return_url: str):
|
||||
query_params = search.query_params
|
||||
query_params['return_url'] = return_url
|
||||
query_string = urllib.parse.urlencode(query_params)
|
||||
|
||||
return base_action_url if query_string == '' else base_action_url + '?' + query_string
|
||||
|
||||
def get_base_url(self):
|
||||
raise Exception(f'Must be implemented by subclass')
|
||||
|
||||
def get_base_action_url(self):
|
||||
raise Exception(f'Must be implemented by subclass')
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
raise Exception(f'Must be implemented by subclass')
|
||||
|
||||
@@ -102,32 +111,41 @@ class ActiveBookmarkListContext(BookmarkListContext):
|
||||
def get_base_url(self):
|
||||
return reverse('bookmarks:index')
|
||||
|
||||
def get_base_action_url(self):
|
||||
return reverse('bookmarks:index.action')
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
return queries.query_bookmarks(self.request.user,
|
||||
self.request.user_profile,
|
||||
self.filters.query)
|
||||
self.search)
|
||||
|
||||
|
||||
class ArchivedBookmarkListContext(BookmarkListContext):
|
||||
def get_base_url(self):
|
||||
return reverse('bookmarks:archived')
|
||||
|
||||
def get_base_action_url(self):
|
||||
return reverse('bookmarks:archived.action')
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
return queries.query_archived_bookmarks(self.request.user,
|
||||
self.request.user_profile,
|
||||
self.filters.query)
|
||||
self.search)
|
||||
|
||||
|
||||
class SharedBookmarkListContext(BookmarkListContext):
|
||||
def get_base_url(self):
|
||||
return reverse('bookmarks:shared')
|
||||
|
||||
def get_base_action_url(self):
|
||||
return reverse('bookmarks:shared.action')
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
user = User.objects.filter(username=self.filters.user).first()
|
||||
user = User.objects.filter(username=self.search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmarks(user,
|
||||
self.request.user_profile,
|
||||
self.filters.query,
|
||||
self.search,
|
||||
public_only)
|
||||
|
||||
|
||||
@@ -158,8 +176,10 @@ class TagGroup:
|
||||
|
||||
class TagCloudContext:
|
||||
def __init__(self, request: WSGIRequest) -> None:
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
self.filters = BookmarkFilters(self.request)
|
||||
self.search = BookmarkSearch.from_request(self.request.GET, user_profile.search_preferences)
|
||||
|
||||
query_set = self.get_tag_query_set()
|
||||
tags = list(query_set)
|
||||
@@ -179,7 +199,7 @@ class TagCloudContext:
|
||||
raise Exception(f'Must be implemented by subclass')
|
||||
|
||||
def get_selected_tags(self, tags: List[Tag]):
|
||||
parsed_query = queries.parse_query_string(self.filters.query)
|
||||
parsed_query = queries.parse_query_string(self.search.q)
|
||||
tag_names = parsed_query['tag_names']
|
||||
if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
tag_names = tag_names + parsed_query['search_terms']
|
||||
@@ -192,21 +212,21 @@ class ActiveTagCloudContext(TagCloudContext):
|
||||
def get_tag_query_set(self):
|
||||
return queries.query_bookmark_tags(self.request.user,
|
||||
self.request.user_profile,
|
||||
self.filters.query)
|
||||
self.search)
|
||||
|
||||
|
||||
class ArchivedTagCloudContext(TagCloudContext):
|
||||
def get_tag_query_set(self):
|
||||
return queries.query_archived_bookmark_tags(self.request.user,
|
||||
self.request.user_profile,
|
||||
self.filters.query)
|
||||
self.search)
|
||||
|
||||
|
||||
class SharedTagCloudContext(TagCloudContext):
|
||||
def get_tag_query_set(self):
|
||||
user = User.objects.filter(username=self.filters.user).first()
|
||||
user = User.objects.filter(username=self.search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmark_tags(user,
|
||||
self.request.user_profile,
|
||||
self.filters.query,
|
||||
self.search,
|
||||
public_only)
|
||||
|
@@ -12,8 +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.queries import query_bookmarks
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, UserProfileForm, FeedToken
|
||||
from bookmarks.services import exporter, tasks
|
||||
from bookmarks.services import importer
|
||||
from bookmarks.utils import app_version
|
||||
@@ -136,7 +135,7 @@ def bookmark_import(request):
|
||||
def bookmark_export(request):
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
bookmarks = list(query_bookmarks(request.user, request.user_profile, ''))
|
||||
bookmarks = Bookmark.objects.filter(owner=request.user)
|
||||
# Prefetch tags to prevent n+1 queries
|
||||
prefetch_related_objects(bookmarks, 'tags')
|
||||
file_content = exporter.export_netscape_html(bookmarks)
|
||||
|
89
docker/alpine.Dockerfile
Normal file
89
docker/alpine.Dockerfile
Normal file
@@ -0,0 +1,89 @@
|
||||
FROM node:18.18.0-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY rollup.config.js package.json package-lock.json ./
|
||||
RUN npm install
|
||||
# copy files needed for JS build
|
||||
COPY bookmarks/frontend ./bookmarks/frontend
|
||||
# run build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.10.13-alpine3.18 AS python-base
|
||||
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
|
||||
FROM python-base AS python-build
|
||||
# install build dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
# remove playwright from requirements as there is not always a distro and it's not needed for the build
|
||||
RUN sed -i '/playwright/d' requirements.txt
|
||||
RUN pip install -U pip && pip install -Ur requirements.txt
|
||||
# copy files needed for Django build
|
||||
COPY . .
|
||||
COPY --from=node-build /etc/linkding .
|
||||
# run Django part of the build
|
||||
RUN python manage.py compilescss && \
|
||||
python manage.py collectstatic --ignore=*.scss && \
|
||||
python manage.py compilescss --delete-files
|
||||
|
||||
|
||||
FROM python-base AS prod-deps
|
||||
COPY requirements.prod.txt ./requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip wheel && \
|
||||
/opt/venv/bin/pip install -Ur requirements.txt
|
||||
|
||||
|
||||
FROM python-base AS compile-icu
|
||||
# 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.13-alpine3.18 AS final
|
||||
# install runtime dependencies
|
||||
RUN apk update && apk add bash curl icu libpq mailcap
|
||||
# create www-data user and group
|
||||
RUN set -x ; \
|
||||
addgroup -g 82 -S www-data ; \
|
||||
adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1
|
||||
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
|
||||
EXPOSE 9090
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV /opt/venv
|
||||
ENV PATH /opt/venv/bin:$PATH
|
||||
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
||||
RUN chmod g+w . && \
|
||||
chmod +x ./bootstrap.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
|
||||
CMD ["./bootstrap.sh"]
|
@@ -1,11 +1,11 @@
|
||||
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 ./
|
||||
RUN npm install -g npm && \
|
||||
npm install
|
||||
# compile JS components
|
||||
COPY . .
|
||||
COPY rollup.config.js package.json package-lock.json ./
|
||||
RUN npm install
|
||||
# copy files needed for JS build
|
||||
COPY bookmarks/frontend ./bookmarks/frontend
|
||||
# run build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -17,9 +17,13 @@ WORKDIR /etc/linkding
|
||||
FROM python-base AS python-build
|
||||
# install build dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
# remove playwright from requirements as there is not always a distro and it's not needed for the build
|
||||
RUN sed -i '/playwright/d' requirements.txt
|
||||
RUN pip install -U pip && pip install -Ur requirements.txt
|
||||
# run Django part of the build
|
||||
# copy files needed for Django build
|
||||
COPY . .
|
||||
COPY --from=node-build /etc/linkding .
|
||||
# run Django part of the build
|
||||
RUN python manage.py compilescss && \
|
||||
python manage.py collectstatic --ignore=*.scss && \
|
||||
python manage.py compilescss --delete-files
|
||||
@@ -33,13 +37,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
|
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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
BIN
docs/Add To Linkding.shortcut
Normal file
BIN
docs/Add To Linkding.shortcut
Normal file
Binary file not shown.
@@ -1,52 +1,82 @@
|
||||
# Backups
|
||||
|
||||
This page describes some options on how to create backups.
|
||||
Linkding stores all data in the application's data folder.
|
||||
The full path to that folder in the Docker container is `/etc/linkding/data`.
|
||||
As described in the installation docs, you should mount the `/etc/linkding/data` folder to a folder on your host system.
|
||||
|
||||
## What to backup
|
||||
The data folder contains the following contents:
|
||||
- `db.sqlite3` - the SQLite database
|
||||
- `favicons` - folder that contains downloaded favicons
|
||||
|
||||
Linkding stores all data in a SQLite database, so all you need to backup are the contents of that database.
|
||||
The following sections explain how to back up the individual contents.
|
||||
|
||||
The location of the database file is `data/db.sqlite3` in the application folder.
|
||||
If you are using Docker then the full path in the Docker container is `/etc/linkding/data/db.sqlite`.
|
||||
As described in the installation docs, you should mount the `/etc/linkding/data` folder to a folder on your host system, from which you then can execute the backup.
|
||||
## Database
|
||||
|
||||
Below, we describe several methods to create a backup of the database:
|
||||
This section describes several methods on how to back up the contents of the SQLite database.
|
||||
|
||||
- Manual backup using the export function from the UI
|
||||
- Create a copy of the SQLite database with the SQLite backup function
|
||||
- Create a plain textfile with the contents of the SQLite database with the SQLite dump function
|
||||
> [!WARNING]
|
||||
> While the SQLite database is just a single file, it is not recommended to just copy that file.
|
||||
> This method is not transaction safe and may result in a [corrupted database](https://www.sqlite.org/howtocorrupt.html).
|
||||
> Use one of the backup methods described below.
|
||||
|
||||
Choose the method that fits you best.
|
||||
### Using the backup command
|
||||
|
||||
## Exporting from the UI
|
||||
linkding includes a CLI command for creating a backup copy of the database.
|
||||
|
||||
The least technical option is to use the bookmark export in the UI.
|
||||
Go to the settings page and open the *Data* tab.
|
||||
Then click on the *Download* button to download an HTML file containing all your bookmarks.
|
||||
You can backup this file on a drive, or an online file host.
|
||||
|
||||
## Using the SQLite backup function
|
||||
|
||||
Requires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.
|
||||
|
||||
With this method you create a new SQLite database, which is a copy of your linkding database.
|
||||
This method uses the backup command in the [Command Line Shell For SQLite](https://sqlite.org/cli.html).
|
||||
To create a backup, execute the following command:
|
||||
```shell
|
||||
sqlite3 db.sqlite3 ".backup 'backup.sqlite3'"
|
||||
docker exec -it linkding python manage.py backup backup.sqlite3
|
||||
```
|
||||
After you have created the backup database `backup.sqlite` you have to move it to another system, for example with rsync.
|
||||
This creates a `backup.sqlite3` file in the Docker container.
|
||||
|
||||
## Using the SQLite dump function
|
||||
To copy the backup file to your host system, execute the following command:
|
||||
```shell
|
||||
docker cp linkding:/etc/linkding/backup.sqlite3 backup.sqlite3
|
||||
```
|
||||
This copies the backup file from the Docker container to the current folder on your host system.
|
||||
Now you can move that file to your backup location.
|
||||
|
||||
To restore the backup, just copy the backup file to the data folder of your new installation and rename it to `db.sqlite3`. Then start the Docker container.
|
||||
|
||||
### Using the SQLite dump function
|
||||
|
||||
Requires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.
|
||||
|
||||
With this method you create a plain text file with the SQL statements to recreate the SQLite database.
|
||||
|
||||
To create a backup, execute the following command in the data folder:
|
||||
```shell
|
||||
sqlite3 db.sqlite3 .dump > backup.sql
|
||||
```
|
||||
This creates a `backup.sql` which you can copy to your backup location.
|
||||
As this is a plain text file you can also commit it to any revision management system, like git.
|
||||
Using git, you can commit the changes, followed by a git push to a remote repository.
|
||||
|
||||
As this is a plain text file you can commit it to any revision management system, like git.
|
||||
Using git you can commit the changes, followed by a git push to a remote repository.
|
||||
### Exporting bookmarks from the UI
|
||||
|
||||
This is the least technical option to back up bookmarks, but has several limitations:
|
||||
- It does not export user profiles.
|
||||
- It only exports your own bookmarks, not those of other users.
|
||||
- It does not export archived bookmarks.
|
||||
- It does not export URLs of snapshots on the Internet Archive Wayback machine.
|
||||
- It does not export favicons.
|
||||
|
||||
Only use this method if you are fine with the above limitations.
|
||||
|
||||
To export bookmarks from the UI, open the general settings.
|
||||
In the Export section, click on the *Download* button to download an HTML file containing all your bookmarks.
|
||||
Then move that file to your backup location.
|
||||
|
||||
To restore bookmarks, open the general settings on your new installation.
|
||||
In the Import section, click on the *Choose file* button to select the HTML file you downloaded before.
|
||||
Then click on the *Import* button to import the bookmarks.
|
||||
|
||||
## Favicons
|
||||
|
||||
Doing a backup of the icons is optional, as they can be downloaded again.
|
||||
|
||||
If you choose not to back up the icons, you can just restore the database and then click the _Refresh Favicons_ button in the general settings.
|
||||
This will download all missing icons again.
|
||||
|
||||
If you want to back up the icons, then you have to copy the `favicons` folder to your backup location.
|
||||
|
||||
To restore the icons, copy the `favicons` folder back to the data folder of your new installation.
|
||||
|
BIN
docs/donations/2023-10-11-internet-archive.png
Normal file
BIN
docs/donations/2023-10-11-internet-archive.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 189 KiB |
@@ -40,23 +40,22 @@ Try using share button on an app, a new item `Send to...` should appear on the s
|
||||
|
||||
This how-to explains how to make use of the app shortcuts iOS app to create a share action that can be used in Safari for adding bookmarks to your linkding instance.
|
||||
|
||||
**In the shortcuts app:**
|
||||
- create new shortcut
|
||||
- go to shortcut details, enable to option to show the shortcut in share menu
|
||||
- from the available share input types only select "URL"
|
||||
- add Safari action "Show Web Page At"
|
||||
- for URL enter your linkding instance URL and specifically point to the new bookmark form, then add the shortcut input variable from the list of suggested variables after the URL parameter. Visually it should look something like this: `https://linkding.mydomain.com/bookmarks/new?url=[Shortcut input]`, where `[Shortcut input]` is a visual block that was inserted after selecting the shortcut input variable suggestion. This is basically a placeholder that will get replaced with the actual URL that you want to bookmark. See screenshot at the end for an example on how this looks.
|
||||
- save, give the shortcut a nice name + glyph
|
||||
To install the shortcut:
|
||||
- Download the [Shortcut](https://raw.githubusercontent.com/sissbruecker/linkding/master/docs/Add%20To%20Linkding.shortcut) on your iOS device
|
||||
- Tap the downloaded file, which brings up the Shortcuts app
|
||||
- Confirm that you want to add the shortcut
|
||||
- In the shortcut, change `https://linkding.mydomain.com` to the URL of your linkding instance
|
||||
- Confirm / close the shortcut
|
||||
|
||||
Example of how the shortcut configuration should look:
|
||||
To use the shortcut:
|
||||
- Open Safari and navigate to the page you want to bookmark
|
||||
- Tap the share button
|
||||
- Scroll down and tap "Add To Linkding"
|
||||
- This opens linkding in a Safari overlay where you can configure the bookmark
|
||||
- When you're done, tap "Save"
|
||||
- After the bookmark is saved you can close the overlay
|
||||
|
||||

|
||||
|
||||
**Using the share action from Safari:**
|
||||
- browse to the website that you want to share
|
||||
- click the share button
|
||||
- your new app shortcut should now be available as share action
|
||||
- select the app shortcut
|
||||
- this should open a new Safari overlay showing the add bookmark form with the URL field prefilled
|
||||
- after saving the bookmark you can close the overlay and continue browsing
|
||||
At the bottom of the share sheet there is a button for configuring share actions. You can use this to move the "Add To Linkding" action to the top of the share sheet if you like.
|
||||
|
||||
> [!NOTE]
|
||||
> You can also check the [Community section](https://github.com/sissbruecker/linkding#community) for other pre-made shortcuts that you can use.
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.21.1",
|
||||
"version": "1.24.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
4
pytest.ini
Normal file
4
pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = siteroot.settings.dev
|
||||
# -- recommended but optional:
|
||||
python_files = tests.py test_*.py *_tests.py
|
@@ -6,7 +6,7 @@ certifi==2023.7.22
|
||||
charset-normalizer==2.1.1
|
||||
click==8.1.3
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==4.1.10
|
||||
Django==4.1.13
|
||||
django-generate-secret-key==1.0.2
|
||||
django-registration==3.3
|
||||
django-sass-processor==1.2.1
|
||||
@@ -23,7 +23,7 @@ soupsieve==2.3.2.post1
|
||||
sqlparse==0.4.4
|
||||
supervisor==4.2.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
urllib3==1.26.18
|
||||
uWSGI==2.0.22
|
||||
waybackpy==3.0.6
|
||||
webencodings==0.5.1
|
||||
|
@@ -1,13 +1,12 @@
|
||||
asgiref==3.5.2
|
||||
beautifulsoup4==4.11.1
|
||||
bleach==6.0.0
|
||||
bleach-allowlist==1.0.3
|
||||
bleach==6.0.0
|
||||
certifi==2023.7.22
|
||||
charset-normalizer==2.1.1
|
||||
click==8.1.3
|
||||
confusable-homoglyphs==3.2.0
|
||||
coverage==5.5
|
||||
Django==4.1.10
|
||||
django-appconf==1.0.5
|
||||
django-compressor==4.1
|
||||
django-debug-toolbar==3.6.0
|
||||
@@ -15,15 +14,18 @@ django-generate-secret-key==1.0.2
|
||||
django-registration==3.3
|
||||
django-sass-processor==1.2.1
|
||||
django-widget-tweaks==1.4.12
|
||||
Django==4.1.13
|
||||
django4-background-tasks==1.2.7
|
||||
djangorestframework==3.13.1
|
||||
greenlet==2.0.1
|
||||
greenlet==3.0.1
|
||||
idna==3.3
|
||||
libsass==0.21.0
|
||||
Markdown==3.4.3
|
||||
playwright==1.29.1
|
||||
playwright==1.40.0
|
||||
psycopg2-binary==2.9.5
|
||||
pyee==9.0.4
|
||||
pyee==11.0.1
|
||||
pytest-django==4.7.0
|
||||
pytest==7.4.4
|
||||
python-dateutil==2.8.2
|
||||
pytz==2022.2.1
|
||||
rcssmin==1.1.0
|
||||
@@ -33,6 +35,6 @@ six==1.16.0
|
||||
soupsieve==2.3.2.post1
|
||||
sqlparse==0.4.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
urllib3==1.26.18
|
||||
waybackpy==3.0.6
|
||||
webencodings==0.5.1
|
||||
|
@@ -3,6 +3,13 @@
|
||||
version=$(<version.txt)
|
||||
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
-f docker/default.Dockerfile \
|
||||
-t sissbruecker/linkding:latest \
|
||||
-t sissbruecker/linkding:$version \
|
||||
--push .
|
||||
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
-f docker/alpine.Dockerfile \
|
||||
-t sissbruecker/linkding:latest-alpine \
|
||||
-t sissbruecker/linkding:$version-alpine \
|
||||
--push .
|
||||
|
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker build -t sissbruecker/linkding:local .
|
||||
variant="${1:-default}"
|
||||
|
||||
docker build -f "docker/$variant.Dockerfile" -t sissbruecker/linkding:local .
|
||||
|
||||
docker rm -f linkding-local || true
|
||||
|
||||
|
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.21.1
|
||||
1.24.0
|
||||
|
Reference in New Issue
Block a user