Compare commits

..

52 Commits

Author SHA1 Message Date
Sascha Ißbrücker
6775633be5 Bump version 2024-01-27 10:58:21 +01:00
Jonathan Sundqvist
150dfecc6f Support Open Graph description (#602)
* Support pytest for running tests

* Support extracting description from meta og:description property

* Revert changes to TOC

* Add test

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-01-27 10:28:46 +01:00
Jonathan Sundqvist
81ae55bc1c Add tooltip to truncated bookmark titles (#607)
* Add title to link so you can see the entire title when hover

* Tweak JS, styles

* Fix snapshot tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-01-27 10:16:44 +01:00
Sascha Ißbrücker
935189ecc2 Improve bulk tag performance (#612) 2024-01-27 09:13:21 +01:00
JnsDornbusch
7997f20d89 Adjust archive.org donation link in general.html (#603)
Adjust archive.org donation link due to broken link.
2024-01-23 22:57:50 +01:00
Jonathan Sundqvist
ae27500cde Bump playwright dependencies (#601) 2024-01-23 22:45:25 +01:00
Adam Shand
71d853999e Add CapRover as managed hosting option (#585)
Add a note that I've created a one-click app for Linkding in CapRover (Scalable PaaS, automated Docker+nginx).
2024-01-23 22:37:51 +01:00
Sebastian Ruml
70288d6865 Increase tag limit in tag autocomplete (#581)
- increas tag limit to 5000

Co-authored-by: Sebastian Ruml <sebastian@sebastianruml.name>
2024-01-23 22:32:16 +01:00
Sascha Ißbrücker
e83d519cab Bump version 2023-12-08 22:01:24 +01:00
Sascha Ißbrücker
6355d8dff1 Properly encode search query param (#587) 2023-12-08 21:53:54 +01:00
Sascha Ißbrücker
227cfdb063 Update CHANGELOG.md 2023-11-24 10:23:44 +01:00
Sascha Ißbrücker
2d4da099c7 Bump version 2023-11-24 09:33:36 +01:00
Sascha Ißbrücker
a9512b2333 Include archived bookmarks in export (#579) 2023-11-24 09:21:23 +01:00
Oleksandr Perepadia
47e944e6c5 Update README.md (#574)
* Update README.md

Correct Firefox addon links to direct to the English language page

* Update firefox addon links to not presume any language
2023-11-14 17:08:17 +01:00
Sascha Ißbrücker
6c7ce91d53 Add backup CLI command (#571) 2023-11-05 19:27:48 +01:00
Sascha Ißbrücker
87020de917 Add Alpine based Docker image (experimental) (#570)
* use alpine as base image

* try fix missing mime types

* Extract separate Dockerfile

* Restore playwright dev dependency

* Add info to README.md
2023-11-05 14:18:26 +01:00
Sascha Ißbrücker
a130daa0f0 Update backup docs 2023-11-05 13:53:08 +01:00
Sascha Ißbrücker
d7c68c2818 Update CHANGELOG.md 2023-11-04 12:26:30 +01:00
Sascha Ißbrücker
1daad2c86c Bump version 2023-11-04 10:17:01 +01:00
dependabot[bot]
251def2583 Bump django from 4.1.10 to 4.1.13 (#567)
Bumps [django](https://github.com/django/django) from 4.1.10 to 4.1.13.
- [Commits](https://github.com/django/django/compare/4.1.10...4.1.13)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-04 09:57:08 +01:00
Vitor Marçal
560769f068 Fix RSS feed not handling None values (#569)
Previously, the 'sanitize' function would throw an error when 'text' was None. This commit fixes the issue by adding a check to handle the case where 'text' is None, returning an empty string instead.

Closes #568
2023-11-04 09:56:06 +01:00
Sascha Ißbrücker
dc9799cc53 Update CHANGELOG.md 2023-10-27 21:18:08 +02:00
Sascha Ißbrücker
41c1b9ab84 Bump version 2023-10-27 20:06:25 +02:00
dependabot[bot]
2396c8fe99 Bump urllib3 from 1.26.17 to 1.26.18 (#560)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.17 to 1.26.18.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.17...1.26.18)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-27 20:00:14 +02:00
Sascha Ißbrücker
de328c78e2 Sanitize RSS feed to remove control characters (#565) 2023-10-27 19:59:06 +02:00
Strubbl
314e4a9b74 Add feed2linkding to community section (#544)
* Update README.md

add feed2linkding

* Update README.md

sort feed2linkding in the list
2023-10-14 00:25:44 +02:00
Sascha Ißbrücker
ff400a79ec Disable editing of search preferences in user admin (#555) 2023-10-14 00:05:27 +02:00
Sascha Ißbrücker
f4fcb96b5e Update README.md 2023-10-11 18:16:47 +02:00
Sascha Ißbrücker
daab772971 update ios shortcut how-to 2023-10-07 18:02:07 +02:00
Sascha Ißbrücker
64c81ea565 rename ios shortcut 2023-10-07 17:58:01 +02:00
Sascha Ißbrücker
1dd19e8fa2 add ios shortcut 2023-10-07 17:36:54 +02:00
andrewdolphin
dd3699cdeb Add iOS shortcut to community section (#550)
* Update README.md

* Update README.md

* Update README.md

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2023-10-07 16:41:12 +02:00
dependabot[bot]
f9c9d17873 Bump urllib3 from 1.26.11 to 1.26.17 (#542)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.11 to 1.26.17.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.11...1.26.17)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-07 10:30:53 +02:00
Sascha Ißbrücker
5c9f03a715 Fix search options not opening on iOS (#549)
* Fix search options not opening on iOS

* cleanup
2023-10-07 10:24:09 +02:00
Sascha Ißbrücker
7600fe87f9 Bump version 2023-10-06 23:35:17 +02:00
Sascha Ißbrücker
f756e28daf Fix memory leak with SQLite (#548) 2023-10-06 23:29:29 +02:00
Sascha Ißbrücker
1e10d7eb4a Bump docker node version 2023-10-03 18:08:23 +02:00
Sascha Ißbrücker
ccf8e03571 Update CHANGELOG.md 2023-10-01 22:19:39 +02:00
Sascha Ißbrücker
30708cc5e3 Bump version 2023-10-01 22:05:02 +02:00
Sascha Ißbrücker
3e4f08f51b Add user profile endpoint (#541)
* feat: Implement UserProfile serializer and add API endpoint per #457

* chore: Document API addition

* Address review comments

---------

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

* Rename shared filter values

* Add update search preferences handler

* Separate search and preferences forms

* Properly initialize bookmark search from get or post

* Add tests for applying search preferences

* Implement saving search preferences

* Remove bookmark search query alias

* Use search preferences as default

* Only show save button for authenticated users

* Only show modified indicator if preferences are modified

* Fix overriding search preferences

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

* Add shared filter UI

* Implement shared filter

* Add API test

* Use radio buttons

* Rename shared parameter

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

* Improve header controls responsiveness

* Improve modal styles

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

* Refactor queries to accept BookmarkSearch

* Sort query by data added and title

* Ensure pagination respects search parameters

* Ensure tag cloud respects search parameters

* Ensure user select respects search parameters

* Ensure return url respects search options

* Fix passing search options to user select

* Fix BookmarkSearch initialization

* Extract common search form logic

* Ensure partial update respects search options

* Add sort UI

* Use custom ICU collation when sorting with SQLite

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

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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,)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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) {

View 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);

View File

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

View File

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

View File

@@ -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');

View File

@@ -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";

View 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}'))

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

@@ -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}')

View File

@@ -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

View File

@@ -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 = ''

View File

@@ -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:

View File

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

View File

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

View File

@@ -2,31 +2,36 @@
grid-gap: $unit-10;
}
/* Bookmark search box */
.bookmarks-page .search {
$searchbox-width: 180px;
$searchbox-width-md: 300px;
$searchbox-height: 1.8rem;
/* Bookmark area header controls */
.bookmarks-page .content-area-header {
--searchbox-max-width: 350px;
--searchbox-height: 1.8rem;
@media (max-width: $size-sm) {
--searchbox-max-width: initial;
flex-direction: column;
}
}
.bookmarks-page .search-container {
flex: 1 1 0;
display: flex;
justify-content: flex-end;
// Regular input
input[type='search'] {
width: $searchbox-width;
height: $searchbox-height;
height: var(--searchbox-height);
-webkit-appearance: none;
@media (min-width: $control-width-md) {
width: $searchbox-width-md;
}
}
// Enhanced auto-complete input
// This needs a bit more wrangling to make the CSS component align with the attached button
.form-autocomplete {
height: $searchbox-height;
height: var(--searchbox-height);
.form-autocomplete-input {
width: $searchbox-width;
height: $searchbox-height;
width: 100%;
height: var(--searchbox-height);
input[type='search'] {
width: 100%;
@@ -34,9 +39,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 {

View File

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

View File

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

View File

@@ -14,14 +14,16 @@
<section class="content-area col-2">
<div class="content-area-header mb-0">
<h2>Archived bookmarks</h2>
<div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query|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' %}

View File

@@ -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

View File

@@ -14,13 +14,15 @@
<section class="content-area col-2">
<div class="content-area-header mb-0">
<h2>Bookmarks</h2>
<div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags %}
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query|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' %}

View File

@@ -26,7 +26,7 @@
</li>
{% endif %}
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
<a href="{% url 'bookmarks:index' %}?unread=yes" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
@@ -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>

View File

@@ -1,44 +1,108 @@
<div class="search">
<form action="" method="get" role="search">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ filters.query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}">
{% endif %}
{% load widget_tweaks %}
<div class="search-container">
<form id="search" class="input-group" action="" method="get" role="search">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ search.q }}">
<input type="submit" value="Search" class="btn input-group-btn">
{% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</form>
<div 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>

View File

@@ -13,10 +13,13 @@
<section class="content-area col-2">
<div class="content-area-header">
<h2>Shared bookmarks</h2>
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
<div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
</div>
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?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">

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -1,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\')')

View File

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

View File

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

View File

@@ -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'''

View File

@@ -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()

View File

@@ -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 &lt;style&gt; HTML element contains style information for a document, or part of a document.',
html
)
self.assertIn(
'Interesting notes about the &lt;style&gt; HTML element.',
html
)
def test_handle_empty_values(self):
bookmark = self.setup_bookmark()

View File

@@ -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']))

View File

@@ -67,7 +67,8 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=True),
BookmarkHtmlTag(href='https://example.com/private', title='Private title', description='Private description',
BookmarkHtmlTag(href='https://example.com/private', title='Private title',
description='Private description',
add_date='4', private=True),
]
import_html = self.render_html(tags=html_tags)
@@ -90,7 +91,8 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
add_date='333', tags='updated-bar-tag, updated-other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=False),
BookmarkHtmlTag(href='https://example.com/private', title='Private title', description='Private description',
BookmarkHtmlTag(href='https://example.com/private', title='Private title',
description='Private description',
add_date='4', private=False),
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
]
@@ -293,6 +295,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='')

View File

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

View File

@@ -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">&lt;style&gt;: The Style Information element</A>
<DD>The &lt;style&gt; HTML element contains style information for a document, or part of a document.[linkding-notes]Interesting notes about the &lt;style&gt; HTML element.[/linkding-notes]
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].title,
'<style>: The Style Information element')
self.assertEqual(bookmarks[0].description,
'The <style> HTML element contains style information for a document, or part of a document.')
self.assertEqual(bookmarks[0].notes, 'Interesting notes about the <style> HTML element.')

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -12,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
View 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"]

View File

@@ -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

View File

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

Binary file not shown.

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@@ -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
![Screenshot](/docs/ios-app-shortcut-example.png?raw=true "Screenshot demonstrating how to insert the input placeholder into the URL")
**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.

View File

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

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = siteroot.settings.dev
# -- recommended but optional:
python_files = tests.py test_*.py *_tests.py

View File

@@ -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

View File

@@ -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

View File

@@ -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 .

View File

@@ -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
View File

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

View File

@@ -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)

View File

@@ -1 +1 @@
1.21.1
1.24.0