Compare commits

..

21 Commits

Author SHA1 Message Date
Sascha Ißbrücker
f071423f1e Bump version 2023-08-22 07:51:08 +02:00
Sascha Ißbrücker
be789ea9e6 Avoid page reload when triggering actions in bookmark list (#506)
* Extract bookmark view contexts

* Implement basic partial updates for bookmark list and tag cloud

* Refactor confirm button JS into web component

* Refactor bulk edit JS into web component

* Refactor tag autocomplete JS into web component

* Refactor bookmark page JS into web component

* Refactor global shortcuts JS into web component

* Update tests

* Add E2E test for partial updates

* Add partial updates for archived bookmarks

* Add partial updates for shared bookmarks

* Cleanup helpers

* Improve naming in bulk edit

* Refactor shared components into behaviors

* Refactor bulk edit components into behaviors

* Refactor bookmark list components into behaviors

* Update tests

* Combine all scripts into bundle

* Fix E2E CI
2023-08-21 23:12:00 +02:00
Sascha Ißbrücker
8206705876 Add support for PRIVATE flag in import and export (#505)
* Add support for PRIVATE attribute in import

* Add support for PRIVATE attribute in export

* Update import sync tests
2023-08-20 11:44:53 +02:00
Sascha Ißbrücker
5d9e487ec1 Various improvements to favicons (#504)
* Update default favicon provider

* Add domain placeholder for favicon providers

* Fix favicon loader to handle streaming response

* Handle different mime types for favicons

* Use 32px size by default

* Update documentation

* Skip mime-type test for now

* Manually configure image/x-icon mime type
2023-08-15 16:49:58 +02:00
Sascha Ißbrücker
ea240eefd9 Add option to share bookmarks publicly (#503)
* Make shared view public, add user profile fallback

* Allow unauthenticated access to shared bookmarks API

* Link shared bookmarks in unauthenticated layout

* Add public sharing setting

* Only show shared bookmarks link if there are publicly shared bookmarks

* Disable public sharing if sharing is disabled

* Show specific helper text when public sharing is enabled

* Fix tests

* Add more tests

* Improve setting description
2023-08-15 00:20:52 +02:00
Sascha Ißbrücker
22e8750c24 Bump version 2023-07-29 11:22:34 +02:00
dependabot[bot]
ac75fd2ebd Bump django from 4.1.9 to 4.1.10 (#494)
Bumps [django](https://github.com/django/django) from 4.1.9 to 4.1.10.
- [Commits](https://github.com/django/django/compare/4.1.9...4.1.10)

---
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-07-29 10:37:10 +02:00
dependabot[bot]
b05bf2534c Bump certifi from 2022.12.7 to 2023.7.22 (#497)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.12.7 to 2023.7.22.
- [Commits](https://github.com/certifi/python-certifi/compare/2022.12.07...2023.07.22)

---
updated-dependencies:
- dependency-name: certifi
  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-07-29 10:32:51 +02:00
Sascha Ißbrücker
86a39e0433 Remove padding from multiline code blocks 2023-05-31 17:53:04 +02:00
Sascha Ißbrücker
4220ea0b4c Fix website loader content encoding detection (#482) 2023-05-30 22:04:54 +02:00
Sascha Ißbrücker
5d48c64b2b Enable WAL to avoid locked databases (#480) 2023-05-30 09:41:53 +02:00
acbgbca
424df155d8 Allow passing title and description to new bookmark form (#479)
* Added ability to set title and description #118

* Updated bookmarklet to pass site title #118

* Revert "Updated bookmarklet to pass site title #118"

This reverts commit 873d90130b.
2023-05-30 09:19:17 +02:00
dependabot[bot]
d87611dbcb Bump requests from 2.28.1 to 2.31.0 (#478)
Bumps [requests](https://github.com/psf/requests) from 2.28.1 to 2.31.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.28.1...v2.31.0)

---
updated-dependencies:
- dependency-name: requests
  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-05-23 21:21:47 +02:00
acbgbca
cd66dcee7b Added Apple web-app meta tag #358 (#359)
* Added Apple web-app meta tag #358

* Added manifest file for web app

* Changed manifest to use template #358

* Small tweaks, add tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-05-23 21:20:58 +02:00
Sascha Ißbrücker
84f13dd792 Reorganize scripts and E2E tests 2023-05-21 14:32:24 +02:00
acbgbca
417dce785a Added Dev Container support (#474)
* Added dev container configuration

* Fixed container creation

* Added DevContainer detail to readme

* Ignoring dev container files

* Added playwright deps for tests

* Removed playwright installation #473
2023-05-21 13:35:00 +02:00
Sascha Ißbrücker
b28fc05d06 Update asset 2023-05-21 10:30:20 +02:00
Sascha Ißbrücker
17ab203f4f Document keyboard shortcuts 2023-05-20 21:08:04 +02:00
Sascha Ißbrücker
a06f9035cf Update README 2023-05-20 20:01:58 +02:00
Matt Sephton
5f28e87877 Update README.md to add Postman collection (#476)
I notice that for some time the list has not had items added in alphabetical order. I have not reordered any items.
2023-05-20 17:44:19 +02:00
Sascha Ißbrücker
f2ad826b11 Update CHANGELOG.md 2023-05-20 13:17:58 +02:00
120 changed files with 3212 additions and 1567 deletions

View File

@@ -0,0 +1,29 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [8000],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "pip3 install --user -r requirements.txt && npm install && mkdir -p data && python3 manage.py migrate",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"ms-python.python"
]
}
},
"remoteUser": "vscode"
}

View File

@@ -6,12 +6,15 @@
/tmp /tmp
/docs /docs
/static /static
/scripts
/build /build
/out /out
/.git /.git
/.devcontainer
/.dockerignore /.dockerignore
/.gitignore /.gitignore
/.gitattributes
/Dockerfile /Dockerfile
/docker-compose.yml /docker-compose.yml
/*.sh /*.sh

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf

View File

@@ -41,7 +41,10 @@ jobs:
run: | run: |
pip install -r requirements.txt pip install -r requirements.txt
playwright install chromium playwright install chromium
- name: Run build
run: |
npm run build
python manage.py compilescss python manage.py compilescss
python manage.py collectstatic --ignore=*.scss python manage.py collectstatic --ignore=*.scss
- name: Run tests - name: Run tests
run: python manage.py test bookmarks.e2e run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"

View File

@@ -1,6 +0,0 @@
module.exports = {
ignoreIssuesWith: [
"wontfix",
"duplicate"
]
}

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## v1.19.0 (20/05/2023)
### What's Changed
* Add notes to bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/472
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.18.0...v1.19.0
---
## v1.18.0 (18/05/2023) ## v1.18.0 (18/05/2023)
### What's Changed ### What's Changed

View File

@@ -17,11 +17,12 @@
- [Documentation](#documentation) - [Documentation](#documentation)
- [Browser Extension](#browser-extension) - [Browser Extension](#browser-extension)
- [Community](#community) - [Community](#community)
- [Acknowledgements](#acknowledgements)
- [Development](#development) - [Development](#development)
## Introduction ## Introduction
linkding is a simple bookmark service that you can host yourself. linkding is a bookmark manager that you can host yourself.
It's designed be to be minimal, fast, and easy to set up using Docker. It's designed be to be minimal, fast, and easy to set up using Docker.
The name comes from: The name comes from:
@@ -30,22 +31,23 @@ The name comes from:
- ...so basically something for managing your links - ...so basically something for managing your links
**Feature Overview:** **Feature Overview:**
- Clean UI optimized for readability
- Organize bookmarks with tags - Organize bookmarks with tags
- Add notes using Markdown
- Read it later functionality - Read it later functionality
- Share bookmarks with other users - Share bookmarks with other users
- Bulk editing - Bulk editing
- Bookmark archive - Automatically provides titles, descriptions and icons of bookmarked websites
- Automatically provides titles and descriptions of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/) - Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format - 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/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- Light and dark themes - Light and dark themes
- REST API for developing 3rd party apps - REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access - Admin panel for user self-service and raw data access
- Easy setup using Docker, uses SQLite as database - Easy setup using Docker and a SQLite database, with PostgreSQL as an option
**Demo:** https://demo.linkding.link/ (configured with open registration) **Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
**Screenshot:** **Screenshot:**
@@ -63,15 +65,13 @@ Alternatively linkding supports PostgreSQL, see the [database options](docs/Opti
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub: To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
```shell ```shell
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
```
By default, the application runs on port `9090`, you can map it to a different host port by modifying the port mapping in the command above. If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
Note that the command above will store the linkding SQLite database in the container, which means that deleting the container, for example when upgrading the installation, will also remove the database. For hosting an actual installation you usually want to store the database on the host system, rather than in the container. To do so, run the following command, and replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database:
```shell
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
``` ```
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it. To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation. To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
@@ -101,6 +101,8 @@ 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. 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).
### Reverse Proxy Setup ### Reverse Proxy Setup
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail. When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
@@ -158,7 +160,7 @@ Instead of configuring header forwarding in your proxy, you can also configure t
### Managed Hosting Options ### Managed Hosting Options
Self-hosting web applications on your own hardware (unfortunately) 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 linkding, 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 (usually monthly) 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, 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.
- [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) - [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)
@@ -171,6 +173,7 @@ Self-hosting web applications on your own hardware (unfortunately) still require
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database | | [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems | | [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking | | [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI | | [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API | | [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
@@ -186,14 +189,15 @@ 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. 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)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske) - [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [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) - [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) - [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)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold) - [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [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)
- [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)
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino) - [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
- [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
@@ -243,3 +247,23 @@ Start the Django development server with:
python3 manage.py runserver python3 manage.py runserver
``` ```
The frontend is now available under http://localhost:8000 The frontend is now available under http://localhost:8000
### DevContainers
This repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
Once checked out, only the following commands are required to get started:
Create a user for the frontend:
```
python3 manage.py createsuperuser --username=joe --email=joe@example.com
```
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
```
npm run dev
```
Start the Django development server with:
```
python3 manage.py runserver
```
The frontend is now available under http://localhost:8000

View File

@@ -1,5 +1,6 @@
from rest_framework import viewsets, mixins, status from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
@@ -18,6 +19,17 @@ class BookmarkViewSet(viewsets.GenericViewSet,
mixins.DestroyModelMixin): mixins.DestroyModelMixin):
serializer_class = BookmarkSerializer serializer_class = BookmarkSerializer
def get_permissions(self):
# Allow unauthenticated access to shared bookmarks.
# The shared action should still filter bookmarks so that
# unauthenticated users only see bookmarks from users that have public
# sharing explicitly enabled
if self.action == 'shared':
return [AllowAny()]
# Otherwise use default permissions which should require authentication
return super().get_permissions()
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
# For list action, use query set that applies search and tag projections # For list action, use query set that applies search and tag projections
@@ -45,7 +57,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def shared(self, request): def shared(self, request):
filters = BookmarkFilters(request) filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first() user = User.objects.filter(username=filters.user).first()
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query) public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only)
page = self.paginate_queryset(query_set) page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class() serializer = self.get_serializer_class()
data = serializer(page, many=True).data data = serializer(page, many=True).data

View File

@@ -1,271 +0,0 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let filters;
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionFilters = {
...filters,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank')
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.search.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View File

@@ -1,48 +0,0 @@
const SEARCH_HISTORY_KEY = 'searchHistory'
const MAX_ENTRIES = 30
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
return historyJson ? JSON.parse(historyJson) : {
recent: []
}
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('q');
if (!searchParam) return
this.push(searchParam)
}
push(search) {
const history = this.getHistory()
history.recent.unshift(search)
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc
if (acc.indexOf(cur) >= 0) return acc
acc.push(cur)
return acc
}, [])
const newHistoryJson = JSON.stringify(history)
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
}
getRecentSearches(query, max) {
const history = this.getHistory()
return history.recent
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
.slice(0, max)
}
}

View File

@@ -1,168 +0,0 @@
<script>
import {getCurrentWord, getCurrentWordBounds} from "./util";
export let id;
export let name;
export let value;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
init();
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.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag.name}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
}
.form-autocomplete.small .form-autocomplete-input input {
margin: 0;
padding: 0;
font-size: 0.7rem;
}
.form-autocomplete.small .menu .menu-item {
font-size: 0.7rem;
}
</style>

View File

@@ -1,32 +0,0 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl
}
listBookmarks(filters, options = {limit: 100, offset: 0, path: ''}) {
const query = [
`limit=${options.limit}`,
`offset=${options.offset}`,
]
Object.keys(filters).forEach(key => {
const value = filters[key]
if (value) {
query.push(`${key}=${encodeURIComponent(value)}`)
}
})
const queryString = query.join('&')
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
getTags(options = {limit: 100, offset: 0}) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
}

View File

@@ -1,10 +0,0 @@
import TagAutoComplete from './TagAutocomplete.svelte'
import SearchAutoComplete from './SearchAutoComplete.svelte'
import {ApiClient} from './api'
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete
}

View File

@@ -1,37 +0,0 @@
export function debounce(callback, delay = 250) {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
callback(...args)
}, delay)
}
}
export function clampText(text, maxChars = 30) {
if(!text || text.length <= 30) return text
return text.substr(0, maxChars) + '...'
}
export function getCurrentWordBounds(input) {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== ' ' && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return {start, end};
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View File

@@ -1,12 +1,25 @@
from bookmarks import queries
from bookmarks.models import Toast from bookmarks.models import Toast
def toasts(request): def toasts(request):
user = request.user if hasattr(request, 'user') else None user = request.user
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else [] toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user.is_authenticated else []
has_toasts = len(toast_messages) > 0 has_toasts = len(toast_messages) > 0
return { return {
'has_toasts': has_toasts, 'has_toasts': has_toasts,
'toast_messages': toast_messages, 'toast_messages': toast_messages,
} }
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)
has_public_shares = query_set.count() > 0
return {
'has_public_shares': has_public_shares,
}
return {}

View File

@@ -6,17 +6,15 @@ from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase from bookmarks.e2e.helpers import LinkdingE2ETestCase
@skip("Fails in CI, needs investigation") class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
class BookmarkListE2ETestCase(LinkdingE2ETestCase): @skip("Fails in CI, needs investigation")
def test_toggle_notes_should_show_hide_notes(self): def test_toggle_notes_should_show_hide_notes(self):
self.setup_bookmark(notes='Test notes') bookmark = self.setup_bookmark(notes='Test notes')
with sync_playwright() as p: with sync_playwright() as p:
browser = self.setup_browser(p) page = self.open(reverse('bookmarks:index'), p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
notes = page.locator('li .notes') notes = self.locate_bookmark(bookmark.title).locator('.notes')
expect(notes).to_be_hidden() expect(notes).to_be_hidden()
toggle_notes = page.locator('li button.toggle-notes') toggle_notes = page.locator('li button.toggle-notes')

View File

@@ -0,0 +1,252 @@
from typing import List
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def setup_fixture(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
# create a number of bookmarks with different states / visibility to
# verify correct data is loaded on update
self.setup_numbered_bookmarks(3, with_tags=True)
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
self.setup_numbered_bookmarks(3,
shared=True,
prefix="Joe's Bookmark",
user=self.setup_user(enable_sharing=True))
def assertVisibleBookmarks(self, titles: List[str]):
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
expect(bookmark_tags).to_have_count(len(titles))
for title in titles:
matching_tag = bookmark_tags.filter(has_text=title)
expect(matching_tag).to_be_visible()
def assertVisibleTags(self, titles: List[str]):
tag_tags = self.page.locator('.tag-cloud .unselected-tags a')
expect(tag_tags).to_have_count(len(titles))
for title in titles:
matching_tag = tag_tags.filter(has_text=title)
expect(matching_tag).to_be_visible()
def test_partial_update_respects_query(self):
self.setup_numbered_bookmarks(5, prefix='foo')
self.setup_numbered_bookmarks(5, prefix='bar')
with sync_playwright() as p:
url = reverse('bookmarks:index') + '?q=foo'
self.open(url, p)
self.assertVisibleBookmarks(['foo 1', 'foo 2', 'foo 3', 'foo 4', 'foo 5'])
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_page(self):
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix='foo', suffix='-')
with sync_playwright() as p:
url = reverse('bookmarks:index') + '?q=foo&page=2'
self.open(url, p)
# with descending sort, page two has 'foo 1' to 'foo 20'
expected_titles = [f'foo {i}-' for i in range(1, 21)]
self.assertVisibleBookmarks(expected_titles)
self.locate_bookmark('foo 20-').get_by_text('Archive').click()
expected_titles = [f'foo {i}-' for i in range(1, 20)]
self.assertVisibleBookmarks(expected_titles)
def test_multiple_partial_updates(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bookmark('Bookmark 1').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 2', 'Bookmark 3', 'Bookmark 4', 'Bookmark 5'])
self.locate_bookmark('Bookmark 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 3', 'Bookmark 4', 'Bookmark 5'])
self.locate_bookmark('Bookmark 3').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 4', 'Bookmark 5'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bookmark('Bookmark 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bookmark('Bookmark 2').get_by_text('Remove').click()
self.locate_bookmark('Bookmark 2').get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_mark_as_read(self):
self.setup_fixture()
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
bookmark2.unread = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic')
self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click()
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic')
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bookmark('Archived Bookmark 2').get_by_text('Unarchive').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bookmark('Archived Bookmark 2').get_by_text('Remove').click()
self.locate_bookmark('Archived Bookmark 2').get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True)
with sync_playwright() as p:
self.open(reverse('bookmarks:shared'), p)
self.locate_bookmark('My Bookmark 2').get_by_text('Archive').click()
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
self.assertVisibleBookmarks([
'My Bookmark 1',
'My Bookmark 2',
'My Bookmark 3',
"Joe's Bookmark 1",
"Joe's Bookmark 2",
"Joe's Bookmark 3",
])
self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 2', 'Shared Tag 3'])
self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True)
with sync_playwright() as p:
self.open(reverse('bookmarks:shared'), p)
self.locate_bookmark('My Bookmark 2').get_by_text('Remove').click()
self.locate_bookmark('My Bookmark 2').get_by_text('Confirm').click()
self.assertVisibleBookmarks([
'My Bookmark 1',
'My Bookmark 3',
"Joe's Bookmark 1",
"Joe's Bookmark 2",
"Joe's Bookmark 3",
])
self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 3'])
self.assertReloads(0)

View File

@@ -0,0 +1,39 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:settings.general'))
enable_sharing = page.get_by_label('Enable bookmark sharing')
enable_sharing_label = page.get_by_text('Enable bookmark sharing')
enable_public_sharing = page.get_by_label('Enable public bookmark sharing')
enable_public_sharing_label = page.get_by_text('Enable public bookmark sharing')
# Public sharing is disabled by default
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()
# Enable sharing
enable_sharing_label.click()
expect(enable_sharing).to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_enabled()
# Enable public sharing
enable_public_sharing_label.click()
expect(enable_public_sharing).to_be_checked()
expect(enable_public_sharing).to_be_enabled()
# Disable sharing
enable_sharing_label.click()
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()

View File

@@ -1,5 +1,5 @@
from django.contrib.staticfiles.testing import LiveServerTestCase from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext from playwright.sync_api import BrowserContext, Playwright, Page
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -19,3 +19,27 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
'path': '/' 'path': '/'
}]) }])
return context return context
def open(self, url: str, playwright: Playwright) -> Page:
browser = self.setup_browser(playwright)
self.page = browser.new_page()
self.page.goto(self.live_server_url + url)
self.page.on('load', self.on_load)
self.num_loads = 0
return self.page
def on_load(self):
self.num_loads += 1
def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count)
def locate_bookmark(self, title: str):
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
return bookmark_tags.filter(has_text=title)
def locate_bulk_edit_bar(self):
return self.page.locator('.bulk-edit-bar')
def locate_bulk_edit_toggle(self):
return self.page.get_by_title('Bulk edit')

29
bookmarks/frontend/api.js Normal file
View File

@@ -0,0 +1,29 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
listBookmarks(filters, options = { limit: 100, offset: 0, path: "" }) {
const query = [`limit=${options.limit}`, `offset=${options.offset}`];
Object.keys(filters).forEach((key) => {
const value = filters[key];
if (value) {
query.push(`${key}=${encodeURIComponent(value)}`);
}
});
const queryString = query.join("&");
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`;
return fetch(url)
.then((response) => response.json())
.then((data) => data.results);
}
getTags(options = { limit: 100, offset: 0 }) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;
return fetch(url)
.then((response) => response.json())
.then((data) => data.results);
}
}

View File

@@ -0,0 +1,65 @@
import { registerBehavior, swap } from "./index";
class BookmarkPage {
constructor(element) {
this.element = element;
this.form = element.querySelector("form.bookmark-actions");
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.form.action;
const formData = new FormData(this.form);
formData.append(event.submitter.name, event.submitter.value);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
await this.refresh();
}
async refresh() {
const query = window.location.search;
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
const tagsUrl = this.element.getAttribute("tags-url");
Promise.all([
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swap(this.bookmarkList, bookmarkListHtml);
swap(this.tagCloud, tagCloudHtml);
this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", { bubbles: true }),
);
});
}
}
registerBehavior("ld-bookmark-page", BookmarkPage);
class BookmarkItem {
constructor(element) {
this.element = element;
const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
}
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();
this.element.classList.toggle("show-notes");
}
}
registerBehavior("ld-bookmark-item", BookmarkItem);

View File

@@ -0,0 +1,100 @@
import { registerBehavior } from "./index";
class BulkEdit {
constructor(element) {
this.element = element;
this.active = false;
element.addEventListener(
"bulk-edit-toggle-active",
this.onToggleActive.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-all",
this.onToggleAll.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-bookmark",
this.onToggleBookmark.bind(this),
);
element.addEventListener(
"bookmark-list-updated",
this.onListUpdated.bind(this),
);
}
get allCheckbox() {
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
}
get bookmarkCheckboxes() {
return [
...this.element.querySelectorAll(
"[ld-bulk-edit-checkbox]:not([all]) input",
),
];
}
onToggleActive() {
this.active = !this.active;
if (this.active) {
this.element.classList.add("active", "activating");
setTimeout(() => {
this.element.classList.remove("activating");
}, 500);
} else {
this.element.classList.remove("active");
}
}
onToggleBookmark() {
this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked;
});
}
onToggleAll() {
const checked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = checked;
});
}
onListUpdated() {
this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
}
}
class BulkEditActiveToggle {
constructor(element) {
this.element = element;
element.addEventListener("click", this.onClick.bind(this));
}
onClick() {
this.element.dispatchEvent(
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
);
}
}
class BulkEditCheckbox {
constructor(element) {
this.element = element;
element.addEventListener("change", this.onChange.bind(this));
}
onChange() {
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
this.element.dispatchEvent(
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
);
}
}
registerBehavior("ld-bulk-edit", BulkEdit);
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);

View File

@@ -0,0 +1,50 @@
import { registerBehavior } from "./index";
class ConfirmButtonBehavior {
constructor(element) {
const button = element;
button.dataset.type = button.type;
button.dataset.name = button.name;
button.dataset.value = button.value;
button.removeAttribute("type");
button.removeAttribute("name");
button.removeAttribute("value");
button.addEventListener("click", this.onClick.bind(this));
this.button = button;
}
onClick(event) {
event.preventDefault();
const cancelButton = document.createElement(this.button.nodeName);
cancelButton.type = "button";
cancelButton.innerText = "Cancel";
cancelButton.className = "btn btn-link btn-sm mr-1";
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName);
confirmButton.type = this.button.dataset.type;
confirmButton.name = this.button.dataset.name;
confirmButton.value = this.button.dataset.value;
confirmButton.innerText = "Confirm";
confirmButton.className = "btn btn-link btn-sm";
confirmButton.addEventListener("click", this.reset.bind(this));
const container = document.createElement("span");
container.className = "confirmation";
container.append(cancelButton, confirmButton);
this.container = container;
this.button.before(container);
this.button.classList.add("d-none");
}
reset() {
setTimeout(() => {
this.container.remove();
this.button.classList.remove("d-none");
});
}
}
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -0,0 +1,73 @@
import { registerBehavior } from "./index";
class GlobalShortcuts {
constructor() {
document.addEventListener("keydown", this.onKeyDown.bind(this));
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
// Handle shortcuts for navigating bookmarks with arrow keys
const isArrowUp = event.key === "ArrowUp";
const isArrowDown = event.key === "ArrowDown";
if (isArrowUp || isArrowDown) {
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
);
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector("[ld-bookmark-item]");
}
// Focus first link
if (nextItem) {
nextItem.querySelector("a").focus();
}
}
// Handle shortcut for toggling all notes
if (event.key === "e") {
const list = document.querySelector(".bookmark-list");
if (list) {
list.classList.toggle("show-notes");
}
}
// Handle shortcut for focusing search input
if (event.key === "s") {
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
}
// Handle shortcut for adding new bookmark
if (event.key === "n") {
window.location.assign("/bookmarks/new");
}
}
}
registerBehavior("ld-global-shortcuts", GlobalShortcuts);

View File

@@ -0,0 +1,36 @@
const behaviorRegistry = {};
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
applyBehaviors(document, [name]);
}
export function applyBehaviors(container, behaviorNames = null) {
if (!behaviorNames) {
behaviorNames = Object.keys(behaviorRegistry);
}
behaviorNames.forEach((behaviorName) => {
const behavior = behaviorRegistry[behaviorName];
const elements = container.querySelectorAll(`[${behaviorName}]`);
elements.forEach((element) => {
element.__behaviors = element.__behaviors || [];
const hasBehavior = element.__behaviors.some(
(b) => b instanceof behavior,
);
if (hasBehavior) {
return;
}
const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance);
});
});
}
export function swap(element, html) {
element.innerHTML = html;
applyBehaviors(element);
}

View File

@@ -0,0 +1,26 @@
import { registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api";
class TagAutocomplete {
constructor(element) {
const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl);
new TagAutoCompleteComponent({
target: wrapper,
props: {
id: element.id,
name: element.name,
value: element.value,
apiClient: apiClient,
variant: element.getAttribute("variant"),
},
});
element.replaceWith(wrapper);
}
}
registerBehavior("ld-tag-autocomplete", TagAutocomplete);

View File

@@ -0,0 +1,272 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let filters;
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionFilters = {
...filters,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank')
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.search.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View File

@@ -0,0 +1,52 @@
const SEARCH_HISTORY_KEY = "searchHistory";
const MAX_ENTRIES = 30;
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY);
return historyJson
? JSON.parse(historyJson)
: {
recent: [],
};
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return;
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get("q");
if (!searchParam) return;
this.push(searchParam);
}
push(search) {
const history = this.getHistory();
history.recent.unshift(search);
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc;
if (acc.indexOf(cur) >= 0) return acc;
acc.push(cur);
return acc;
}, []);
const newHistoryJson = JSON.stringify(history);
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson);
}
getRecentSearches(query, max) {
const history = this.getHistory();
return history.recent
.filter(
(search) =>
!query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0,
)
.slice(0, max);
}
}

View File

@@ -0,0 +1,170 @@
<script>
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
init();
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.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag.name}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
}
.form-autocomplete.small .form-autocomplete-input input {
margin: 0;
padding: 0;
font-size: 0.7rem;
}
.form-autocomplete.small .menu .menu-item {
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,14 @@
import TagAutoComplete from "./components/TagAutocomplete.svelte";
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
import { ApiClient } from "./api";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete,
};

View File

@@ -0,0 +1,37 @@
export function debounce(callback, delay = 250) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
callback(...args);
}, delay);
};
}
export function clampText(text, maxChars = 30) {
if (!text || text.length <= 30) return text;
return text.substr(0, maxChars) + "...";
}
export function getCurrentWordBounds(input) {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== " " && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return { start, end };
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View File

@@ -0,0 +1,24 @@
import logging
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import connections
logger = logging.getLogger(__name__)
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':
return
connection = connections['default']
with connection.cursor() as cursor:
cursor.execute("PRAGMA journal_mode")
current_mode = cursor.fetchone()[0]
logger.info(f'Current journal mode: {current_mode}')
if current_mode != 'wal':
cursor.execute("PRAGMA journal_mode=wal;")
logger.info('Switched to WAL journal mode')

View File

@@ -1,6 +1,24 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware from django.contrib.auth.middleware import RemoteUserMiddleware
from bookmarks.models import UserProfile
class CustomRemoteUserMiddleware(RemoteUserMiddleware): class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER header = settings.LD_AUTH_PROXY_USERNAME_HEADER
class UserProfileMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
request.user_profile = request.user.profile
else:
request.user_profile = UserProfile()
request.user_profile.enable_favicons = True
response = self.get_response(request)
return response

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-08-14 07:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0023_userprofile_permanent_notes'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='enable_public_sharing',
field=models.BooleanField(default=False),
),
]

View File

@@ -176,6 +176,7 @@ class UserProfile(models.Model):
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False, tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
default=TAG_SEARCH_STRICT) default=TAG_SEARCH_STRICT)
enable_sharing = models.BooleanField(default=False, null=False) enable_sharing = models.BooleanField(default=False, null=False)
enable_public_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False) enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False) display_url = models.BooleanField(default=False, null=False)
permanent_notes = models.BooleanField(default=False, null=False) permanent_notes = models.BooleanField(default=False, null=False)
@@ -185,7 +186,7 @@ class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search', fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes'] 'enable_sharing', 'enable_public_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=get_user_model())

View File

@@ -17,10 +17,13 @@ def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str
.filter(is_archived=True) .filter(is_archived=True)
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet: def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str,
return _base_bookmarks_query(user, profile, query_string) \ public_only: bool) -> QuerySet:
.filter(shared=True) \ conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
.filter(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)
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet: def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
@@ -85,16 +88,17 @@ def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string:
return query_set.distinct() return query_set.distinct()
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet: def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str,
bookmarks_query = query_shared_bookmarks(user, profile, query_string) public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string, public_only)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query) query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct() return query_set.distinct()
def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet: def query_shared_bookmark_users(profile: UserProfile, query_string: str, public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string) bookmarks_query = query_shared_bookmarks(None, profile, query_string, public_only)
query_set = User.objects.filter(bookmark__in=bookmarks_query) query_set = User.objects.filter(bookmark__in=bookmarks_query)

View File

@@ -33,9 +33,10 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
desc = html.escape(bookmark.resolved_description or '') desc = html.escape(bookmark.resolved_description or '')
tags = ','.join(bookmark.tag_names) tags = ','.join(bookmark.tag_names)
toread = '1' if bookmark.unread else '0' toread = '1' if bookmark.unread else '0'
private = '0' if bookmark.shared else '1'
added = int(bookmark.date_added.timestamp()) added = int(bookmark.date_added.timestamp())
doc.append(f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="0" 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: if desc:
doc.append(f'<DD>{desc}') doc.append(f'<DD>{desc}')

View File

@@ -1,6 +1,7 @@
import logging
import mimetypes
import os.path import os.path
import re import re
import shutil
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -10,25 +11,46 @@ from django.conf import settings
max_file_age = 60 * 60 * 24 # 1 day max_file_age = 60 * 60 * 24 # 1 day
logger = logging.getLogger(__name__)
# register mime type for .ico files, which is not included in the default
# mimetypes of the Docker image
mimetypes.add_type('image/x-icon', '.ico')
def _ensure_favicon_folder(): def _ensure_favicon_folder():
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True) Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
def _url_to_filename(url: str) -> str: def _url_to_filename(url: str) -> str:
name = re.sub(r'\W+', '_', url) return re.sub(r'\W+', '_', url)
return f'{name}.png'
def _get_base_url(url: str) -> str: def _get_url_parameters(url: str) -> dict:
parsed_uri = urlparse(url) parsed_uri = urlparse(url)
return f'{parsed_uri.scheme}://{parsed_uri.hostname}' return {
# https://example.com/foo?bar -> https://example.com
'url': f'{parsed_uri.scheme}://{parsed_uri.hostname}',
# https://example.com/foo?bar -> example.com
'domain': parsed_uri.hostname,
}
def _get_favicon_path(favicon_file: str) -> Path: def _get_favicon_path(favicon_file: str) -> Path:
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file)) return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
def _check_existing_favicon(favicon_name: str):
# return existing file if a file with the same name, ignoring extension,
# exists and is not stale
for filename in os.listdir(settings.LD_FAVICON_FOLDER):
file_base_name, _ = os.path.splitext(filename)
if file_base_name == favicon_name:
favicon_path = _get_favicon_path(filename)
return filename if not _is_stale(favicon_path) else None
return None
def _is_stale(path: Path) -> bool: def _is_stale(path: Path) -> bool:
stat = path.stat() stat = path.stat()
file_age = time.time() - stat.st_mtime file_age = time.time() - stat.st_mtime
@@ -36,22 +58,26 @@ def _is_stale(path: Path) -> bool:
def load_favicon(url: str) -> str: def load_favicon(url: str) -> str:
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host url_parameters = _get_url_parameters(url)
base_url = _get_base_url(url)
favicon_name = _url_to_filename(base_url)
favicon_path = _get_favicon_path(favicon_name)
# Load icon if it doesn't exist yet or has become stale # Create favicon folder if not exists
if not favicon_path.exists() or _is_stale(favicon_path): _ensure_favicon_folder()
# Create favicon folder if not exists # Use scheme+hostname as favicon filename to reuse icon for all pages on the same domain
_ensure_favicon_folder() favicon_name = _url_to_filename(url_parameters['url'])
favicon_file = _check_existing_favicon(favicon_name)
if not favicon_file:
# Load favicon from provider, save to file # Load favicon from provider, save to file
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url) favicon_url = settings.LD_FAVICON_PROVIDER.format(**url_parameters)
response = requests.get(favicon_url, stream=True) logger.debug(f'Loading favicon from: {favicon_url}')
with requests.get(favicon_url, stream=True) as response:
content_type = response.headers['Content-Type']
file_extension = mimetypes.guess_extension(content_type)
favicon_file = f'{favicon_name}{file_extension}'
favicon_path = _get_favicon_path(favicon_file)
with open(favicon_path, 'wb') as file:
for chunk in response.iter_content(chunk_size=8192):
file.write(chunk)
logger.debug(f'Saved favicon as: {favicon_path}')
with open(favicon_path, 'wb') as file: return favicon_file
shutil.copyfileobj(response.raw, file)
del response
return favicon_name

View File

@@ -20,6 +20,11 @@ class ImportResult:
failed: int = 0 failed: int = 0
@dataclass
class ImportOptions:
map_private_flag: bool = False
class TagCache: class TagCache:
def __init__(self, user: User): def __init__(self, user: User):
self.user = user self.user = user
@@ -50,7 +55,7 @@ class TagCache:
self.cache[tag.name.lower()] = tag self.cache[tag.name.lower()] = tag
def import_netscape_html(html: str, user: User): def import_netscape_html(html: str, user: User, options: ImportOptions = ImportOptions()) -> ImportResult:
result = ImportResult() result = ImportResult()
import_start = timezone.now() import_start = timezone.now()
@@ -70,7 +75,7 @@ def import_netscape_html(html: str, user: User):
# Split bookmarks to import into batches, to keep memory usage for bulk operations manageable # Split bookmarks to import into batches, to keep memory usage for bulk operations manageable
batches = _get_batches(netscape_bookmarks, 200) batches = _get_batches(netscape_bookmarks, 200)
for batch in batches: for batch in batches:
_import_batch(batch, user, tag_cache, result) _import_batch(batch, user, options, tag_cache, result)
# Create snapshots for newly imported bookmarks # Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user) tasks.schedule_bookmarks_without_snapshots(user)
@@ -114,7 +119,11 @@ def _get_batches(items: List, batch_size: int):
return batches return batches
def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_cache: TagCache, result: ImportResult): def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
user: User,
options: ImportOptions,
tag_cache: TagCache,
result: ImportResult):
# Query existing bookmarks # Query existing bookmarks
batch_urls = [bookmark.href for bookmark in netscape_bookmarks] batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls) existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
@@ -135,7 +144,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
else: else:
is_update = True is_update = True
# Copy data from parsed bookmark # Copy data from parsed bookmark
_copy_bookmark_data(netscape_bookmark, bookmark) _copy_bookmark_data(netscape_bookmark, bookmark, options)
# Validate bookmark fields, exclude owner to prevent n+1 database query, # Validate bookmark fields, exclude owner to prevent n+1 database query,
# also there is no specific validation on owner # also there is no specific validation on owner
bookmark.clean_fields(exclude=['owner']) bookmark.clean_fields(exclude=['owner'])
@@ -152,8 +161,14 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
result.failed = result.failed + 1 result.failed = result.failed + 1
# Bulk update bookmarks in DB # Bulk update bookmarks in DB
Bookmark.objects.bulk_update(bookmarks_to_update, Bookmark.objects.bulk_update(bookmarks_to_update, ['url',
['url', 'date_added', 'date_modified', 'unread', 'title', 'description', 'owner']) 'date_added',
'date_modified',
'unread',
'shared',
'title',
'description',
'owner'])
# Bulk insert new bookmarks into DB # Bulk insert new bookmarks into DB
Bookmark.objects.bulk_create(bookmarks_to_create) Bookmark.objects.bulk_create(bookmarks_to_create)
@@ -187,7 +202,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True) BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark): def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions):
bookmark.url = netscape_bookmark.href bookmark.url = netscape_bookmark.href
if netscape_bookmark.date_added: if netscape_bookmark.date_added:
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added) bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
@@ -199,3 +214,5 @@ def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark)
bookmark.title = netscape_bookmark.title bookmark.title = netscape_bookmark.title
if netscape_bookmark.description: if netscape_bookmark.description:
bookmark.description = netscape_bookmark.description bookmark.description = netscape_bookmark.description
if options.map_private_flag and not netscape_bookmark.private:
bookmark.shared = True

View File

@@ -11,6 +11,7 @@ class NetscapeBookmark:
date_added: str date_added: str
tag_string: str tag_string: str
to_read: bool to_read: bool
private: bool
class BookmarkParser(HTMLParser): class BookmarkParser(HTMLParser):
@@ -26,6 +27,7 @@ class BookmarkParser(HTMLParser):
self.title = '' self.title = ''
self.description = '' self.description = ''
self.toread = '' self.toread = ''
self.private = ''
def handle_starttag(self, tag: str, attrs: list): def handle_starttag(self, tag: str, attrs: list):
name = 'handle_start_' + tag.lower() name = 'handle_start_' + tag.lower()
@@ -58,7 +60,9 @@ class BookmarkParser(HTMLParser):
description='', description='',
date_added=self.add_date, date_added=self.add_date,
tag_string=self.tags, tag_string=self.tags,
to_read=self.toread == '1' to_read=self.toread == '1',
# Mark as private by default, also when attribute is not specified
private=self.private != '0',
) )
def handle_a_data(self, data): def handle_a_data(self, data):
@@ -79,6 +83,7 @@ class BookmarkParser(HTMLParser):
self.title = '' self.title = ''
self.description = '' self.description = ''
self.toread = '' self.toread = ''
self.private = ''
def parse(html: str) -> List[NetscapeBookmark]: def parse(html: str) -> List[NetscapeBookmark]:

View File

@@ -130,12 +130,12 @@ def _load_favicon_task(bookmark_id: int):
logger.info(f'Load favicon for bookmark. url={bookmark.url}') logger.info(f'Load favicon for bookmark. url={bookmark.url}')
new_favicon = favicon_loader.load_favicon(bookmark.url) new_favicon_file = favicon_loader.load_favicon(bookmark.url)
if new_favicon != bookmark.favicon_file: if new_favicon_file != bookmark.favicon_file:
bookmark.favicon_file = new_favicon bookmark.favicon_file = new_favicon_file
bookmark.save(update_fields=['favicon_file']) bookmark.save(update_fields=['favicon_file'])
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}') logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon_file}')
def schedule_bookmarks_without_favicons(user: User): def schedule_bookmarks_without_favicons(user: User):

View File

@@ -71,8 +71,10 @@ def load_page(url: str):
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})') logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
# Stop reading if we have parsed end of head tag # Stop reading if we have parsed end of head tag
if '</head>'.encode('utf-8') in content: end_of_head = '</head>'.encode('utf-8')
if end_of_head in content:
logger.debug(f'Found closing head tag after {size} bytes') logger.debug(f'Found closing head tag after {size} bytes')
content = content.split(end_of_head)[0] + end_of_head
break break
# Stop reading if we exceed limit # Stop reading if we exceed limit
if size > MAX_CONTENT_LIMIT: if size > MAX_CONTENT_LIMIT:

View File

@@ -1,171 +0,0 @@
(function () {
function allowBulkEdit() {
return !!document.getElementById('bulk-edit-mode');
}
function setupBulkEdit() {
if (!allowBulkEdit()) {
return;
}
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
function isAllSelected() {
let result = true
singleToggles.forEach(function (toggle) {
result = result && toggle.checked
})
return result
}
function selectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = true
})
}
function deselectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = false
})
}
// Toggle all
allToggle.addEventListener('change', function (e) {
if (e.target.checked) {
selectAll()
} else {
deselectAll()
}
})
// Toggle single
singleToggles.forEach(function (toggle) {
toggle.addEventListener('change', function () {
allToggle.checked = isAllSelected()
})
})
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
let bulkEditToggleTimeout
if (bulkEditToggle.checked) {
bulkEditBar.style.overflow = 'visible';
}
bulkEditToggle.addEventListener('change', function (e) {
if (bulkEditToggleTimeout) {
clearTimeout(bulkEditToggleTimeout);
bulkEditToggleTimeout = null;
}
if (e.target.checked) {
bulkEditToggleTimeout = setTimeout(function () {
bulkEditBar.style.overflow = 'visible';
}, 500);
} else {
bulkEditBar.style.overflow = 'hidden';
}
});
}
function setupBulkEditTagAutoComplete() {
if (!allowBulkEdit()) {
return;
}
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
const apiClient = new linkding.ApiClient(apiBaseUrl)
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: 'bulk-edit-tags-input',
name: tagInput.name,
value: tagInput.value,
apiClient: apiClient,
variant: 'small'
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
}
function setupListNavigation() {
// Add logic for navigating bookmarks with arrow keys
document.addEventListener('keydown', event => {
// Skip if event occurred within an input element
// or does not use arrow keys
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
const isArrowUp = event.key === 'ArrowUp';
const isArrowDown = event.key === 'ArrowDown';
if (isInputTarget || !(isArrowUp || isArrowDown)) {
return;
}
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(item => item.hasAttribute && item.hasAttribute('data-is-bookmark-item'));
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector('li[data-is-bookmark-item]');
}
// Focus first link
if (nextItem) {
nextItem.querySelector('a').focus();
}
});
}
function setupNotes() {
// Shortcut for toggling all notes
document.addEventListener('keydown', function(event) {
// Filter for shortcut key
if (event.key !== 'e') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
const list = document.querySelector('.bookmark-list');
list.classList.toggle('show-notes');
});
// Toggle notes for single bookmark
const bookmarks = document.querySelectorAll('.bookmark-list li');
bookmarks.forEach(bookmark => {
const toggleButton = bookmark.querySelector('.toggle-notes');
if (toggleButton) {
toggleButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
bookmark.classList.toggle('show-notes');
});
}
});
}
setupBulkEdit();
setupBulkEditTagAutoComplete();
setupListNavigation();
setupNotes();
})()

View File

@@ -1,83 +0,0 @@
(function () {
function initConfirmationButtons() {
const buttonEls = document.querySelectorAll('.btn-confirmation');
function showConfirmation(buttonEl) {
const cancelEl = document.createElement(buttonEl.nodeName);
cancelEl.innerText = 'Cancel';
cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1';
cancelEl.addEventListener('click', function () {
container.remove();
buttonEl.style = '';
});
const confirmEl = document.createElement(buttonEl.nodeName);
confirmEl.innerText = 'Confirm';
confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action';
if (buttonEl.nodeName === 'BUTTON') {
confirmEl.type = buttonEl.type;
confirmEl.name = buttonEl.name;
confirmEl.value = buttonEl.value;
}
if (buttonEl.nodeName === 'A') {
confirmEl.href = buttonEl.href;
}
const container = document.createElement('span');
container.className = 'confirmation'
container.appendChild(cancelEl);
container.appendChild(confirmEl);
buttonEl.parentElement.insertBefore(container, buttonEl);
buttonEl.style = 'display: none';
}
buttonEls.forEach(function (linkEl) {
linkEl.addEventListener('click', function (e) {
e.preventDefault();
showConfirmation(linkEl);
});
});
}
function initGlobalShortcuts() {
// Focus search button
document.addEventListener('keydown', function (event) {
// Filter for shortcut key
if (event.key !== 's') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
});
// Add new bookmark
document.addEventListener('keydown', function(event) {
// Filter for new entry shortcut key
if (event.key !== 'n') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
window.location.assign("/bookmarks/new");
});
}
initConfirmationButtons();
initGlobalShortcuts();
})()

View File

@@ -1,3 +1,4 @@
/* Bookmark search box */
.bookmarks-page .search { .bookmarks-page .search {
$searchbox-width: 180px; $searchbox-width: 180px;
$searchbox-width-md: 300px; $searchbox-width-md: 300px;
@@ -37,12 +38,6 @@
} }
} }
.bookmarks-page .content-area-header {
span.btn {
margin-left: 8px;
}
}
/* Bookmark list */ /* Bookmark list */
ul.bookmark-list { ul.bookmark-list {
list-style: none; list-style: none;
@@ -51,9 +46,10 @@ ul.bookmark-list {
} }
/* Bookmarks */ /* Bookmarks */
ul.bookmark-list li { li[ld-bookmark-item] {
position: relative;
.bulk-edit-toggle { [ld-bulk-edit-checkbox].form-checkbox {
display: none; display: none;
} }
@@ -88,14 +84,11 @@ ul.bookmark-list li {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem;
} }
.actions { .actions {
> *:not(:last-child) { a, button.btn-link {
margin-right: 0.4rem;
}
a, button {
color: $gray-color; color: $gray-color;
padding: 0; padding: 0;
height: auto; height: auto;
@@ -235,6 +228,7 @@ ul.bookmark-list .notes-content {
> *:first-child { > *:first-child {
margin-top: 0; margin-top: 0;
} }
> *:last-child { > *:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -256,6 +250,7 @@ ul.bookmark-list .notes-content {
pre code { pre code {
background: none; background: none;
box-shadow: none; box-shadow: none;
padding: 0;
} }
> pre:first-child:last-child { > pre:first-child:last-child {
@@ -265,14 +260,13 @@ ul.bookmark-list .notes-content {
} }
} }
/* Bookmark actions / bulk edit */ /* Bookmark bulk edit */
$bulk-edit-toggle-width: 16px; $bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px; $bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset); $bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms; $bulk-edit-transition-duration: 400ms;
.bookmarks-page form.bookmark-actions { [ld-bulk-edit] {
.bulk-edit-bar { .bulk-edit-bar {
margin-top: -17px; margin-top: -17px;
margin-bottom: 16px; margin-bottom: 16px;
@@ -282,56 +276,27 @@ $bulk-edit-transition-duration: 400ms;
transition: max-height $bulk-edit-transition-duration; transition: max-height $bulk-edit-transition-duration;
} }
.bulk-edit-actions { &.active .bulk-edit-bar {
display: flex; max-height: 37px;
align-items: baseline; border-bottom: solid 1px $border-color;
padding: 4px 0;
border-top: solid 1px $border-color;
button:hover {
text-decoration: underline;
}
> label.form-checkbox {
min-height: 1rem;
}
> button {
padding: 0;
margin-left: 8px;
}
> span {
margin-left: 8px;
}
> input, .form-autocomplete {
width: auto;
margin-left: 4px;
max-width: 200px;
-webkit-appearance: none;
}
span.confirmation {
display: flex;
}
span.confirmation button {
padding: 0;
}
} }
.bulk-edit-all-toggle { /* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
&.active:not(.activating) .bulk-edit-bar {
overflow: visible;
}
/* All checkbox */
[ld-bulk-edit-checkbox][all].form-checkbox {
display: block;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset; margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0; padding: 0;
min-height: 1rem;
} }
ul.bookmark-list li { /* Bookmark checkboxes */
position: relative; li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
}
ul.bookmark-list li .bulk-edit-toggle {
display: block; display: block;
position: absolute; position: absolute;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
@@ -343,22 +308,36 @@ $bulk-edit-transition-duration: 400ms;
opacity: 0; opacity: 0;
transition: all $bulk-edit-transition-duration; transition: all $bulk-edit-transition-duration;
i { .form-icon {
top: 0.2rem; top: 0.2rem;
} }
} }
}
#bulk-edit-mode { &.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
display: none; visibility: visible;
} opacity: 1;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle { /* Actions */
visibility: visible; .bulk-edit-actions {
opacity: 1; display: flex;
} align-items: baseline;
padding: 4px 0;
border-top: solid 1px $border-color;
gap: 8px;
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar { button {
max-height: 37px; padding: 0 !important;
border-bottom: solid 1px $border-color; }
button:hover {
text-decoration: underline;
}
> input, .form-autocomplete {
width: auto;
max-width: 200px;
-webkit-appearance: none;
}
}
} }

View File

@@ -14,9 +14,15 @@ section.content-area {
} }
// Confirm button component // Confirm button component
.btn-confirmation-action { span.confirmation {
display: flex;
align-items: baseline;
}
span.confirmation .btn.btn-link {
color: $error-color !important; color: $error-color !important;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }

View File

@@ -4,43 +4,42 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns"
{% include 'bookmarks/bulk_edit/state.html' %} ld-bulk-edit
ld-bookmark-page
<div class="bookmarks-page columns"> bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area column col-8 col-md-12"> <section class="content-area column col-8 col-md-12">
<div class="content-area-header"> <div class="content-area-header">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search filters tags mode='archived' %} {% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %} {% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
{% if empty %} <div class="bookmark-list-container">
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/bookmark_list.html' %}
{% else %} </div>
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form> </form>
</section> </section>
{# Tag list #} {# Tag cloud #}
<section class="content-area column col-4 hide-md"> <section class="content-area column col-4 hide-md">
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags selected_tags %} <div class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,128 +1,136 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
<ul class="bookmark-list{% if request.user.profile.permanent_notes %} show-notes{% endif %}">
{% for bookmark in bookmarks %} {% if bookmark_list.is_empty %}
<li data-is-bookmark-item> {% include 'bookmarks/empty_bookmarks.html' %}
<label class="form-checkbox bulk-edit-toggle"> {% else %}
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}"> <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}">
<i class="form-icon"></i> {% for bookmark in bookmark_list.bookmarks_page %}
</label> <li ld-bookmark-item>
<div class="title"> <label ld-bulk-edit-checkbox class="form-checkbox">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" <input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
class="{% if bookmark.unread %}text-italic{% endif %}"> <i class="form-icon"></i>
{% if bookmark.favicon_file and request.user.profile.enable_favicons %} </label>
<img src="{% static bookmark.favicon_file %}" alt=""> <div class="title">
{% endif %} <a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
{{ bookmark.resolved_title }} class="{% if bookmark.unread %}text-italic{% endif %}">
</a> {% if bookmark.favicon_file and bookmark_list.show_favicons %}
</div> <img src="{% static bookmark.favicon_file %}" alt="">
{% if request.user.profile.display_url %} {% endif %}
<div class="url-path truncate"> {{ bookmark.resolved_title }}
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="url-display text-sm">
{{ bookmark.url }}
</a> </a>
</div> </div>
{% endif %} {% if bookmark_list.show_url %}
<div class="description truncate"> <div class="url-path truncate">
{% if bookmark.tag_names %} <a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
<span> class="url-display text-sm">
{{ bookmark.url }}
</a>
</div>
{% endif %}
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %} {% for tag_name in bookmark.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %} {% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %} {% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span> <span>{{ bookmark.resolved_description }}</span>
{% endif %} {% endif %}
</div>
{% if bookmark.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{% markdown bookmark.notes %}
</div>
</div> </div>
{% endif %} {% if bookmark.notes %}
<div class="actions text-gray text-sm"> <div class="notes bg-gray text-gray-dark">
{% if request.user.profile.bookmark_date_display == 'relative' %} <div class="notes-content">
<span> {% markdown bookmark.notes %}
</div>
</div>
{% endif %}
<div class="actions text-gray text-sm">
{% if bookmark_list.date_display == 'relative' %}
<span>
{% if bookmark.web_archive_snapshot_url %} {% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}" <a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener"> rel="noopener">
{% endif %} {% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span> <span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
<span class="separator">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span>
{% if bookmark.web_archive_snapshot_url %} {% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" </a>
rel="noopener">
{% endif %} {% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span> </span>
<span class="separator">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove
</button>
{% if bookmark.unread %}
<span class="separator">|</span> <span class="separator">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read
</button>
{% endif %} {% endif %}
{% else %} {% if bookmark_list.date_display == 'absolute' %}
{# Shared bookmark actions #} <span>
<span>Shared by {% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
<span class="separator">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% if bookmark.unread %}
<span class="separator">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a> <a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span> </span>
{% endif %} {% endif %}
{% if bookmark.notes and not request.user.profile.permanent_notes %} {% if bookmark.notes and not bookmark_list.show_notes %}
<span class="separator">|</span> <span class="separator">|</span>
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes"> <button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16" <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" height="16"
stroke-linejoin="round"> viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> stroke-linejoin="round">
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 7l6 0"></path> <path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 11l6 0"></path> <path d="M9 7l6 0"></path>
<path d="M9 15l4 0"></path> <path d="M9 11l6 0"></path>
</svg> <path d="M9 15l4 0"></path>
<span>Notes</span> </svg>
</button> <span>Notes</span>
{% endif %} </button>
</div> {% endif %}
</li> </div>
{% endfor %} </li>
</ul> {% endfor %}
</ul>
<div class="bookmark-pagination"> <div class="bookmark-pagination">
{% pagination bookmarks %} {% pagination bookmark_list.bookmarks_page %}
</div> </div>
{% endif %}

View File

@@ -1,34 +1,34 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray"> <div class="bulk-edit-actions bg-gray">
<label class="form-checkbox bulk-edit-all-toggle"> <label ld-bulk-edit-checkbox all class="form-checkbox">
<input type="checkbox" style="display: none"> <input type="checkbox" style="display: none">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
{% if mode == 'archive' %} {% if mode == 'archive' %}
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation" <button ld-confirm-button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm"
title="Unarchive selected bookmarks">Unarchive title="Unarchive selected bookmarks">Unarchive
</button>
{% else %}
<button ld-confirm-button type="submit" name="bulk_archive" class="btn btn-link btn-sm"
title="Archive selected bookmarks">Archive
</button>
{% endif %}
<span class="text-sm text-gray-dark"></span>
<button ld-confirm-button type="submit" name="bulk_delete" class="btn btn-link btn-sm"
title="Delete selected bookmarks">Delete
</button> </button>
{% else %} <span class="text-sm text-gray-dark"></span>
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation" <span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
title="Archive selected bookmarks">Archive <input ld-tag-autocomplete variant="small"
name="bulk_tag_string" class="form-input input-sm" placeholder="&nbsp;">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
title="Add tags to selected bookmarks">Add
</button> </button>
{% endif %} <button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
<span class="text-sm text-gray-dark"></span> title="Remove tags from selected bookmarks">Remove
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation" </button>
title="Delete selected bookmarks">Delete </div>
</button>
<span class="text-sm text-gray-dark"></span>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
<input id="bulk-edit-tags-input" name="bulk_tag_string" class="form-input input-sm"
placeholder="&nbsp;">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
title="Add tags to selected bookmarks">Add
</button>
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
title="Remove tags from selected bookmarks">Remove
</button>
</div> </div>
</div>
{% endhtmlmin %} {% endhtmlmin %}

View File

@@ -1 +0,0 @@
<input id="bulk-edit-mode" type="checkbox">

View File

@@ -1,9 +1,7 @@
<label for="bulk-edit-mode" class="hide-sm"> <button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" title="Bulk edit">
<span class="btn" title="Bulk edit"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px" height="20px">
height="20px"> <path
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/> </svg>
</svg> </button>
</span>
</label>

View File

@@ -21,7 +21,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} {{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint"> <div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
exist it will be exist it will be
@@ -90,7 +90,7 @@
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them. Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div> </div>
</div> </div>
{% if request.user.profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox"> <label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }} {{ form.shared }}
@@ -98,7 +98,11 @@
<span>Share</span> <span>Share</span>
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">
Share this bookmark with other users. {% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users.
{% else %}
Share this bookmark with other registered users.
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -114,23 +118,6 @@
{# Replace tag input with auto-complete component #} {# Replace tag input with auto-complete component #}
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script type="application/javascript">
const wrapper = document.createElement('div');
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: '{{ form.tag_string.id_for_label }}',
name: '{{ form.tag_string.name }}',
value: tagInput.value,
apiClient: apiClient
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
</script>
<script type="application/javascript"> <script type="application/javascript">
/** /**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes * - Pre-fill title and description placeholders with metadata from website as soon as URL changes

View File

@@ -4,43 +4,42 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns"
{% include 'bookmarks/bulk_edit/state.html' %} ld-bulk-edit
ld-bookmark-page
<div class="bookmarks-page columns"> bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area column col-8 col-md-12"> <section class="content-area column col-8 col-md-12">
<div class="content-area-header"> <div class="content-area-header">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search filters tags %} {% bookmark_search bookmark_list.filters tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %} {% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
{% if empty %} <div class="bookmark-list-container">
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/bookmark_list.html' %}
{% else %} </div>
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form> </form>
</section> </section>
{# Tag list #} {# Tag cloud #}
<section class="content-area column col-4 hide-md"> <section class="content-area column col-4 hide-md">
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags selected_tags %} <div class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View File

@@ -8,6 +8,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/> <link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}"> <link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service"> <meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow"> <meta name="robots" content="index,follow">
@@ -15,9 +17,9 @@
<title>linkding</title> <title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #} {# Include SASS styles, files are resolved from bookmarks/styles #}
{# Include specific theme variant based on user profile setting #} {# Include specific theme variant based on user profile setting #}
{% if request.user.profile.theme == 'light' %} {% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/> <link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
{% elif request.user.profile.theme == 'dark' %} {% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/> <link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
{% else %} {% else %}
{# Use auto theme as fallback #} {# Use auto theme as fallback #}
@@ -27,7 +29,7 @@
media="(prefers-color-scheme: light)"/> media="(prefers-color-scheme: light)"/>
{% endif %} {% endif %}
</head> </head>
<body> <body ld-global-shortcuts>
<header> <header>
{% if has_toasts %} {% if has_toasts %}
<div class="toasts container grid-lg"> <div class="toasts container grid-lg">
@@ -49,11 +51,16 @@
<h1>linkding</h1> <h1>linkding</h1>
</a> </a>
</section> </section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
<section class="navbar-section"> <section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %} {% include 'bookmarks/nav_menu.html' %}
</section> </section>
{% elif has_public_shares %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
<section class="navbar-section">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
</section>
{% endif %} {% endif %}
</div> </div>
</header> </header>

View File

@@ -20,7 +20,7 @@
<li> <li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a> <a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li> </li>
{% if request.user.profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<li> <li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a> <a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li> </li>
@@ -59,7 +59,7 @@
<li style="padding-left: 1rem"> <li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a> <a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li> </li>
{% if request.user.profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<li style="padding-left: 1rem"> <li style="padding-left: 1rem">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a> <a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li> </li>

View File

@@ -4,26 +4,26 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns"
<div class="bookmarks-page columns"> ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area column col-8 col-md-12"> <section class="content-area column col-8 col-md-12">
<div class="content-area-header"> <div class="content-area-header">
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search filters tags mode='shared' %} {% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% if empty %} <div class="bookmark-list-container">
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/bookmark_list.html' %}
{% else %} </div>
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form> </form>
</section> </section>
@@ -39,11 +39,11 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags selected_tags %} <div class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<div class="tag-cloud"> <div class="tag-cloud">
{% if has_selected_tags %} {% if tag_cloud.has_selected_tags %}
<p class="selected-tags"> <p class="selected-tags">
{% for tag in selected_tags %} {% for tag in tag_cloud.selected_tags %}
<a href="?{% remove_tag_from_query tag.name %}" <a href="?{% remove_tag_from_query tag.name %}"
class="text-bold mr-2"> class="text-bold mr-2">
<span>-{{ tag.name }}</span> <span>-{{ tag.name }}</span>
@@ -12,7 +12,7 @@
</p> </p>
{% endif %} {% endif %}
<div class="unselected-tags"> <div class="unselected-tags">
{% for group in groups %} {% for group in tag_cloud.groups %}
<p class="group"> <p class="group">
{% for tag in group.tags %} {% for tag in group.tags %}
{# Highlight first char of first tag in group #} {# Highlight first char of first tag in group #}

View File

@@ -61,7 +61,8 @@
<div class="form-input-hint"> <div class="form-input-hint">
In strict mode, tags must be prefixed with a hash character (#). In strict mode, tags must be prefixed with a hash character (#).
In lax mode, tags can also be searched without the hash character. In lax mode, tags can also be searched without the hash character.
Note that tags without the hash character are indistinguishable from search terms, which means the search result will also include bookmarks where a search term matches otherwise. Note that tags without the hash character are indistinguishable from search terms, which means the search
result will also include bookmarks where a search term matches otherwise.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -73,11 +74,11 @@
Automatically loads favicons for bookmarked websites and displays them next to each bookmark. Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
By default, this feature uses a <b>Google service</b> to download favicons. By default, this feature uses a <b>Google service</b> to download favicons.
If you don't want to use this service, check the <a If you don't want to use this service, check the <a
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md" target="_blank">options href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
documentation</a> on how to configure a custom favicon provider. target="_blank">options documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up. Icons are downloaded in the background, and it may take a while for them to show up.
</div> </div>
{% if request.user.profile.enable_favicons and enable_refresh_favicons %} {% if request.user_profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button> <button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %} {% endif %}
{% if refresh_favicons_success_message %} {% if refresh_favicons_success_message %}
@@ -112,6 +113,17 @@
Disabling this feature will hide all previously shared bookmarks from other users. Disabling this feature will hide all previously shared bookmarks from other users.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.enable_public_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_public_sharing }}
<i class="form-icon"></i> Enable public bookmark sharing
</label>
<div class="form-input-hint">
Makes shared bookmarks publicly accessible, without requiring a login.
That means that anyone with a link to this instance can view shared bookmarks via the <a
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
</div>
</div>
<div class="form-group"> <div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2"> <input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %} {% if update_profile_success_message %}
@@ -132,6 +144,16 @@
added and existing ones are updated.</p> added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}"> <form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
{% csrf_token %} {% csrf_token %}
<div class="form-group">
<label for="import_map_private_flag" class="form-checkbox">
<input type="checkbox" id="import_map_private_flag" name="map_private_flag">
<i class="form-icon"></i> Import public bookmarks as shared
</label>
<div class="form-input-hint">
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
Otherwise, all bookmarks will be imported as private bookmarks.
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="input-group col-8 col-md-12"> <div class="input-group col-8 col-md-12">
<input class="form-input" type="file" name="import_file"> <input class="form-input" type="file" name="import_file">
@@ -159,6 +181,10 @@
<section class="content-area"> <section class="content-area">
<h2>Export</h2> <h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p> <p>Export all bookmarks in Netscape HTML format.</p>
<p>
Note that exporting bookmark notes is currently not supported due to limitations of the format.
For proper backups please use a database backup as described in the documentation.
</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a> <a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %} {% if export_error %}
<div class="has-error"> <div class="has-error">
@@ -196,4 +222,22 @@
</section> </section>
</div> </div>
<script>
// Automatically disable public bookmark sharing if bookmark sharing is disabled
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
function updatePublicSharing() {
if (enableSharing.checked) {
enablePublicSharing.disabled = false;
} else {
enablePublicSharing.disabled = true;
enablePublicSharing.checked = false;
}
}
updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing);
</script>
{% endblock %} {% endblock %}

View File

@@ -1,10 +1,8 @@
from typing import List, Set from typing import List
from django import template from django import template
from django.core.paginator import Page
from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User
from bookmarks.utils import unique
register = template.Library() register = template.Library()
@@ -20,60 +18,6 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int
} }
class TagGroup:
def __init__(self, char):
self.tags = []
self.char = char
def create_tag_groups(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None
groups = []
# Group tags that start with a different character than the previous one
for tag in sorted_tags:
tag_char = tag.name[0].lower()
if not group or group.char != tag_char:
group = TagGroup(tag_char)
groups.append(group)
group.tags.append(tag)
return groups
@register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True)
def tag_cloud(context, tags: List[Tag], selected_tags: List[Tag]):
# Only display each tag name once, ignoring casing
# This covers cases where the tag cloud contains shared tags with duplicate names
# Also means that the cloud can not make assumptions that it will necessarily contain
# all tags of the current user
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = create_tag_groups(unselected_tags)
return {
'groups': groups,
'selected_tags': unique_selected_tags,
'has_selected_tags': has_selected_tags,
}
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
def bookmark_list(context, bookmarks: Page, return_url: str, link_target: str = '_blank'):
return {
'request': context['request'],
'bookmarks': bookmarks,
'return_url': return_url,
'link_target': link_target,
}
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True) @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, filters: BookmarkFilters, tags: [Tag], mode: str = ''):
tag_names = [tag.name for tag in tags] tag_names = [tag.name for tag in tags]

View File

@@ -49,7 +49,7 @@ def remove_tag_from_query(context, tag_name: str):
tag_name_with_hash = '#' + tag_name tag_name_with_hash = '#' + tag_name
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)] query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
# When using lax tag search, also remove tag without hash # When using lax tag search, also remove tag without hash
profile = context.request.user.profile profile = context.request.user_profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX: if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)] query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
# Rebuild query string # Rebuild query string

View File

@@ -1,5 +1,6 @@
import random import random
import logging import logging
import datetime
from typing import List from typing import List
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -35,6 +36,7 @@ class BookmarkFactoryMixin:
website_description: str = '', website_description: str = '',
web_archive_snapshot_url: str = '', web_archive_snapshot_url: str = '',
favicon_file: str = '', favicon_file: str = '',
added: datetime = None,
): ):
if not title: if not title:
title = get_random_string(length=32) title = get_random_string(length=32)
@@ -45,6 +47,8 @@ class BookmarkFactoryMixin:
if not url: if not url:
unique_id = get_random_string(length=32) unique_id = get_random_string(length=32)
url = 'https://example.com/' + unique_id url = 'https://example.com/' + unique_id
if added is None:
added = timezone.now()
bookmark = Bookmark( bookmark = Bookmark(
url=url, url=url,
title=title, title=title,
@@ -52,7 +56,7 @@ class BookmarkFactoryMixin:
notes=notes, notes=notes,
website_title=website_title, website_title=website_title,
website_description=website_description, website_description=website_description,
date_added=timezone.now(), date_added=added,
date_modified=timezone.now(), date_modified=timezone.now(),
owner=user, owner=user,
is_archived=is_archived, is_archived=is_archived,
@@ -67,6 +71,44 @@ class BookmarkFactoryMixin:
bookmark.save() bookmark.save()
return bookmark return bookmark
def setup_numbered_bookmarks(self,
count: int,
prefix: str = '',
suffix: str = '',
tag_prefix: str = '',
archived: bool = False,
shared: bool = False,
with_tags: bool = False,
user: User = None):
user = user or self.get_or_create_test_user()
if not prefix:
if archived:
prefix = 'Archived Bookmark'
elif shared:
prefix = 'Shared Bookmark'
else:
prefix = 'Bookmark'
if not tag_prefix:
if archived:
tag_prefix = 'Archived Tag'
elif shared:
tag_prefix = 'Shared Tag'
else:
tag_prefix = 'Tag'
for i in range(1, count + 1):
title = f'{prefix} {i}{suffix}'
tags = []
if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)]
self.setup_bookmark(title=title, is_archived=archived, shared=shared, tags=tags, user=user)
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
def setup_tag(self, user: User = None, name: str = ''): def setup_tag(self, user: User = None, name: str = ''):
if user is None: if user is None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -76,11 +118,12 @@ class BookmarkFactoryMixin:
tag.save() tag.save()
return tag return tag
def setup_user(self, name: str = None, enable_sharing: bool = False): def setup_user(self, name: str = None, enable_sharing: bool = False, enable_public_sharing: bool = False):
if not name: if not name:
name = get_random_string(length=32) name = get_random_string(length=32)
user = User.objects.create_user(name, 'user@example.com', 'password123') user = User.objects.create_user(name, 'user@example.com', 'password123')
user.profile.enable_sharing = enable_sharing user.profile.enable_sharing = enable_sharing
user.profile.enable_public_sharing = enable_public_sharing
user.profile.save() user.profile.save()
return user return user
@@ -124,13 +167,15 @@ class BookmarkHtmlTag:
description: str = '', description: str = '',
add_date: str = '', add_date: str = '',
tags: str = '', tags: str = '',
to_read: bool = False): to_read: bool = False,
private: bool = True):
self.href = href self.href = href
self.title = title self.title = title
self.description = description self.description = description
self.add_date = add_date self.add_date = add_date
self.tags = tags self.tags = tags
self.to_read = to_read self.to_read = to_read
self.private = private
class ImportTestMixin: class ImportTestMixin:
@@ -140,7 +185,8 @@ class ImportTestMixin:
<A {f'HREF="{tag.href}"' if tag.href else ''} <A {f'HREF="{tag.href}"' if tag.href else ''}
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''} {f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
{f'TAGS="{tag.tags}"' if tag.tags else ''} {f'TAGS="{tag.tags}"' if tag.tags else ''}
TOREAD="{1 if tag.to_read else 0}"> TOREAD="{1 if tag.to_read else 0}"
PRIVATE="{1 if tag.private else 0}">
{tag.title if tag.title else ''} {tag.title if tag.title else ''}
</A> </A>
{f'<DD>{tag.description}' if tag.description else ''} {f'<DD>{tag.description}' if tag.description else ''}

View File

@@ -0,0 +1,26 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
def assertSharedBookmarksLinkCount(self, response, count):
url = reverse('bookmarks:shared')
self.assertContains(response, f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
count=count)
def test_publicly_shared_bookmarks_link(self):
# should not render link if no public shares exist
user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True)
response = self.client.get(reverse('login'))
self.assertSharedBookmarksLinkCount(response, 0)
# should render link if public shares exist
user.profile.enable_public_sharing = True
user.profile.save()
response = self.client.get(reverse('login'))
self.assertSharedBookmarksLinkCount(response, 1)

View File

@@ -16,7 +16,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(

View File

@@ -27,7 +27,7 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -39,4 +39,4 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -89,7 +89,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag_string = build_tag_string(bookmark.tag_names, ' ') tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML(f''' self.assertInHTML(f'''
<input type="text" name="tag_string" value="{tag_string}" <input ld-tag-autocomplete type="text" name="tag_string" value="{tag_string}"
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string"> autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
''', html) ''', html)

View File

@@ -17,7 +17,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(

View File

@@ -27,7 +27,7 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -39,4 +39,4 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -76,6 +76,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'id="id_url">', 'id="id_url">',
html) html)
def test_should_prefill_title_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?title=Example%20Title')
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="title" value="Example Title" '
'class="form-input" maxlength="512" autocomplete="off" '
'id="id_title">',
html)
def test_should_prefill_description_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?description=Example%20Site%20Description')
html = response.content.decode()
self.assertInHTML(
'<textarea name="description" class="form-input" cols="40" '
'rows="2" id="id_description">Example Site Description</textarea>',
html)
def test_should_enable_auto_close_when_specified_in_url_parameter(self): def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get( response = self.client.get(
reverse('bookmarks:new') + '?auto_close') reverse('bookmarks:new') + '?auto_close')
@@ -141,8 +160,32 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
</label> </label>
''', html, count=1) ''', html, count=1)
def test_should_hide_notes_if_there_are_no_notes(self): def test_should_show_respective_share_hint(self):
bookmark = self.setup_bookmark() self.user.profile.enable_sharing = True
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id])) self.user.profile.save()
self.assertContains(response, '<details class="notes">', count=1) response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('''
<div class="form-input-hint">
Share this bookmark with other registered users.
</div>
''', html)
self.user.profile.enable_public_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('''
<div class="form-input-hint">
Share this bookmark with other registered users and anonymous users.
</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]))
self.assertContains(response, '<details class="notes">', count=1)

View File

@@ -10,6 +10,8 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()): def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) 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) filters = BookmarkFilters(request)
context = RequestContext(request, { context = RequestContext(request, {
'request': request, 'request': request,

View File

@@ -10,7 +10,7 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def authenticate(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
@@ -22,7 +22,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 1, link_target) self.assertBookmarkCount(html, bookmark, 1, link_target)
@@ -65,6 +65,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
''', html, count=0) ''', html, count=0)
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self): def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True) user3 = self.setup_user(enable_sharing=True)
@@ -89,6 +90,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_shared_bookmarks_from_selected_user(self): def test_should_list_shared_bookmarks_from_selected_user(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True) user3 = self.setup_user(enable_sharing=True)
@@ -108,6 +110,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self): def test_should_list_bookmarks_matching_query(self):
self.authenticate()
user = self.setup_user(enable_sharing=True) user = self.setup_user(enable_sharing=True)
visible_bookmarks = [ visible_bookmarks = [
self.setup_bookmark(shared=True, title='searchvalue', user=user), self.setup_bookmark(shared=True, title='searchvalue', user=user),
@@ -126,7 +129,29 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
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),
]
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self): def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True) user3 = self.setup_user(enable_sharing=True)
@@ -158,6 +183,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_shared_bookmarks_from_selected_user(self): def test_should_list_tags_for_shared_bookmarks_from_selected_user(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True) user3 = self.setup_user(enable_sharing=True)
@@ -180,6 +206,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self): def test_should_list_tags_for_bookmarks_matching_query(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True) user3 = self.setup_user(enable_sharing=True)
@@ -207,7 +234,32 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
visible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user1),
]
invisible_tags = [
self.setup_tag(user=user2),
self.setup_tag(user=user2),
]
self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])
self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[1]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self): def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
self.authenticate()
expected_visible_users = [ expected_visible_users = [
self.setup_user(enable_sharing=True), self.setup_user(enable_sharing=True),
self.setup_user(enable_sharing=True), self.setup_user(enable_sharing=True),
@@ -226,30 +278,53 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertVisibleUserOptions(response, expected_visible_users) self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users) self.assertInvisibleUserOptions(response, expected_invisible_users)
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
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_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1])
def test_should_open_bookmarks_in_new_page_by_default(self): expected_invisible_users = [
visible_bookmarks = [ self.setup_user(enable_sharing=True),
self.setup_bookmark(shared=True), self.setup_user(enable_sharing=True),
self.setup_bookmark(shared=True), ]
self.setup_bookmark(shared=True) self.setup_bookmark(shared=True, user=expected_invisible_users[0])
] self.setup_bookmark(shared=True, user=expected_invisible_users[1])
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users)
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank') def test_should_open_bookmarks_in_new_page_by_default(self):
self.authenticate()
user = self.get_or_create_test_user()
user.profile.enable_sharing = True
user.profile.save()
visible_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True)
]
response = self.client.get(reverse('bookmarks:shared'))
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self): self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
user = self.get_or_create_test_user()
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = [ def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
self.setup_bookmark(shared=True), self.authenticate()
self.setup_bookmark(shared=True), user = self.get_or_create_test_user()
self.setup_bookmark(shared=True) user.profile.enable_sharing = True
] user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
response = self.client.get(reverse('bookmarks:shared')) visible_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True)
]
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')

View File

@@ -28,7 +28,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -41,4 +41,4 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -16,8 +16,6 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
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)
self.tag1 = self.setup_tag() self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag() self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes') self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
@@ -26,6 +24,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, 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) 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)
def assertBookmarkListEqual(self, data_list, bookmarks): def assertBookmarkListEqual(self, data_list, bookmarks):
expectations = [] expectations = []
for bookmark in bookmarks: for bookmark in bookmarks:
@@ -53,24 +55,34 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertCountEqual(data_list, expectations) self.assertCountEqual(data_list, expectations)
def test_list_bookmarks(self): def test_list_bookmarks(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK) 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'], [self.bookmark1, self.bookmark2, self.bookmark3])
def test_list_bookmarks_should_filter_by_query(self): def test_list_bookmarks_should_filter_by_query(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name, response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK) expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1]) self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self): def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK) 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'], [self.archived_bookmark1, self.archived_bookmark2])
def test_list_archived_bookmarks_should_filter_by_query(self): def test_list_archived_bookmarks_should_filter_by_query(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK) expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1]) self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_list_shared_bookmarks(self): def test_list_shared_bookmarks(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True) user3 = self.setup_user(enable_sharing=True)
@@ -89,7 +101,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks) self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user1)
]
self.setup_bookmark(shared=True, user=user2)
self.setup_bookmark(shared=True, user=user2)
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
def test_list_shared_bookmarks_should_filter_by_query_and_user(self): def test_list_shared_bookmarks_should_filter_by_query_and_user(self):
self.authenticate()
# Search by query # Search by query
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
@@ -131,6 +159,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks) self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
def test_create_bookmark(self): def test_create_bookmark(self):
self.authenticate()
data = { data = {
'url': 'https://example.com/', 'url': 'https://example.com/',
'title': 'Test title', 'title': 'Test title',
@@ -155,6 +185,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1) self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self): def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.authenticate()
original_bookmark = self.setup_bookmark() original_bookmark = self.setup_bookmark()
data = { data = {
'url': original_bookmark.url, 'url': original_bookmark.url,
@@ -182,6 +214,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1) self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_replaces_whitespace_in_tag_names(self): def test_create_bookmark_replaces_whitespace_in_tag_names(self):
self.authenticate()
data = { data = {
'url': 'https://example.com/', 'url': 'https://example.com/',
'title': 'Test title', 'title': 'Test title',
@@ -194,10 +228,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertListEqual(tag_names, ['tag-1', 'tag-2']) self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
def test_create_bookmark_minimal_payload(self): def test_create_bookmark_minimal_payload(self):
self.authenticate()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_create_archived_bookmark(self): def test_create_archived_bookmark(self):
self.authenticate()
data = { data = {
'url': 'https://example.com/', 'url': 'https://example.com/',
'title': 'Test title', 'title': 'Test title',
@@ -216,41 +254,55 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1) self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_is_not_archived_by_default(self): def test_create_bookmark_is_not_archived_by_default(self):
self.authenticate()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
def test_create_unread_bookmark(self): def test_create_unread_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com/', 'unread': True} data = {'url': 'https://example.com/', 'unread': True}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.unread) self.assertTrue(bookmark.unread)
def test_create_bookmark_is_not_unread_by_default(self): def test_create_bookmark_is_not_unread_by_default(self):
self.authenticate()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.unread) self.assertFalse(bookmark.unread)
def test_create_shared_bookmark(self): def test_create_shared_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com/', 'shared': True} data = {'url': 'https://example.com/', 'shared': True}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.shared) self.assertTrue(bookmark.shared)
def test_create_bookmark_is_not_shared_by_default(self): def test_create_bookmark_is_not_shared_by_default(self):
self.authenticate()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.shared) self.assertFalse(bookmark.shared)
def test_get_bookmark(self): def test_get_bookmark(self):
self.authenticate()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [self.bookmark1]) self.assertBookmarkListEqual([response.data], [self.bookmark1])
def test_update_bookmark(self): def test_update_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -258,11 +310,15 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.url, data['url']) self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self): def test_update_bookmark_fails_without_required_fields(self):
self.authenticate()
data = {'title': 'https://example.com/'} data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST) self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self): def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
self.authenticate()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -274,6 +330,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.tag_names, []) self.assertEqual(updated_bookmark.tag_names, [])
def test_update_bookmark_unread_flag(self): def test_update_bookmark_unread_flag(self):
self.authenticate()
data = {'url': 'https://example.com/', 'unread': True} data = {'url': 'https://example.com/', 'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -281,6 +339,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.unread, True) self.assertEqual(updated_bookmark.unread, True)
def test_update_bookmark_shared_flag(self): def test_update_bookmark_shared_flag(self):
self.authenticate()
data = {'url': 'https://example.com/', 'shared': True} data = {'url': 'https://example.com/', 'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -288,6 +348,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.shared, True) self.assertEqual(updated_bookmark.shared, True)
def test_patch_bookmark(self): def test_patch_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com'} data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
@@ -344,6 +406,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2']) self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self): def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK) self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
@@ -353,23 +417,31 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names) self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
def test_delete_bookmark(self): def test_delete_bookmark(self):
self.authenticate()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT) 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=self.bookmark1.id)), 0)
def test_archive(self): def test_archive(self):
self.authenticate()
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.bookmark1.id) bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertTrue(bookmark.is_archived) self.assertTrue(bookmark.is_archived)
def test_unarchive(self): def test_unarchive(self):
self.authenticate()
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id]) url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) 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=self.archived_bookmark1.id)
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self): def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
self.authenticate()
url = reverse('bookmarks:bookmark-check') url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com') check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK) response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
@@ -378,6 +450,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertIsNone(bookmark_data) self.assertIsNone(bookmark_data)
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self): def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
self.authenticate()
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata: with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata( expected_metadata = WebsiteMetadata(
'https://example.com', 'https://example.com',
@@ -397,6 +471,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertIsNotNone(expected_metadata.description, metadata['description']) self.assertIsNotNone(expected_metadata.description, metadata['description'])
def test_check_returns_bookmark_if_url_is_bookmarked(self): def test_check_returns_bookmark_if_url_is_bookmarked(self):
self.authenticate()
bookmark = self.setup_bookmark(url='https://example.com', bookmark = self.setup_bookmark(url='https://example.com',
title='Example title', title='Example title',
description='Example description') description='Example description')
@@ -413,6 +489,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.description, bookmark_data['description']) self.assertEqual(bookmark.description, bookmark_data['description'])
def test_check_returns_existing_metadata_if_url_is_bookmarked(self): def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
self.authenticate()
bookmark = self.setup_bookmark(url='https://example.com', bookmark = self.setup_bookmark(url='https://example.com',
website_title='Existing title', website_title='Existing title',
website_description='Existing description') website_description='Existing description')
@@ -430,6 +508,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertIsNotNone(bookmark.website_description, metadata['description']) self.assertIsNotNone(bookmark.website_description, metadata['description'])
def test_can_only_access_own_bookmarks(self): def test_can_only_access_own_bookmarks(self):
self.authenticate()
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user) inaccessible_bookmark = self.setup_bookmark(user=other_user)
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True) inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)

View File

@@ -0,0 +1,113 @@
import urllib.parse
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def authenticate(self) -> None:
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)
def test_list_bookmarks_requires_authentication(self):
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
def test_list_archived_bookmarks_requires_authentication(self):
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
def test_list_shared_bookmarks_does_not_require_authentication(self):
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.authenticate()
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
def test_create_bookmark_requires_authentication(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'notes': 'Test notes',
'is_archived': False,
'unread': False,
'shared': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_get_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_update_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.put(url, data, expected_status_code=status.HTTP_200_OK)
def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_archive_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-archive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_unarchive_requires_authentication(self):
bookmark = self.setup_bookmark(is_archived=True)
url = reverse('bookmarks:bookmark-unarchive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_check_requires_authentication(self):
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)

View File

@@ -1,23 +1,33 @@
from typing import Type
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils import timezone, formats from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Bookmark, UserProfile, User from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.partials import contexts
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank', unread: bool = False): def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
unread = bookmark.unread
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
self.assertInHTML( self.assertInHTML(
f''' f'''
<a href="{bookmark.url}" <a href="{bookmark.url}"
target="{link_target}" target="{link_target}"
rel="noopener" rel="noopener"
class="{'text-italic' if unread else ''}">{bookmark.resolved_title}</a> class="{'text-italic' if unread else ''}">
{favicon_img}
{bookmark.resolved_title}
</a>
''', ''',
html html
) )
@@ -52,7 +62,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
# Edit link # Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id]) edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url=/test">Edit</a> <a href="{edit_url}?return_url=%2Fbookmarks">Edit</a>
''', html, count=count) ''', html, count=count)
# Archive link # Archive link
self.assertInHTML(f''' self.assertInHTML(f'''
@@ -61,8 +71,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
''', html, count=count) ''', html, count=count)
# Delete link # Delete link
self.assertInHTML(f''' self.assertInHTML(f'''
<button type="submit" name="remove" value="{bookmark.id}" <button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
class="btn btn-link btn-sm btn-confirmation">Remove</button> class="btn btn-link btn-sm">Remove</button>
''', html, count=count) ''', html, count=count)
def assertShareInfo(self, html: str, bookmark: Bookmark): def assertShareInfo(self, html: str, bookmark: Bookmark):
@@ -130,32 +140,24 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
</button> </button>
''', html, count=count) ''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str: def render_template(self,
url='/bookmarks',
context_type: Type[contexts.BookmarkListContext] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None) -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = self.get_or_create_test_user() request.user = user or self.get_or_create_test_user()
paginator = Paginator(bookmarks, 10) middleware = UserProfileMiddleware(lambda r: HttpResponse())
page = paginator.page(1) middleware(request)
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'}) bookmark_list_context = context_type(request)
context = RequestContext(request, {'bookmark_list': bookmark_list_context})
template = Template(
"{% include 'bookmarks/bookmark_list.html' %}"
)
return template.render(context) return template.render(context)
def render_default_template(self, bookmarks: [Bookmark], url: str = '/test') -> str:
template = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
return self.render_template(bookmarks, template, url)
def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str:
template = Template(
f'''
{{% load bookmarks %}}
{{% bookmark_list bookmarks return_url '{link_target}' %}}
'''
)
return self.render_template(bookmarks, template)
def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''): def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.date_added = timezone.now() - relativedelta(days=8)
@@ -168,7 +170,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def test_should_respect_absolute_date_setting(self): def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE) bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_default_template([bookmark]) html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT') formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertDateLabel(html, formatted_date) self.assertDateLabel(html, formatted_date)
@@ -176,86 +178,95 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def test_should_render_web_archive_link_with_absolute_date_setting(self): def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE, bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/') 'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark]) html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT') formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url) self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
def test_should_respect_relative_date_setting(self): def test_should_respect_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE) self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertDateLabel(html, '1 week ago') self.assertDateLabel(html, '1 week ago')
def test_should_render_web_archive_link_with_relative_date_setting(self): def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE, bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/') 'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url) self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
def test_bookmark_link_target_should_be_blank_by_default(self): def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_template()
html = self.render_default_template([bookmark])
self.assertBookmarksLink(html, bookmark, link_target='_blank') self.assertBookmarksLink(html, bookmark, link_target='_blank')
def test_bookmark_link_target_should_respect_link_target_parameter(self): def test_bookmark_link_target_should_respect_user_profile(self):
bookmark = self.setup_bookmark() profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
html = self.render_template_with_link_target([bookmark], '_self') bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarksLink(html, bookmark, link_target='_self') self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_bookmark_link_target_should_respect_unread_flag(self):
bookmark = self.setup_bookmark()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=False)
bookmark = self.setup_bookmark(unread=True)
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=True)
def test_web_archive_link_target_should_be_blank_by_default(self): def test_web_archive_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com' bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save() bookmark.save()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
def test_web_archive_link_target_respect_link_target_parameter(self): def test_web_archive_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com' bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save() bookmark.save()
html = self.render_template_with_link_target([bookmark], '_self') html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
def test_should_respect_unread_flag(self):
bookmark = self.setup_bookmark(unread=True)
html = self.render_template()
self.assertBookmarksLink(html, bookmark)
def test_show_bookmark_actions_for_owned_bookmarks(self): def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkActions(html, bookmark) self.assertBookmarkActions(html, bookmark)
self.assertNoShareInfo(html, bookmark) self.assertNoShareInfo(html, bookmark)
def test_show_share_info_for_non_owned_bookmarks(self): def test_show_share_info_for_non_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user) other_user.profile.enable_sharing = True
html = self.render_default_template([bookmark]) other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
def test_share_info_user_link_keeps_query_params(self): def test_share_info_user_link_keeps_query_params(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user) other_user.profile.enable_sharing = True
html = self.render_default_template([bookmark], url='/test?q=foo') other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True, title='foo')
html = self.render_template(url='/bookmarks?q=foo', context_type=contexts.SharedBookmarkListContext)
self.assertInHTML(f''' self.assertInHTML(f'''
<span>Shared by <span>Shared by
@@ -269,7 +280,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png') bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertFaviconVisible(html, bookmark) self.assertFaviconVisible(html, bookmark)
@@ -279,7 +290,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file='') bookmark = self.setup_bookmark(favicon_file='')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertFaviconHidden(html, bookmark) self.assertFaviconHidden(html, bookmark)
@@ -289,7 +300,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png') bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertFaviconHidden(html, bookmark) self.assertFaviconHidden(html, bookmark)
@@ -298,7 +309,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkURLHidden(html, bookmark) self.assertBookmarkURLHidden(html, bookmark)
@@ -308,7 +319,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkURLVisible(html, bookmark) self.assertBookmarkURLVisible(html, bookmark)
@@ -318,68 +329,67 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkURLHidden(html, bookmark) self.assertBookmarkURLHidden(html, bookmark)
def test_without_notes(self): def test_without_notes(self):
bookmark = self.setup_bookmark() self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotes(html, '', 0) self.assertNotes(html, '', 0)
self.assertNotesToggle(html, 0) self.assertNotesToggle(html, 0)
def test_with_notes(self): def test_with_notes(self):
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
note_html = '<p>Test note</p>' note_html = '<p>Test note</p>'
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
def test_note_renders_markdown(self): def test_note_renders_markdown(self):
bookmark = self.setup_bookmark(notes='**Example:** `print("Hello world!")`') self.setup_bookmark(notes='**Example:** `print("Hello world!")`')
html = self.render_default_template([bookmark]) html = self.render_template()
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>' note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
def test_note_cleans_html(self): def test_note_cleans_html(self):
bookmark = self.setup_bookmark(notes='<script>alert("test")</script>') self.setup_bookmark(notes='<script>alert("test")</script>')
html = self.render_default_template([bookmark]) html = self.render_template()
note_html = '&lt;script&gt;alert("test")&lt;/script&gt;' note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
def test_notes_are_hidden_initially_by_default(self): def test_notes_are_hidden_initially_by_default(self):
html = self.render_default_template([]) self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertInHTML(""" self.assertIn('<ul class="bookmark-list">', html)
<ul class="bookmark-list"></ul>
""", html)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self): def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
profile.permanent_notes = False profile.permanent_notes = False
profile.save() profile.save()
html = self.render_default_template([])
self.assertInHTML(""" self.setup_bookmark(notes='Test note')
<ul class="bookmark-list"></ul> html = self.render_template()
""", html)
self.assertIn('<ul class="bookmark-list">', html)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self): def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
profile.permanent_notes = True profile.permanent_notes = True
profile.save() profile.save()
html = self.render_default_template([])
self.assertInHTML(""" self.setup_bookmark(notes='Test note')
<ul class="bookmark-list show-notes"></ul> html = self.render_template()
""", html)
self.assertIn('<ul class="bookmark-list show-notes">', html)
def test_toggle_notes_is_visible_by_default(self): def test_toggle_notes_is_visible_by_default(self):
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotesToggle(html, 1) self.assertNotesToggle(html, 1)
@@ -388,8 +398,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = False profile.permanent_notes = False
profile.save() profile.save()
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotesToggle(html, 1) self.assertNotesToggle(html, 1)
@@ -398,7 +408,35 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = True profile.permanent_notes = True
profile.save() profile.save()
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotesToggle(html, 0) self.assertNotesToggle(html, 0)
def test_with_anonymous_user(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.enable_public_sharing = True
profile.save()
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com'
bookmark.notes = '**Example:** `print("Hello world!")`'
bookmark.favicon_file = 'https_example_com.png'
bookmark.shared = True
bookmark.save()
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
self.assertBookmarksLink(html, bookmark, link_target='_blank')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1)
self.assertFaviconVisible(html, bookmark)
def test_empty_state(self):
html = self.render_template()
self.assertInHTML('<p class="empty-title h5">You have no bookmarks yet</p>', html)

View File

@@ -1,10 +1,36 @@
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from bookmarks.services import exporter from bookmarks.services import exporter
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
class ExporterTestCase(TestCase, BookmarkFactoryMixin): class ExporterTestCase(TestCase, BookmarkFactoryMixin):
def test_export_bookmarks(self):
added = timezone.now()
timestamp = int(added.timestamp())
bookmarks = [
self.setup_bookmark(url='https://example.com/1', title='Title 1', added=added,
description='Example description'),
self.setup_bookmark(url='https://example.com/2', title='Title 2', added=added,
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2'),
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),
]
html = exporter.export_netscape_html(bookmarks)
lines = [
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
'<DD>Example description',
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>',
]
self.assertIn('\n\r'.join(lines), html)
def test_escape_html_in_title_and_description(self): def test_escape_html_in_title_and_description(self):
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
title='<style>: The Style Information element', title='<style>: The Style Information element',

View File

@@ -2,25 +2,40 @@ import io
import os.path import os.path
import time import time
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock, skip
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase, override_settings
from bookmarks.services import favicon_loader from bookmarks.services import favicon_loader
mock_icon_data = b'mock_icon' mock_icon_data = b'mock_icon'
class MockStreamingResponse:
def __init__(self, data=mock_icon_data, content_type='image/png'):
self.chunks = [data]
self.headers = {'Content-Type': content_type}
def iter_content(self, **kwargs):
return self.chunks
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
pass
class FaviconLoaderTestCase(TestCase): class FaviconLoaderTestCase(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.ensure_favicon_folder() self.ensure_favicon_folder()
self.clear_favicon_folder() self.clear_favicon_folder()
def create_mock_response(self, icon_data=mock_icon_data): def create_mock_response(self, icon_data=mock_icon_data, content_type='image/png'):
mock_response = mock.Mock() mock_response = mock.Mock()
mock_response.raw = io.BytesIO(icon_data) mock_response.raw = io.BytesIO(icon_data)
return mock_response return MockStreamingResponse(icon_data, content_type)
def ensure_favicon_folder(self): def ensure_favicon_folder(self):
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True) Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
@@ -93,12 +108,14 @@ class FaviconLoaderTestCase(TestCase):
with mock.patch('requests.get') as mock_get: with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response() mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com') favicon_file = favicon_loader.load_favicon('https://example.com')
mock_get.assert_called() mock_get.assert_called()
self.assertEqual(favicon_file, 'https_example_com.png')
mock_get.reset_mock() mock_get.reset_mock()
favicon_loader.load_favicon('https://example.com') updated_favicon_file = favicon_loader.load_favicon('https://example.com')
mock_get.assert_not_called() mock_get.assert_not_called()
self.assertEqual(favicon_file, updated_favicon_file)
def test_load_favicon_updates_stale_icon(self): def test_load_favicon_updates_stale_icon(self):
with mock.patch('requests.get') as mock_get: with mock.patch('requests.get') as mock_get:
@@ -125,3 +142,35 @@ class FaviconLoaderTestCase(TestCase):
favicon_loader.load_favicon('https://example.com') favicon_loader.load_favicon('https://example.com')
mock_get.assert_called() mock_get.assert_called()
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png')) self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))
@override_settings(LD_FAVICON_PROVIDER='https://custom.icons.com/?url={url}')
def test_custom_provider_with_url_param(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com/foo?bar=baz')
mock_get.assert_called_with('https://custom.icons.com/?url=https://example.com', stream=True)
@override_settings(LD_FAVICON_PROVIDER='https://custom.icons.com/?url={domain}')
def test_custom_provider_with_domain_param(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com/foo?bar=baz')
mock_get.assert_called_with('https://custom.icons.com/?url=example.com', stream=True)
def test_guess_file_extension(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response(content_type='image/png')
favicon_loader.load_favicon('https://example.com')
self.assertTrue(self.icon_exists('https_example_com.png'))
self.clear_favicon_folder()
self.ensure_favicon_folder()
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response(content_type='image/x-icon')
favicon_loader.load_favicon('https://example.com')
self.assertTrue(self.icon_exists('https_example_com.ico'))

View File

@@ -6,7 +6,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, Tag, parse_tag_string from bookmarks.models import Bookmark, Tag, parse_tag_string
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services.importer import import_netscape_html from bookmarks.services.importer import import_netscape_html, ImportOptions
from bookmarks.tests.helpers import BookmarkFactoryMixin, ImportTestMixin, BookmarkHtmlTag, disable_logging from bookmarks.tests.helpers import BookmarkFactoryMixin, ImportTestMixin, BookmarkHtmlTag, disable_logging
from bookmarks.utils import parse_timestamp from bookmarks.utils import parse_timestamp
@@ -22,6 +22,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(bookmark.description, html_tag.description) self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date)) self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
self.assertEqual(bookmark.unread, html_tag.to_read) self.assertEqual(bookmark.unread, html_tag.to_read)
self.assertEqual(bookmark.shared, not html_tag.private)
tag_names = parse_tag_string(html_tag.tags) tag_names = parse_tag_string(html_tag.tags)
@@ -66,35 +67,46 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
add_date='3', tags='bar-tag, other-tag'), add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description', BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=True), add_date='3', to_read=True),
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) import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user()) import_netscape_html(import_html, self.get_or_create_test_user())
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 5)
self.assertBookmarksImported(html_tags)
# Change data, add some new data # Change data, add some new data
html_tags = [ html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Updated Example title', BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
description='Updated Example description', add_date='111', tags='updated-example-tag'), description='Updated Example description', add_date='111', tags='updated-example-tag'),
BookmarkHtmlTag(href='https://example.com/foo', title='Updated Foo title', description='Updated Foo description', BookmarkHtmlTag(href='https://example.com/foo', title='Updated Foo title',
description='Updated Foo description',
add_date='222', tags='new-tag'), add_date='222', tags='new-tag'),
BookmarkHtmlTag(href='https://example.com/bar', title='Updated Bar title', description='Updated Bar description', BookmarkHtmlTag(href='https://example.com/bar', title='Updated Bar title',
description='Updated Bar description',
add_date='333', tags='updated-bar-tag, updated-other-tag'), add_date='333', tags='updated-bar-tag, updated-other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description', BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=False), add_date='3', to_read=False),
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') BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
] ]
# Import updated data # Import updated data
import_html = self.render_html(tags=html_tags) import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user()) result = import_netscape_html(import_html, self.get_or_create_test_user(), ImportOptions(map_private_flag=True))
# Check result # Check result
self.assertEqual(result.total, 5) self.assertEqual(result.total, 6)
self.assertEqual(result.success, 5) self.assertEqual(result.success, 6)
self.assertEqual(result.failed, 0) self.assertEqual(result.failed, 0)
# Check bookmarks # Check bookmarks
bookmarks = Bookmark.objects.all() bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 5) self.assertEqual(len(bookmarks), 6)
self.assertBookmarksImported(html_tags) self.assertBookmarksImported(html_tags)
def test_import_with_some_invalid_bookmarks(self): def test_import_with_some_invalid_bookmarks(self):
@@ -254,6 +266,33 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(import_result.success, 0) self.assertEqual(import_result.success, 0)
self.assertEqual(import_result.failed, 2) self.assertEqual(import_result.failed, 2)
def test_private_flag(self):
# does not map private flag if not enabled in options
test_html = self.render_html(tags_html='''
<DT><A HREF="https://example.com/1" ADD_DATE="1">Example title 1</A>
<DD>Example description 1</DD>
<DT><A HREF="https://example.com/2" ADD_DATE="1" PRIVATE="1">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].shared, False)
self.assertEqual(Bookmark.objects.all()[1].shared, False)
self.assertEqual(Bookmark.objects.all()[2].shared, False)
# does map private flag if enabled in options
Bookmark.objects.all().delete()
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions(map_private_flag=True))
bookmark1 = Bookmark.objects.get(url='https://example.com/1')
bookmark2 = Bookmark.objects.get(url='https://example.com/2')
bookmark3 = Bookmark.objects.get(url='https://example.com/3')
self.assertEqual(bookmark1.shared, False)
self.assertEqual(bookmark2.shared, False)
self.assertEqual(bookmark3.shared, True)
def test_schedule_snapshot_creation(self): def test_schedule_snapshot_creation(self):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
test_html = self.render_html(tags_html='') test_html = self.render_html(tags_html='')

View File

@@ -0,0 +1,33 @@
from django.test import TestCase, override_settings
class MetadataViewTestCase(TestCase):
def test_default_manifest(self):
response = self.client.get("/manifest.json")
self.assertEqual(response.status_code, 200)
response_body = response.json()
expected_body = {
"short_name": "linkding",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/"
}
self.assertDictEqual(response_body, expected_body)
@override_settings(LD_CONTEXT_PATH="linkding/")
def test_manifest_respects_context_path(self):
response = self.client.get("/manifest.json")
self.assertEqual(response.status_code, 200)
response_body = response.json()
expected_body = {
"short_name": "linkding",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/linkding/"
}
self.assertDictEqual(response_body, expected_body)

View File

@@ -1,13 +1,17 @@
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import SimpleTestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.tests.helpers import BookmarkFactoryMixin
class PaginationTagTest(SimpleTestCase): class PaginationTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, num_items: int, page_size: int, current_page: int, url: str = '/test') -> str: def render_template(self, num_items: int, page_size: int, current_page: int, url: str = '/test') -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
paginator = Paginator(range(0, num_items), page_size) paginator = Paginator(range(0, num_items), page_size)
page = paginator.page(current_page) page = paginator.page(current_page)

View File

@@ -18,6 +18,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual(bookmark.description, html_tag.description) self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.tag_string, html_tag.tags) self.assertEqual(bookmark.tag_string, html_tag.tags)
self.assertEqual(bookmark.to_read, html_tag.to_read) self.assertEqual(bookmark.to_read, html_tag.to_read)
self.assertEqual(bookmark.private, html_tag.private)
def test_parse_bookmarks(self): def test_parse_bookmarks(self):
html_tags = [ html_tags = [
@@ -123,3 +124,28 @@ class ParserTestCase(TestCase, ImportTestMixin):
bookmarks = parse(html) bookmarks = parse(html)
self.assertTagsEqual(bookmarks, html_tags) self.assertTagsEqual(bookmarks, html_tags)
def test_private_flag(self):
# is private by default
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
<DD>Example description</DD>
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].private, True)
# explicitly marked as private
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1" PRIVATE="1">Example title</A>
<DD>Example description</DD>
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].private, True)
# explicitly marked as public
html = self.render_html(tags_html='''
<DT><A HREF="https://example.com" ADD_DATE="1" PRIVATE="0">Example title</A>
<DD>Example description</DD>
''')
bookmarks = parse(html)
self.assertEqual(bookmarks[0].private, False)

View File

@@ -679,16 +679,26 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user4, shared=True, tags=[tag]), self.setup_bookmark(user=user4, shared=True, tags=[tag]),
# Should return shared bookmarks from all users # Should return shared bookmarks from all users
query_set = queries.query_shared_bookmarks(None, self.profile, '') query_set = queries.query_shared_bookmarks(None, self.profile, '', False)
self.assertQueryResult(query_set, [shared_bookmarks]) self.assertQueryResult(query_set, [shared_bookmarks])
# Should respect search query # Should respect search query
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title') query_set = queries.query_shared_bookmarks(None, self.profile, 'test title', False)
self.assertQueryResult(query_set, [[shared_bookmarks[0]]]) self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name) query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name, False)
self.assertQueryResult(query_set, [[shared_bookmarks[2]]]) self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
def test_query_publicly_shared_bookmarks(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
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)
self.assertQueryResult(query_set, [[bookmark1]])
def test_query_shared_bookmark_tags(self): def test_query_shared_bookmark_tags(self):
user1 = self.setup_user(enable_sharing=True) user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True) user2 = self.setup_user(enable_sharing=True)
@@ -710,10 +720,24 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]), 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)]), self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
query_set = queries.query_shared_bookmark_tags(None, self.profile, '') query_set = queries.query_shared_bookmark_tags(None, self.profile, '', False)
self.assertQueryResult(query_set, [shared_tags]) self.assertQueryResult(query_set, [shared_tags])
def test_query_publicly_shared_bookmark_tags(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user1)
tag2 = self.setup_tag(user=user2)
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)
self.assertQueryResult(query_set, [[tag1]])
def test_query_shared_bookmark_users(self): def test_query_shared_bookmark_users(self):
users_with_shared_bookmarks = [ users_with_shared_bookmarks = [
self.setup_user(enable_sharing=True), self.setup_user(enable_sharing=True),
@@ -735,9 +759,19 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True), self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
# Should return users with shared bookmarks # Should return users with shared bookmarks
query_set = queries.query_shared_bookmark_users(self.profile, '') query_set = queries.query_shared_bookmark_users(self.profile, '', False)
self.assertQueryResult(query_set, [users_with_shared_bookmarks]) self.assertQueryResult(query_set, [users_with_shared_bookmarks])
# Should respect search query # Should respect search query
query_set = queries.query_shared_bookmark_users(self.profile, 'test title') query_set = queries.query_shared_bookmark_users(self.profile, 'test title', False)
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]]) self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
def test_query_publicly_shared_bookmark_users(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user1, shared=True)
self.setup_bookmark(user=user2, shared=True)
query_set = queries.query_shared_bookmark_users(self.profile, '', True)
self.assertQueryResult(query_set, [[user1]])

View File

@@ -27,6 +27,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK, 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED, 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
'enable_sharing': False, 'enable_sharing': False,
'enable_public_sharing': False,
'enable_favicons': False, 'enable_favicons': False,
'tag_search': UserProfile.TAG_SEARCH_STRICT, 'tag_search': UserProfile.TAG_SEARCH_STRICT,
'display_url': False, 'display_url': False,
@@ -54,6 +55,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF, 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED, 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True, 'enable_sharing': True,
'enable_public_sharing': True,
'enable_favicons': True, 'enable_favicons': True,
'tag_search': UserProfile.TAG_SEARCH_LAX, 'tag_search': UserProfile.TAG_SEARCH_LAX,
'display_url': True, 'display_url': True,
@@ -70,6 +72,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target']) self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration']) self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing']) self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
self.assertEqual(self.user.profile.enable_public_sharing, form_data['enable_public_sharing'])
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons']) self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
self.assertEqual(self.user.profile.tag_search, form_data['tag_search']) self.assertEqual(self.user.profile.tag_search, form_data['tag_search'])
self.assertEqual(self.user.profile.display_url, form_data['display_url']) self.assertEqual(self.user.profile.display_url, form_data['display_url'])

View File

@@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
@@ -77,3 +78,30 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertRedirects(response, reverse('bookmarks:settings.general')) self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertFormSuccessHint(response, '2 bookmarks were successfully imported') self.assertFormSuccessHint(response, '2 bookmarks were successfully imported')
self.assertFormErrorHint(response, '1 bookmarks could not be imported') self.assertFormErrorHint(response, '1 bookmarks could not be imported')
def test_should_respect_map_private_flag_option(self):
with open('bookmarks/tests/resources/simple_valid_import_file.html') as import_file:
self.client.post(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertEqual(Bookmark.objects.count(), 3)
self.assertEqual(Bookmark.objects.all()[0].shared, False)
self.assertEqual(Bookmark.objects.all()[1].shared, False)
self.assertEqual(Bookmark.objects.all()[2].shared, False)
Bookmark.objects.all().delete()
with open('bookmarks/tests/resources/simple_valid_import_file.html') as import_file:
self.client.post(
reverse('bookmarks:settings.import'),
{'import_file': import_file, 'map_private_flag': 'on'},
follow=True
)
self.assertEqual(Bookmark.objects.count(), 3)
self.assertEqual(Bookmark.objects.all()[0].shared, True)
self.assertEqual(Bookmark.objects.all()[1].shared, True)
self.assertEqual(Bookmark.objects.all()[2].shared, True)

View File

@@ -1,28 +1,31 @@
from typing import List from typing import List, Type
from django.contrib.auth.models import User, AnonymousUser
from django.http import HttpResponse
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.models import Tag, UserProfile from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts
class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template(self, tags: List[Tag], selected_tags: List[Tag] = None, url: str = '/test'): def render_template(self,
if not selected_tags: context_type: Type[contexts.TagCloudContext] = contexts.ActiveTagCloudContext,
selected_tags = [] url: str = '/test',
user: User | AnonymousUser = None):
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = self.get_or_create_test_user() request.user = user or self.get_or_create_test_user()
context = RequestContext(request, { middleware = UserProfileMiddleware(lambda r: HttpResponse())
'request': request, middleware(request)
'tags': tags,
'selected_tags': selected_tags, tag_cloud_context = context_type(request)
}) context = RequestContext(request, {'tag_cloud': tag_cloud_context})
template_to_render = Template( template_to_render = Template(
'{% load bookmarks %}' "{% include 'bookmarks/tag_cloud.html' %}"
'{% tag_cloud tags selected_tags %}'
) )
return template_to_render.render(context) return template_to_render.render(context)
@@ -48,7 +51,7 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(len(link_elements), count) self.assertEqual(len(link_elements), count)
def test_group_alphabetically(self): def test_group_alphabetically(self):
tags = [ tags = ([
self.setup_tag(name='Cockatoo'), self.setup_tag(name='Cockatoo'),
self.setup_tag(name='Badger'), self.setup_tag(name='Badger'),
self.setup_tag(name='Buffalo'), self.setup_tag(name='Buffalo'),
@@ -58,9 +61,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='Aardvark'), self.setup_tag(name='Aardvark'),
self.setup_tag(name='Bumblebee'), self.setup_tag(name='Bumblebee'),
self.setup_tag(name='Armadillo'), self.setup_tag(name='Armadillo'),
] ])
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(tags) rendered_template = self.render_template()
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [
[ [
@@ -82,12 +86,14 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_no_duplicate_tag_names(self): def test_no_duplicate_tag_names(self):
tags = [ tags = [
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
] ]
for tag in tags:
self.setup_bookmark(tags=[tag], user=tag.owner, shared=True)
rendered_template = self.render_template(tags) rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext)
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [
[ [
@@ -100,8 +106,9 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'), self.setup_tag(name='tag2'),
] ]
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(tags, tags, url='/test?q=%23tag1 %23tag2') rendered_template = self.render_template(url='/test?q=%23tag1 %23tag2')
self.assertNumSelectedTags(rendered_template, 2) self.assertNumSelectedTags(rendered_template, 2)
@@ -128,9 +135,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'), self.setup_tag(name='tag2'),
] ]
self.setup_bookmark(tags=tags)
# Filter by tag name without hash # Filter by tag name without hash
rendered_template = self.render_template(tags, tags, url='/test?q=tag1 %23tag2') rendered_template = self.render_template(url='/test?q=tag1 %23tag2')
self.assertNumSelectedTags(rendered_template, 2) self.assertNumSelectedTags(rendered_template, 2)
@@ -153,8 +161,9 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
tags = [ tags = [
self.setup_tag(name='TEST'), self.setup_tag(name='TEST'),
] ]
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(tags, tags, url='/test?q=%23test') rendered_template = self.render_template(url='/test?q=%23test')
self.assertInHTML(''' self.assertInHTML('''
<a href="?q=" <a href="?q="
@@ -165,12 +174,15 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_no_duplicate_selected_tags(self): def test_no_duplicate_selected_tags(self):
tags = [ tags = [
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
] ]
for tag in tags:
self.setup_bookmark(tags=[tag], shared=True, user=tag.owner)
rendered_template = self.render_template(tags, tags, url='/test?q=%23shared') rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext,
url='/test?q=%23shared')
self.assertInHTML(''' self.assertInHTML('''
<a href="?q=" <a href="?q="
@@ -181,11 +193,12 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_selected_tag_url_keeps_other_search_terms(self): def test_selected_tag_url_keeps_other_search_terms(self):
tag = self.setup_tag(name='tag1') tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1', description='term2')
rendered_template = self.render_template([tag], [tag], url='/test?q=term1 %23tag1 term2 %21untagged') rendered_template = self.render_template(url='/test?q=term1 %23tag1 term2')
self.assertInHTML(''' self.assertInHTML('''
<a href="?q=term1+term2+%21untagged" <a href="?q=term1+term2"
class="text-bold mr-2"> class="text-bold mr-2">
<span>-tag1</span> <span>-tag1</span>
</a> </a>
@@ -199,13 +212,47 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag4'), self.setup_tag(name='tag4'),
self.setup_tag(name='tag5'), self.setup_tag(name='tag5'),
] ]
selected_tags = [ self.setup_bookmark(tags=tags)
tags[0],
tags[1],
]
rendered_template = self.render_template(tags, selected_tags) rendered_template = self.render_template(url='/test?q=%23tag1 %23tag2')
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [
['tag3', 'tag4', 'tag5'] ['tag3', 'tag4', 'tag5']
]) ])
def test_with_anonymous_user(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.enable_public_sharing = True
profile.save()
tags = [
self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'),
self.setup_tag(name='tag3'),
self.setup_tag(name='tag4'),
self.setup_tag(name='tag5'),
]
self.setup_bookmark(tags=tags, shared=True)
rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext,
url='/test?q=%23tag1 %23tag2',
user=AnonymousUser())
self.assertTagGroups(rendered_template, [
['tag3', 'tag4', 'tag5']
])
self.assertNumSelectedTags(rendered_template, 2)
self.assertInHTML('''
<a href="?q=%23tag2"
class="text-bold mr-2">
<span>-tag1</span>
</a>
''', rendered_template)
self.assertInHTML('''
<a href="?q=%23tag1"
class="text-bold mr-2">
<span>-tag2</span>
</a>
''', rendered_template)

View File

@@ -10,6 +10,8 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, url: str, users: QuerySet[User] = User.objects.all()): def render_template(self, url: str, users: QuerySet[User] = User.objects.all()):
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) 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) filters = BookmarkFilters(request)
context = RequestContext(request, { context = RequestContext(request, {
'request': request, 'request': request,

View File

@@ -59,7 +59,7 @@ class WebsiteLoaderTestCase(TestCase):
expected_content_size = 6 * 1024 * 1000 expected_content_size = 6 * 1024 * 1000
self.assertEqual(expected_content_size, len(content)) self.assertEqual(expected_content_size, len(content))
def test_load_page_stops_reading_at_closing_head_tag(self): def test_load_page_stops_reading_at_end_of_head(self):
with mock.patch('requests.get') as mock_get: with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000, mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000,
insert_head_after_chunk=0) insert_head_after_chunk=0)
@@ -69,6 +69,18 @@ class WebsiteLoaderTestCase(TestCase):
expected_content_size = 1 * 1024 * 1000 + len('</head>') expected_content_size = 1 * 1024 * 1000 + len('</head>')
self.assertEqual(expected_content_size, len(content)) self.assertEqual(expected_content_size, len(content))
def test_load_page_removes_bytes_after_end_of_head(self):
with mock.patch('requests.get') as mock_get:
mock_response = MockStreamingResponse(num_chunks=1, chunk_size=0)
mock_response.chunks[0] = '<head>人</head>'.encode('utf-8')
# add a single byte that can't be decoded to utf-8
mock_response.chunks[0] += 0xff.to_bytes(1, 'big')
mock_get.return_value = mock_response
content = website_loader.load_page('https://example.com')
# verify that byte after head was removed, content parsed as utf-8
self.assertEqual(content, '<head>人</head>')
def test_load_website_metadata(self): def test_load_website_metadata(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page: 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') mock_load_page.return_value = self.render_html_document('test title', 'test description')

View File

@@ -1,10 +1,11 @@
from django.urls import re_path
from django.urls import path, include from django.urls import path, include
from django.urls import re_path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from bookmarks.api.routes import router
from bookmarks import views from bookmarks import views
from bookmarks.api.routes import router
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
from bookmarks.views import partials
app_name = 'bookmarks' app_name = 'bookmarks'
urlpatterns = [ urlpatterns = [
@@ -18,6 +19,16 @@ urlpatterns = [
path('bookmarks/close', views.bookmarks.close, name='close'), path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'), path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
path('bookmarks/action', views.bookmarks.action, name='action'), path('bookmarks/action', views.bookmarks.action, name='action'),
# Partials
path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list,
name='partials.bookmark_list.active'),
path('bookmarks/partials/tag-cloud/active', partials.active_tag_cloud, name='partials.tag_cloud.active'),
path('bookmarks/partials/bookmark-list/archived', partials.archived_bookmark_list,
name='partials.bookmark_list.archived'),
path('bookmarks/partials/tag-cloud/archived', partials.archived_tag_cloud, name='partials.tag_cloud.archived'),
path('bookmarks/partials/bookmark-list/shared', partials.shared_bookmark_list,
name='partials.bookmark_list.shared'),
path('bookmarks/partials/tag-cloud/shared', partials.shared_tag_cloud, name='partials.tag_cloud.shared'),
# Settings # Settings
path('settings', views.settings.general, name='settings.index'), path('settings', views.settings.general, name='settings.index'),
path('settings/general', views.settings.general, name='settings.general'), path('settings/general', views.settings.general, name='settings.general'),
@@ -32,5 +43,7 @@ urlpatterns = [
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'), path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'), path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
# Health check # Health check
path('health', views.health, name='health') path('health', views.health, name='health'),
# Manifest
path("manifest.json", views.manifest, name='manifest')
] ]

View File

@@ -2,3 +2,4 @@ from .bookmarks import *
from .settings import * from .settings import *
from .toasts import * from .toasts import *
from .health import health from .health import health
from .manifest import manifest

View File

@@ -1,108 +1,49 @@
import urllib.parse
from typing import List
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db.models import QuerySet, Q, prefetch_related_objects
from django.http import HttpResponseRedirect, Http404 from django.http import HttpResponseRedirect, Http404
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, UserProfile, Tag, build_tag_string from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts
_default_page_size = 30 _default_page_size = 30
@login_required @login_required
def index(request): def index(request):
filters = BookmarkFilters(request) bookmark_list = contexts.ActiveBookmarkListContext(request)
query_set = queries.query_bookmarks(request.user, request.user.profile, filters.query) tag_cloud = contexts.ActiveTagCloudContext(request)
tags = queries.query_bookmark_tags(request.user, request.user.profile, filters.query) return render(request, 'bookmarks/index.html', {
base_url = reverse('bookmarks:index') 'bookmark_list': bookmark_list,
context = get_bookmark_view_context(request, filters, query_set, tags, base_url) 'tag_cloud': tag_cloud,
return render(request, 'bookmarks/index.html', context) })
@login_required @login_required
def archived(request): def archived(request):
filters = BookmarkFilters(request) bookmark_list = contexts.ArchivedBookmarkListContext(request)
query_set = queries.query_archived_bookmarks(request.user, request.user.profile, filters.query) tag_cloud = contexts.ArchivedTagCloudContext(request)
tags = queries.query_archived_bookmark_tags(request.user, request.user.profile, filters.query) return render(request, 'bookmarks/archive.html', {
base_url = reverse('bookmarks:archived') 'bookmark_list': bookmark_list,
context = get_bookmark_view_context(request, filters, query_set, tags, base_url) 'tag_cloud': tag_cloud,
return render(request, 'bookmarks/archive.html', context) })
@login_required
def shared(request): def shared(request):
filters = BookmarkFilters(request) filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first() bookmark_list = contexts.SharedBookmarkListContext(request)
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query) tag_cloud = contexts.SharedTagCloudContext(request)
tags = queries.query_shared_bookmark_tags(user, request.user.profile, filters.query) public_only = not request.user.is_authenticated
users = queries.query_shared_bookmark_users(request.user.profile, filters.query) users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only)
base_url = reverse('bookmarks:shared') return render(request, 'bookmarks/shared.html', {
context = get_bookmark_view_context(request, filters, query_set, tags, base_url) 'bookmark_list': bookmark_list,
context['users'] = users 'tag_cloud': tag_cloud,
return render(request, 'bookmarks/shared.html', context) 'users': users
})
def _get_selected_tags(tags: List[Tag], query_string: str, profile: UserProfile):
parsed_query = queries.parse_query_string(query_string)
tag_names = parsed_query['tag_names']
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query['search_terms']
tag_names = [tag_name.lower() for tag_name in tag_names]
return [tag for tag in tags if tag.name.lower() in tag_names]
def get_bookmark_view_context(request: WSGIRequest,
filters: BookmarkFilters,
query_set: QuerySet[Bookmark],
tags: QuerySet[Tag],
base_url: str):
page = request.GET.get('page')
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
tags = list(tags)
selected_tags = _get_selected_tags(tags, filters.query, request.user.profile)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
prefetch_related_objects(bookmarks.object_list, 'owner', 'tags')
return_url = generate_return_url(base_url, page, filters)
link_target = request.user.profile.bookmark_link_target
if request.GET.get('tag'):
mod = request.GET.copy()
mod.pop('tag')
request.GET = mod
return {
'bookmarks': bookmarks,
'tags': tags,
'selected_tags': selected_tags,
'filters': filters,
'empty': paginator.count == 0,
'return_url': return_url,
'link_target': link_target,
}
def generate_return_url(base_url: str, page: int, filters: BookmarkFilters):
url_query = {}
if filters.query:
url_query['q'] = filters.query
if filters.user:
url_query['user'] = filters.user
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)
def convert_tag_string(tag_string: str): def convert_tag_string(tag_string: str):
@@ -114,6 +55,8 @@ def convert_tag_string(tag_string: str):
@login_required @login_required
def new(request): def new(request):
initial_url = request.GET.get('url') initial_url = request.GET.get('url')
initial_title = request.GET.get('title')
initial_description = request.GET.get('description')
initial_auto_close = 'auto_close' in request.GET initial_auto_close = 'auto_close' in request.GET
if request.method == 'POST': if request.method == 'POST':
@@ -131,6 +74,10 @@ def new(request):
form = BookmarkForm() form = BookmarkForm()
if initial_url: if initial_url:
form.initial['url'] = initial_url form.initial['url'] = initial_url
if initial_title:
form.initial['title'] = initial_title
if initial_description:
form.initial['description'] = initial_description
if initial_auto_close: if initial_auto_close:
form.initial['auto_close'] = 'true' form.initial['auto_close'] = 'true'

View File

@@ -0,0 +1,13 @@
from django.http import JsonResponse
from django.conf import settings
def manifest(request):
response = {
"short_name": "linkding",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/" + settings.LD_CONTEXT_PATH
}
return JsonResponse(response, status=200)

View File

@@ -0,0 +1,58 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from bookmarks.views.partials import contexts
@login_required
def active_bookmark_list(request):
bookmark_list_context = contexts.ActiveBookmarkListContext(request)
return render(request, 'bookmarks/bookmark_list.html', {
'bookmark_list': bookmark_list_context
})
@login_required
def active_tag_cloud(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, 'bookmarks/tag_cloud.html', {
'tag_cloud': tag_cloud_context
})
@login_required
def archived_bookmark_list(request):
bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
return render(request, 'bookmarks/bookmark_list.html', {
'bookmark_list': bookmark_list_context
})
@login_required
def archived_tag_cloud(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, 'bookmarks/tag_cloud.html', {
'tag_cloud': tag_cloud_context
})
@login_required
def shared_bookmark_list(request):
bookmark_list_context = contexts.SharedBookmarkListContext(request)
return render(request, 'bookmarks/bookmark_list.html', {
'bookmark_list': bookmark_list_context
})
@login_required
def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, 'bookmarks/tag_cloud.html', {
'tag_cloud': tag_cloud_context
})

View File

@@ -0,0 +1,168 @@
import urllib.parse
from typing import Set, List
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import models
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import BookmarkFilters, User, UserProfile, Tag
from bookmarks.utils import unique
DEFAULT_PAGE_SIZE = 30
class BookmarkListContext:
def __init__(self, request: WSGIRequest) -> None:
self.request = request
self.filters = BookmarkFilters(self.request)
query_set = self.get_bookmark_query_set()
page_number = request.GET.get('page')
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
bookmarks_page = paginator.get_page(page_number)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags')
self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page
self.return_url = self.generate_return_url(page_number)
self.link_target = request.user_profile.bookmark_link_target
self.date_display = request.user_profile.bookmark_date_display
self.show_url = request.user_profile.display_url
self.show_favicons = request.user_profile.enable_favicons
self.show_notes = request.user_profile.permanent_notes
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
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)
def get_base_url(self):
raise Exception(f'Must be implemented by subclass')
def get_bookmark_query_set(self):
raise Exception(f'Must be implemented by subclass')
class ActiveBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:index')
def get_bookmark_query_set(self):
return queries.query_bookmarks(self.request.user,
self.request.user_profile,
self.filters.query)
class ArchivedBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:archived')
def get_bookmark_query_set(self):
return queries.query_archived_bookmarks(self.request.user,
self.request.user_profile,
self.filters.query)
class SharedBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:shared')
def get_bookmark_query_set(self):
user = User.objects.filter(username=self.filters.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(user,
self.request.user_profile,
self.filters.query,
public_only)
class TagGroup:
def __init__(self, char: str):
self.tags = []
self.char = char
@staticmethod
def create_tag_groups(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None
groups = []
# Group tags that start with a different character than the previous one
for tag in sorted_tags:
tag_char = tag.name[0].lower()
if not group or group.char != tag_char:
group = TagGroup(tag_char)
groups.append(group)
group.tags.append(tag)
return groups
class TagCloudContext:
def __init__(self, request: WSGIRequest) -> None:
self.request = request
self.filters = BookmarkFilters(self.request)
query_set = self.get_tag_query_set()
tags = list(query_set)
selected_tags = self.get_selected_tags(tags)
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(unselected_tags)
self.tags = unique_tags
self.groups = groups
self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags
def get_tag_query_set(self):
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)
tag_names = parsed_query['tag_names']
if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query['search_terms']
tag_names = [tag_name.lower() for tag_name in tag_names]
return [tag for tag in tags if tag.name.lower() in tag_names]
class ActiveTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
return queries.query_bookmark_tags(self.request.user,
self.request.user_profile,
self.filters.query)
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)
class SharedTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
user = User.objects.filter(username=self.filters.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(user,
self.request.user_profile,
self.filters.query,
public_only)

View File

@@ -46,7 +46,7 @@ def general(request):
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...' refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
if not profile_form: if not profile_form:
profile_form = UserProfileForm(instance=request.user.profile) profile_form = UserProfileForm(instance=request.user_profile)
return render(request, 'settings/general.html', { return render(request, 'settings/general.html', {
'form': profile_form, 'form': profile_form,
@@ -116,6 +116,7 @@ def integrations(request):
@login_required @login_required
def bookmark_import(request): def bookmark_import(request):
import_file = request.FILES.get('import_file') import_file = request.FILES.get('import_file')
import_options = importer.ImportOptions(map_private_flag=request.POST.get('map_private_flag') == 'on')
if import_file is None: if import_file is None:
messages.error(request, 'Please select a file to import.', 'bookmark_import_errors') messages.error(request, 'Please select a file to import.', 'bookmark_import_errors')
@@ -123,7 +124,7 @@ def bookmark_import(request):
try: try:
content = import_file.read().decode() content = import_file.read().decode()
result = importer.import_netscape_html(content, request.user) result = importer.import_netscape_html(content, request.user, import_options)
success_msg = str(result.success) + ' bookmarks were successfully imported.' success_msg = str(result.success) + ' bookmarks were successfully imported.'
messages.success(request, success_msg, 'bookmark_import_success') messages.success(request, success_msg, 'bookmark_import_success')
if result.failed > 0: if result.failed > 0:
@@ -141,7 +142,7 @@ def bookmark_import(request):
def bookmark_export(request): def bookmark_export(request):
# noinspection PyBroadException # noinspection PyBroadException
try: try:
bookmarks = list(query_bookmarks(request.user, request.user.profile, '')) bookmarks = list(query_bookmarks(request.user, request.user_profile, ''))
# Prefetch tags to prevent n+1 queries # Prefetch tags to prevent n+1 queries
prefetch_related_objects(bookmarks, 'tags') prefetch_related_objects(bookmarks, 'tags')
file_content = exporter.export_netscape_html(bookmarks) file_content = exporter.export_netscape_html(bookmarks)

View File

@@ -10,6 +10,8 @@ mkdir -p data/favicons
# Run database migration # Run database migration
python manage.py migrate python manage.py migrate
# Enable WAL journal mode for SQLite databases
python manage.py enable_wal
# Generate secret key file if it does not exist # Generate secret key file if it does not exist
python manage.py generate_secret_key python manage.py generate_secret_key
# Create initial superuser if defined in options / environment variables # Create initial superuser if defined in options / environment variables

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
rm -rf static
npm run build
python manage.py compilescss
python manage.py collectstatic --ignore=*.scss
python manage.py compilescss --delete-files

View File

@@ -164,12 +164,20 @@ A json string with additional options for the database. Passed directly to OPTIO
### `LD_FAVICON_PROVIDER` ### `LD_FAVICON_PROVIDER`
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON` Values: `String` | Default = `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32`
The favicon provider used for downloading icons if they are enabled in the user profile settings. The favicon provider used for downloading icons if they are enabled in the user profile settings.
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size. The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
This setting allows to configure a custom provider in form of a URL. This setting allows to configure a custom provider in form of a URL.
When calling the provider with the URL of a website, it must return the image data for the favicon of that website. When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon. The configured favicon provider URL must contain a placeholder that will be replaced with the URL of the website for which to download the favicon.
See the default URL for an example. The available placeholders are:
- `{url}` - Includes the scheme and hostname of the website, for example `https://example.com`
- `{domain}` - Includes only the hostname of the website, for example `example.com`
Which placeholder you need to use depends on the respective favicon provider, please check their documentation or usage examples.
See the default URL for how to insert the placeholder to the favicon provider URL.
Alternative favicon providers:
- DuckDuckGo: `https://icons.duckduckgo.com/ip3/{domain}.ico`

Binary file not shown.

BIN
docs/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Some files were not shown because too many files have changed in this diff Show More