Compare commits

...

53 Commits

Author SHA1 Message Date
Sascha Ißbrücker
fb8e6b3b5f Bump version 2023-01-21 17:16:11 +01:00
Sascha Ißbrücker
814401be2e Add option for showing bookmark favicons (#390)
* Implement favicon loader

* Implement load favicon task

* Show favicons in bookmark list

* Add missing migration

* Load missing favicons on import

* Automatically refresh favicons

* Add enable favicon setting

* Update uwsgi config to host favicons

* Improve settings wording

* Fix favicon loader test setup

* Document LD_FAVICON_PROVIDER setting

* Add refresh favicons button
2023-01-21 16:36:10 +01:00
Sascha Ißbrücker
4cb39fae99 Prefill form if URL is already bookmarked (#402)
* Prefill form from existing bookmark

* add bookmark check api tests
2023-01-20 22:44:10 +01:00
Sascha Ißbrücker
30da1880a5 Cache website metadata to avoid duplicate scraping (#401)
* Cache website metadata to avoid duplicate scraping

* fix test setup
2023-01-20 22:28:44 +01:00
McKenna Jones
da99b8b034 Add Health Check endpoint (#392)
* add simple health endpoint

* add curl and healthcheck to dockerfile

* convert to view

* add simple test

* Add unhealthy test

* Cleanup

* check for LD_SERVER_PORT env var in healthcheck def

* Revert changes to middlewares.py

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-01-20 22:26:58 +01:00
Sascha Ißbrücker
894625aa25 Update CHANGELOG.md 2023-01-20 22:23:32 +01:00
Sascha Ißbrücker
62d7fb5f63 Bump version 2023-01-20 21:28:51 +01:00
dependabot[bot]
fa2633147a Bump minimatch from 3.0.4 to 3.1.2 (#366)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.1.2.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:18:55 +01:00
dependabot[bot]
ddf97b0a3f Bump certifi from 2022.6.15 to 2022.12.7 (#374)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.6.15 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2022.06.15...2022.12.07)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:08:52 +01:00
dependabot[bot]
d3b4aa7602 Bump django from 4.1 to 4.1.2 (#391)
Bumps [django](https://github.com/django/django) from 4.1 to 4.1.2.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/4.1...4.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:04:30 +01:00
Sascha Ißbrücker
021d1cd673 Fix bookmark website metadata not being updated when URL changes (#400) 2023-01-20 20:59:09 +01:00
Sascha Ißbrücker
43d52642a6 Fix website loader test 2023-01-14 12:26:04 +01:00
Sascha Ißbrücker
4f9170c48d Improve website loader logging 2023-01-14 11:24:09 +01:00
Sascha Ißbrücker
313a0ee99f Update CHANGELOG.md 2023-01-12 21:34:36 +01:00
Sascha Ißbrücker
4e32bafe89 Bump version 2023-01-12 21:16:44 +01:00
Sascha Ißbrücker
035399442a Pin node docker image version 2023-01-12 21:16:28 +01:00
Luca
c2d8cde86b Trim website metadata title and description (#383)
* feat: trim fetched metadata placeholders

* feat: implement trimming serverside

* Add website loader tests

* Address review comments

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-01-12 21:06:36 +01:00
tomamplius
13e0516961 Add postgres as database engine (#388)
* Add postgres as database engine

* Fix sissbruecker review

* replace psycopg2 by psycopg2-binary

* Fix Docker setup

* Polish docs

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-01-12 21:00:58 +01:00
McKenna Jones
7b03ceab98 Gracefully stop docker container when it receives SIGTERM (#368)
* add die-on-term option to uwsgi

* exec uwsgi in bootstrap.sh
2023-01-07 19:49:11 +01:00
Alexander Sulfrian
fee979a371 Only show admin link for superusers (#384) 2023-01-06 19:39:47 +01:00
Kazi
9eaae1fcf5 Android HTTP shortcuts v3 (#387)
This simplifies tagging and also filters URL's shared from apps. Useful for apps that don't share clean URLs

Co-authored-by: Roland Meyer <2445563+Waboodoo@users.noreply.github.com>
2023-01-06 19:12:06 +01:00
jhauris
3abdd92430 Correct LD_ENABLE_AUTH_PROXY documentation (#372) (#379) 2022-12-21 08:53:34 +01:00
jhauris
b99d7bf1cc Add apache reverse proxy documentation. (#371)
* Add apache reverse proxy documentation

* Add link to apache2 configuration
2022-12-21 08:52:38 +01:00
Sascha Ißbrücker
f84e2d2210 Add error handling for checking latest version (#360) 2022-10-16 13:04:36 +02:00
Sascha Ißbrücker
2fd7704816 Limit document size for website scraper (#354)
Limits the size of scraped HTML documents to prevent out of memory errors. The scraper will stop reading from the response when it encounters the closing head tag, or if the read content's size exceeds a max limit.

Fixes #345
2022-10-07 21:18:18 +02:00
Sascha Ißbrücker
277c1c76e3 Use raw URL for shortcut JSON 2022-10-06 21:23:23 +02:00
Sascha Ißbrücker
2787dcb769 Bump version 2022-10-05 10:07:40 +02:00
Sascha Ißbrücker
1c3651e91d Add setting and documentation for fixing CSRF errors (#349)
* Add documentation and setting for solving CSRF errors

* Improve proxy setup docs

* Link to reverse proxy documentation

* Fix link
2022-10-05 10:01:44 +02:00
Sascha Ißbrücker
53be77aade Fix static file dir warning (#350) 2022-10-05 10:00:13 +02:00
Sascha Ißbrücker
7148bc62c3 Update CHANGELOG.md 2022-09-11 08:23:04 +02:00
Sascha Ißbrücker
2c7848aa46 Bump version 2022-09-11 08:12:14 +02:00
Sascha Ißbrücker
b94eaee833 Setup logging for background tasks 2022-09-11 07:50:08 +02:00
Sascha Ißbrücker
1b35d5b5ef Prevent rate limit errors in wayback machine API (#339)
The Wayback Machine Save API only allows a limited number of requests within a timespan. This introduces several changes to avoid rate limit errors:
- There will be max. 1 attempt to create a new snapshot
- If a new snapshot could not be created, then attempt to use the latest existing snapshot
- Bulk snapshot updates (bookmark import, load missing snapshots after login) will only attempt to load the latest snapshot instead of creating new ones
2022-09-10 20:43:15 +02:00
Sascha Ißbrücker
6420ec173a Improve bookmark query performance (#334)
* Remove tag projection from bookmark queries

* add feeds performance test
2022-09-09 19:46:55 +02:00
Sascha Ißbrücker
a30571ac99 Fix error when deleting all bookmarks in admin (#336)
Removes the confirmation page when deleting all bookmarks from admin, which could fail in production when the number of deleted objects exceeds 1000.
2022-09-09 08:31:28 +02:00
Sascha Ißbrücker
3aca790212 Bump python version to 3.10 (#333)
* Bump python version to 3.10

* Fix python version in CI config

* Bump to python 3.10.6
2022-09-04 10:05:29 +02:00
Sascha Ißbrücker
38f4dd2bea Minify bookmark list HTML (#332) 2022-09-04 09:03:14 +02:00
Kazi
6e0a345c2c Improved Android HTTP Shortcuts doc (#330)
Simpler to import, one less variable to edit and custom tag(s)
2022-09-04 08:27:03 +02:00
Sascha Ißbrücker
03c0dc04cb Add acknowledgements 2022-09-04 08:17:41 +02:00
Sascha Ißbrücker
f88cc30b48 Add option to create initial superuser (#323)
* Add option to create initial superuser

* Update .env.sample
2022-09-04 08:08:15 +02:00
Sascha Ißbrücker
5841ba0f4c Bump Django and other dependencies (#331)
* Bump Django and other dependencies

* Bump python version for CI
2022-09-04 07:15:09 +02:00
Sascha Ißbrücker
e4636c0ceb Update CHANGELOG.md 2022-08-14 14:42:30 +02:00
Sascha Ißbrücker
992dc69a36 Bump version 2022-08-14 13:41:53 +02:00
Sascha Ißbrücker
c9c6b097d0 Add support for authentication proxies (#321)
* add support for auth proxies

* Improve docs
2022-08-14 13:35:03 +02:00
Sascha Ißbrücker
1308370027 Add bookmark list keyboard navigation (#320)
* Add bookmark list keyboard navigation

* Fix focus outline for title link

* Combine bookmark list scripts
2022-08-14 11:28:36 +02:00
Mingshi Cai
5af4d41ee1 Add project linka to community section in README (#319)
Co-authored-by: masoncai <masoncai@tencent.com>
2022-08-13 15:52:32 +02:00
dependabot[bot]
70b3f824eb Bump django from 3.2.14 to 3.2.15 (#316)
Bumps [django](https://github.com/django/django) from 3.2.14 to 3.2.15.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.14...3.2.15)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-13 11:24:48 +02:00
Sascha Ißbrücker
1b67081773 Skip updating website metadata on edit unless URL has changed (#318)
* Skip updating website metadata on edit unless URL has changed

* Prevent form fetching metadata when editing existing bookmark
2022-08-13 11:21:26 +02:00
Rodrigo Candido Gryzinski
ee7ac775d2 Order tags in bookmark tests (#310) 2022-08-11 09:04:57 +02:00
s2marine
8053468ca5 Add support for context path (#313)
* Add support for context path

add an optional environment variable: LD_CONTEXT_PATH

* Fix for pull request code review comments

Co-authored-by: s2marine <s2marine@gmail.com>
2022-08-07 12:41:11 +02:00
Aaron Bach
eadae32eb3 Add simple docs of the new shared API parameter (#312) 2022-08-05 09:34:03 +02:00
Sascha Ißbrücker
2f0dd0db0d Update README.md 2022-08-04 21:18:56 +02:00
Sascha Ißbrücker
da4ed5b7c1 Update CHANGELOG.md 2022-08-04 21:05:30 +02:00
88 changed files with 3316 additions and 1164 deletions

View File

@@ -5,7 +5,43 @@ LD_HOST_PORT=9090
# Directory on the host system that should be mounted as data dir into the Docker container
LD_HOST_DATA_DIR=./data
# Can be used to run linkding under a context path, for example: linkding/
# Must end with a slash `/`
LD_CONTEXT_PATH=
# Username of the initial superuser to create, leave empty to not create one
LD_SUPERUSER_NAME=
# Password for the initial superuser, leave empty to disable credentials authentication and rely on proxy authentication instead
LD_SUPERUSER_PASSWORD=
# Option to disable background tasks
LD_DISABLE_BACKGROUND_TASKS=False
# Option to disable URL validation for bookmarks completely
LD_DISABLE_URL_VALIDATION=False
# Enables support for authentication proxies such as Authelia
LD_ENABLE_AUTH_PROXY=False
# Name of the request header that the auth proxy passes to the application to identify the user
# See docs/Options.md for more details
LD_AUTH_PROXY_USERNAME_HEADER=
# The URL that linkding should redirect to after a logout, when using an auth proxy
# See docs/Options.md for more details
LD_AUTH_PROXY_LOGOUT_URL=
# List of trusted origins from which to accept POST requests
# See docs/Options.md for more details
LD_CSRF_TRUSTED_ORIGINS=
# Database settings
# These are currently only required for configuring PostreSQL.
# By default, linkding uses SQLite for which you don't need to configure anything.
# Database engine, can be sqlite (default) or postgres
LD_DB_ENGINE=
# Database name (default: linkding)
LD_DB_DATABASE=
# Username to connect to the database server (default: linkding)
LD_DB_USER=
# Password to connect to the database server
LD_DB_PASSWORD=
# The hostname where the database is hosted (default: localhost)
LD_DB_HOST=
# Port use to connect to the database server
# Should use the default port if not set
LD_DB_PORT=

View File

@@ -11,7 +11,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: 3.7
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v2
with:

4
.gitignore vendored
View File

@@ -182,7 +182,9 @@ typings/
### Custom
# Rollup compilation output
/build
/bookmarks/static/bundle.js*
# SASS compilation output
/bookmarks/static/theme-*.css*
# Collected static files for deployment
/static
# Build output, etc.

View File

@@ -1,5 +1,103 @@
# Changelog
## v1.16.1 (20/01/2023)
### What's Changed
* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400
* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391
* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374
* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1
---
## v1.16.0 (12/01/2023)
### What's Changed
* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388
* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368
* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354
* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360
* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383
* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384
* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371
* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379
* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387
### New Contributors
* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371
* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384
* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368
* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388
* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0
---
## v1.15.1 (05/10/2022)
### What's Changed
* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350
* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1
---
## v1.15.0 (11/09/2022)
### What's Changed
* Bump Django and other dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/331
* Add option to create initial superuser by @sissbruecker in https://github.com/sissbruecker/linkding/pull/323
* Improved Android HTTP Shortcuts doc by @kzshantonu in https://github.com/sissbruecker/linkding/pull/330
* Minify bookmark list HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/332
* Bump python version to 3.10 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/333
* Fix error when deleting all bookmarks in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/336
* Improve bookmark query performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/334
* Prevent rate limit errors in wayback machine API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/339
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.14.0...v1.15.0
---
## v1.14.0 (14/08/2022)
### What's Changed
* Add support for context path by @s2marine in https://github.com/sissbruecker/linkding/pull/313
* Add support for authentication proxies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/321
* Add bookmark list keyboard navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/320
* Skip updating website metadata on edit unless URL has changed by @sissbruecker in https://github.com/sissbruecker/linkding/pull/318
* Add simple docs of the new `shared` API parameter by @bachya in https://github.com/sissbruecker/linkding/pull/312
* Add project linka to community section in README by @cmsax in https://github.com/sissbruecker/linkding/pull/319
* Order tags in test_should_create_new_bookmark by @RoGryza in https://github.com/sissbruecker/linkding/pull/310
* Bump django from 3.2.14 to 3.2.15 by @dependabot in https://github.com/sissbruecker/linkding/pull/316
### New Contributors
* @s2marine made their first contribution in https://github.com/sissbruecker/linkding/pull/313
* @RoGryza made their first contribution in https://github.com/sissbruecker/linkding/pull/310
* @cmsax made their first contribution in https://github.com/sissbruecker/linkding/pull/319
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.13.0...v1.14.0
---
## v1.13.0 (04/08/2022)
### What's Changed
* Add bookmark sharing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/311
* Display selected tags in tag cloud by @sissbruecker and @jhauris in https://github.com/sissbruecker/linkding/pull/307
* Update unread flag when saving duplicate URL by @sissbruecker in https://github.com/sissbruecker/linkding/pull/306
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.12.0...v1.13.0
---
## v1.12.0 (23/07/2022)
### What's Changed

View File

@@ -1,4 +1,4 @@
FROM node:current-alpine AS node-build
FROM node:18.13.0-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY package.json package-lock.json ./
@@ -9,8 +9,8 @@ COPY . .
RUN npm run build
FROM python:3.9.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential
FROM python:3.10.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential libpq-dev
WORKDIR /etc/linkding
@@ -33,8 +33,8 @@ RUN mkdir /opt/venv && \
/opt/venv/bin/pip install -Ur requirements.txt
FROM python:3.9.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support
FROM python:3.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support libpq-dev curl
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv
@@ -51,4 +51,8 @@ ENV PATH /opt/venv/bin:$PATH
RUN ["chmod", "g+w", "."]
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/health || exit 1
CMD ["./bootstrap.sh"]

View File

@@ -12,6 +12,7 @@
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
@@ -29,18 +30,19 @@ The name comes from:
- ...so basically something for managing your links
**Feature Overview:**
- Tags for organizing bookmarks
- Search by text or tags
- Organize bookmarks with tags
- Read it later functionality
- Share bookmarks with other users
- Bulk editing
- Bookmark archive
- Dark mode
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Automatically provides titles and descriptions of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), and a bookmarklet that should work in most browsers
- 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
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
- Easy to set up using Docker, uses SQLite as database
- Easy setup using Docker, uses SQLite as database
**Demo:** https://demo.linkding.link/ (configured with open registration)
@@ -51,7 +53,11 @@ The name comes from:
## Installation
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
By default, linkding uses SQLite as a database.
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
### Using Docker
@@ -95,6 +101,61 @@ 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.
### 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.
<details>
<summary>Apache</summary>
Apache2 does not change the headers by default, and should not
need additional configuration.
An example virtual host that proxies to linkding might look like:
```
<VirtualHost *:9100>
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
ProxyPass / http://linkding:9090/
ProxyPassReverse / http://linkding:9090/
</VirtualHost>
```
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
</details>
<details>
<summary>Caddy 2</summary>
Caddy does not change the headers by default, and should not need any further configuration.
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
</details>
<details>
<summary>Nginx</summary>
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
```
location /linkding {
...
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
</details>
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
### 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.
@@ -126,18 +187,23 @@ 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.
- [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)
- [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)
- [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)
## Acknowledgements
JetBrains provides an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.2/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3
- Python 3.10
- Node.js
### Setup

View File

@@ -23,7 +23,25 @@ class AdminBookmark(admin.ModelAdmin):
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
list_filter = ('owner__username', 'is_archived', 'unread', 'tags',)
ordering = ('-date_added',)
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks', 'mark_as_read', 'mark_as_unread']
actions = ['delete_selected_bookmarks', 'archive_selected_bookmarks', 'unarchive_selected_bookmarks', 'mark_as_read', 'mark_as_unread']
def get_actions(self, request):
actions = super().get_actions(request)
# Remove default delete action, which gets replaced by delete_selected_bookmarks below
# The default action shows a confirmation page which can fail in production when selecting all bookmarks and the
# number of objects to delete exceeds the value in DATA_UPLOAD_MAX_NUMBER_FIELDS (1000 by default)
del actions['delete_selected']
return actions
def delete_selected_bookmarks(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
for bookmark in queryset:
bookmark.delete()
self.message_user(request, ngettext(
'%d bookmark was successfully deleted.',
'%d bookmarks were successfully deleted.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
def archive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:

View File

@@ -1,4 +1,3 @@
from django.urls import reverse
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -7,8 +6,8 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
from bookmarks.services.website_loader import WebsiteMetadata
class BookmarkViewSet(viewsets.GenericViewSet,
@@ -68,15 +67,13 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def check(self, request):
url = request.GET.get('url')
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = None
existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
if bookmark is not None:
existing_bookmark_data = {
'id': bookmark.id,
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
}
metadata = load_website_metadata(url)
# Either return metadata from existing bookmark, or scrape from URL
if bookmark:
metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
else:
metadata = website_loader.load_website_metadata(url)
return Response({
'bookmark': existing_bookmark_data,

View File

@@ -1,4 +1,6 @@
from django.db.models import prefetch_related_objects
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
@@ -9,6 +11,14 @@ class TagListField(serializers.ListField):
child = serializers.CharField()
class BookmarkListSerializer(ListSerializer):
def to_representation(self, data):
# Prefetch nested relations to avoid n+1 queries
prefetch_related_objects(data, 'tags')
return super().to_representation(data)
class BookmarkSerializer(serializers.ModelSerializer):
class Meta:
model = Bookmark
@@ -32,6 +42,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'date_added',
'date_modified'
]
list_serializer_class = BookmarkListSerializer
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='')

View File

@@ -0,0 +1,37 @@
import os
import logging
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Creates an initial superuser for a deployment using env variables"
def handle(self, *args, **options):
User = get_user_model()
superuser_name = os.getenv('LD_SUPERUSER_NAME', None)
superuser_password = os.getenv('LD_SUPERUSER_PASSWORD', None)
# Skip if option is undefined
if not superuser_name:
logger.info('Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined')
return
# Skip if user already exists
user_exists = User.objects.filter(username=superuser_name).exists()
if user_exists:
logger.info('Skip creating initial superuser, user already exists')
return
user = User(username=superuser_name, is_superuser=True, is_staff=True)
if superuser_password:
user.set_password(superuser_password)
else:
user.set_unusable_password()
user.save()
logger.info('Created initial superuser')

6
bookmarks/middlewares.py Normal file
View File

@@ -0,0 +1,6 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1 on 2023-01-07 23:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0017_userprofile_enable_sharing'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='favicon_file',
field=models.CharField(blank=True, max_length=512),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1 on 2023-01-09 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0018_bookmark_favicon_file'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='enable_favicons',
field=models.BooleanField(default=False),
),
]

View File

@@ -53,6 +53,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
favicon_file = models.CharField(max_length=512, blank=True)
unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
shared = models.BooleanField(default=False)
@@ -62,11 +63,6 @@ class Bookmark(models.Model):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag)
# Attributes might be calculated in query
tag_count = 0 # Projection for number of associated tags
tag_string = '' # Projection for list of tag names, comma-separated
tag_projection = False # Tracks if the above projections were loaded
@property
def resolved_title(self):
if self.title:
@@ -82,11 +78,7 @@ class Bookmark(models.Model):
@property
def tag_names(self):
# If tag projections were loaded then avoid querying all tags (=executing further selects)
if self.tag_projection:
return parse_tag_string(self.tag_string)
else:
return [tag.name for tag in self.tags.all()]
return [tag.name for tag in self.tags.all()]
def __str__(self):
return self.resolved_title + ' (' + self.url[:30] + '...)'
@@ -101,6 +93,11 @@ class BookmarkForm(forms.ModelForm):
required=False)
description = forms.CharField(required=False,
widget=forms.Textarea())
# Include website title and description as hidden field as they only provide info when editing bookmarks
website_title = forms.CharField(max_length=512,
required=False, widget=forms.HiddenInput())
website_description = forms.CharField(required=False,
widget=forms.HiddenInput())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
@@ -108,7 +105,17 @@ class BookmarkForm(forms.ModelForm):
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'unread', 'shared', 'auto_close']
fields = [
'url',
'tag_string',
'title',
'description',
'website_title',
'website_description',
'unread',
'shared',
'auto_close',
]
class BookmarkFilters:
@@ -155,12 +162,13 @@ class UserProfile(models.Model):
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing']
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons']
@receiver(post_save, sender=get_user_model())

View File

@@ -1,24 +1,12 @@
from typing import Optional
from django.contrib.auth.models import User
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet
from django.db.models import Q, QuerySet
from bookmarks.models import Bookmark, Tag
from bookmarks.utils import unique
class Concat(Aggregate):
function = 'GROUP_CONCAT'
template = '%(function)s(%(distinct)s%(expressions)s)'
def __init__(self, expression, distinct=False, **extra):
super(Concat, self).__init__(
expression,
distinct='DISTINCT ' if distinct else '',
output_field=CharField(),
**extra)
def query_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(is_archived=False)
@@ -36,11 +24,7 @@ def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
# Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'),
tag_string=Concat('tags__name'),
tag_projection=Value(True, BooleanField()))
query_set = Bookmark.objects
# Filter for user
if user:

View File

@@ -5,7 +5,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services.tags import get_or_create_tags
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.services import website_loader
from bookmarks.services import tasks
@@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
bookmark.save()
# Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False)
# Load favicon
tasks.load_favicon(current_user, bookmark)
return bookmark
@@ -38,16 +40,20 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Detect URL change
original_bookmark = Bookmark.objects.get(id=bookmark.id)
has_url_changed = original_bookmark.url != bookmark.url
# Update website info
_update_website_metadata(bookmark)
# Update tag list
_update_bookmark_tags(bookmark, tag_string, current_user)
# Update dates
bookmark.date_modified = timezone.now()
bookmark.save()
# Update web archive snapshot, if URL changed
# Update favicon
tasks.load_favicon(current_user, bookmark)
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed
_update_website_metadata(bookmark)
bookmark.save()
return bookmark
@@ -121,7 +127,7 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
def _update_website_metadata(bookmark: Bookmark):
metadata = load_website_metadata(bookmark.url)
metadata = website_loader.load_website_metadata(bookmark.url)
bookmark.website_title = metadata.title
bookmark.website_description = metadata.description

View File

@@ -0,0 +1,57 @@
import os.path
import re
import shutil
import time
from pathlib import Path
from urllib.parse import urlparse
import requests
from django.conf import settings
max_file_age = 60 * 60 * 24 # 1 day
def _ensure_favicon_folder():
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
def _url_to_filename(url: str) -> str:
name = re.sub(r'\W+', '_', url)
return f'{name}.png'
def _get_base_url(url: str) -> str:
parsed_uri = urlparse(url)
return f'{parsed_uri.scheme}://{parsed_uri.hostname}'
def _get_favicon_path(favicon_file: str) -> Path:
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
def _is_stale(path: Path) -> bool:
stat = path.stat()
file_age = time.time() - stat.st_mtime
return file_age >= max_file_age
def load_favicon(url: str) -> str:
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host
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
if not favicon_path.exists() or _is_stale(favicon_path):
# Create favicon folder if not exists
_ensure_favicon_folder()
# Load favicon from provider, save to file
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url)
response = requests.get(favicon_url, stream=True)
with open(favicon_path, 'wb') as file:
shutil.copyfileobj(response.raw, file)
del response
return favicon_name

View File

@@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User):
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user)
end = timezone.now()
logger.debug(f'Import duration: {end - import_start}')

View File

@@ -2,13 +2,16 @@ import logging
import waybackpy
from background_task import background
from background_task.models import Task
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from waybackpy.exceptions import WaybackError
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
from bookmarks.services import favicon_loader
logger = logging.getLogger(__name__)
@@ -26,6 +29,32 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
_create_web_archive_snapshot_task(bookmark.id, force_update)
def _load_newest_snapshot(bookmark: Bookmark):
try:
logger.info(f'Load existing snapshot for bookmark. url={bookmark.url}')
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(bookmark.url)
existing_snapshot = cdx_api.newest()
if existing_snapshot:
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
bookmark.save()
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
except NoCDXRecordFound:
logger.info(f'Could not find any snapshots for bookmark. url={bookmark.url}')
except WaybackError as error:
logger.error(f'Failed to load existing snapshot. url={bookmark.url}', exc_info=error)
def _create_snapshot(bookmark: Bookmark):
logger.info(f'Create new snapshot for bookmark. url={bookmark.url}...')
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
archive.save()
bookmark.web_archive_snapshot_url = archive.archive_url
bookmark.save()
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
@background()
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
try:
@@ -37,19 +66,32 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
if bookmark.web_archive_snapshot_url and not force_update:
return
logger.debug(f'Create web archive link for bookmark: {bookmark}...')
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT)
# Create new snapshot
try:
archive.save()
_create_snapshot(bookmark)
return
except TooManyRequestsError:
logger.error(
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
except WaybackError as error:
logger.exception(f'Error creating web archive link for bookmark: {bookmark}...', exc_info=error)
raise
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
exc_info=error)
bookmark.web_archive_snapshot_url = archive.archive_url
bookmark.save()
logger.debug(f'Successfully created web archive link for bookmark: {bookmark}...')
# Load the newest snapshot as fallback
_load_newest_snapshot(bookmark)
@background()
def _load_web_archive_snapshot_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
# Skip if snapshot exists
if bookmark.web_archive_snapshot_url:
return
# Load the newest snapshot
_load_newest_snapshot(bookmark)
def schedule_bookmarks_without_snapshots(user: User):
@@ -63,4 +105,70 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int):
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
for bookmark in bookmarks_without_snapshots:
_create_web_archive_snapshot_task(bookmark.id, False)
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
# new ones when processing bookmarks in bulk
_load_web_archive_snapshot_task(bookmark.id)
def is_favicon_feature_active(user: User) -> bool:
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
return background_tasks_enabled and user.profile.enable_favicons
def load_favicon(user: User, bookmark: Bookmark):
if is_favicon_feature_active(user):
_load_favicon_task(bookmark.id)
@background()
def _load_favicon_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
new_favicon = favicon_loader.load_favicon(bookmark.url)
if new_favicon != bookmark.favicon_file:
bookmark.favicon_file = new_favicon
bookmark.save()
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}')
def schedule_bookmarks_without_favicons(user: User):
if is_favicon_feature_active(user):
_schedule_bookmarks_without_favicons_task(user.id)
@background()
def _schedule_bookmarks_without_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(favicon_file__exact='', owner=user)
tasks = []
for bookmark in bookmarks:
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
tasks.append(task)
Task.objects.bulk_create(tasks)
def schedule_refresh_favicons(user: User):
if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS:
_schedule_refresh_favicons_task(user.id)
@background()
def _schedule_refresh_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(owner=user)
tasks = []
for bookmark in bookmarks:
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
tasks.append(task)
Task.objects.bulk_create(tasks)

View File

@@ -0,0 +1,40 @@
import time
from typing import Dict
import waybackpy
import waybackpy.utils
from waybackpy.exceptions import NoCDXRecordFound
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
"""
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
See https://github.com/akamhy/waybackpy/issues/176
"""
def newest(self):
unix_timestamp = int(time.time())
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(unix_timestamp)
self.sort = 'closest'
self.limit = -5
newest_snapshot = None
for snapshot in self.snapshots():
newest_snapshot = snapshot
break
if not newest_snapshot:
raise NoCDXRecordFound(
"Wayback Machine's CDX server did not return any records "
+ "for the query. The URL may not have any archives "
+ " on the Wayback Machine or the URL may have been recently "
+ "archived and is still not available on the CDX server."
)
return newest_snapshot
def add_payload(self, payload: Dict[str, str]) -> None:
super().add_payload(payload)
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
# makes searching for latest snapshots faster
payload['fastLatest'] = 'true'

View File

@@ -1,8 +1,13 @@
import logging
from dataclasses import dataclass
from functools import lru_cache
import requests
from bs4 import BeautifulSoup
from charset_normalizer import from_bytes
from django.utils import timezone
logger = logging.getLogger(__name__)
@dataclass
@@ -19,29 +24,68 @@ class WebsiteMetadata:
}
# Caching metadata avoids scraping again when saving bookmarks, in case the
# metadata was already scraped to show preview values in the bookmark form
@lru_cache(maxsize=10)
def load_website_metadata(url: str):
title = None
description = None
try:
start = timezone.now()
page_text = load_page(url)
end = timezone.now()
logger.debug(f'Load duration: {end - start}')
start = timezone.now()
soup = BeautifulSoup(page_text, 'html.parser')
title = soup.title.string if soup.title is not None else None
title = soup.title.string.strip() if soup.title is not None else None
description_tag = soup.find('meta', attrs={'name': 'description'})
description = description_tag['content'] if description_tag is not None else None
description = description = description_tag['content'].strip() if description_tag and description_tag[
'content'] else None
end = timezone.now()
logger.debug(f'Parsing duration: {end - start}')
finally:
return WebsiteMetadata(url=url, title=title, description=description)
CHUNK_SIZE = 50 * 1024
MAX_CONTENT_LIMIT = 5000 * 1024
def load_page(url: str):
headers = fake_request_headers()
r = requests.get(url, timeout=10, headers=headers)
size = 0
content = None
iteration = 0
# Use with to ensure request gets closed even if it's only read partially
with requests.get(url, timeout=10, headers=headers, stream=True) as r:
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
size += len(chunk)
iteration = iteration + 1
if content is None:
content = chunk
else:
content = content + chunk
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
# Stop reading if we have parsed end of head tag
if '</head>'.encode('utf-8') in content:
logger.debug(f'Found closing head tag after {size} bytes')
break
# Stop reading if we exceed limit
if size > MAX_CONTENT_LIMIT:
logger.debug(f'Cancel reading document after {size} bytes')
break
if hasattr(r, '_content_consumed'):
logger.debug(f'Request consumed: {r._content_consumed}')
# Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
# This is different from Response.text which does respect the encoding specified in the response first,
# before trying to determine one
results = from_bytes(r.content)
results = from_bytes(content or '')
return str(results.best())

View File

@@ -0,0 +1,127 @@
(function () {
function setupBulkEdit() {
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() {
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();
}
});
}
setupBulkEdit();
setupBulkEditTagAutoComplete();
setupListNavigation();
})()

View File

@@ -1,86 +0,0 @@
(function () {
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';
}
});
// Init tag auto-complete
function initTagAutoComplete() {
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);
}
initTagAutoComplete();
})()

View File

@@ -49,6 +49,21 @@ ul.bookmark-list {
margin: 0;
padding: 0;
.title a {
display: inline-block;
vertical-align: top;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title img {
width: 16px;
height: 16px;
vertical-align: text-top;
}
.description {
color: $gray-color-dark;

View File

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

View File

@@ -1,88 +1,102 @@
{% load static %}
{% load shared %}
{% load pagination %}
<ul class="bookmark-list">
{% htmlmin %}
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</label>
<div class="title truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" class="{% if bookmark.unread %}text-italic{% endif %}">{{ bookmark.resolved_title }}</a>
</div>
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %}
<a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span>
{% endif %}
</div>
<div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="date-label text-gray text-sm">
{% 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 }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="date-label text-gray text-sm">
{% 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 }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">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="text-gray text-sm">|</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 class="text-gray text-sm">Shared by
<a class="text-gray" href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
{% endif %}
</div>
</li>
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</label>
<div class="title">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
{{ bookmark.resolved_title }}
</a>
</div>
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %}
<a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span>
{% endif %}
</div>
<div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="date-label text-gray text-sm">
{% 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 }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="date-label text-gray text-sm">
{% 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 }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">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="text-gray text-sm">|</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 class="text-gray text-sm">Shared by
<a class="text-gray"
href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</ul>
<div class="bookmark-pagination">
<div class="bookmark-pagination">
{% pagination bookmarks %}
</div>
</div>
{% endhtmlmin %}

View File

@@ -1,9 +1,9 @@
(function() {
var bookmarkUrl = window.location;
var applicationUrl = '{{ application_url }}';
(function () {
var bookmarkUrl = window.location;
var applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&auto_close';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&auto_close';
window.open(applicationUrl);
window.open(applicationUrl);
})();

View File

@@ -1,31 +1,34 @@
{% load shared %}
{% htmlmin %}
<div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray">
<label class="form-checkbox bulk-edit-all-toggle">
<input type="checkbox" style="display: none">
<i class="form-icon"></i>
</label>
{% if mode == 'archive' %}
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation"
title="Unarchive selected bookmarks">Unarchive
</button>
{% else %}
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation"
title="Archive selected bookmarks">Archive
</button>
{% endif %}
<span class="text-sm text-gray-dark"></span>
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation"
title="Delete selected bookmarks">Delete
</button>
<span class="text-sm text-gray-dark"></span>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
<input 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 class="bulk-edit-actions bg-gray">
<label class="form-checkbox bulk-edit-all-toggle">
<input type="checkbox" style="display: none">
<i class="form-icon"></i>
</label>
{% if mode == 'archive' %}
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation"
title="Unarchive selected bookmarks">Unarchive
</button>
{% else %}
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation"
title="Archive selected bookmarks">Archive
</button>
{% endif %}
<span class="text-sm text-gray-dark"></span>
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation"
title="Delete selected bookmarks">Delete
</button>
<span class="text-sm text-gray-dark"></span>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
<input 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>
{% endhtmlmin %}

View File

@@ -2,7 +2,8 @@
<span class="btn" title="Bulk edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
height="20px">
<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"/>
<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"/>
</svg>
</span>
</label>

View File

@@ -1,9 +1,9 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<script type="application/javascript">
window.close()
</script>
<p>You can now close this window.</p>
<script type="application/javascript">
window.close()
</script>
<p>You can now close this window.</p>
{% endblock %}

View File

@@ -2,14 +2,15 @@
{% load bookmarks %}
{% block content %}
<div class="columns">
<section class="content-area column col-12">
<div class="content-area-header">
<h2>Edit bookmark</h2>
</div>
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form return_url bookmark_id %}
</form>
</section>
</div>
<div class="columns">
<section class="content-area column col-12">
<div class="content-area-header">
<h2>Edit bookmark</h2>
</div>
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
class="col-6 col-md-12" novalidate>
{% bookmark_form form return_url bookmark_id %}
</form>
</section>
</div>
{% endblock %}

View File

@@ -1,8 +1,9 @@
<div class="empty">
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
</p>
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a
href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
</p>
</div>

View File

@@ -2,190 +2,220 @@
{% load static %}
<div class="bookmarks-form">
{% csrf_token %}
{{ form.auto_close|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
{% if form.url.errors %}
<div class="form-input-hint">
{{ form.url.errors }}
</div>
{% endif %}
<div class="form-input-hint bookmark-exists">
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark
by saving this form.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
<div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
exist it will be
automatically created.
</div>
{{ form.tag_string.errors }}
</div>
<div class="form-group has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="has-icon-right">
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit title from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use title from website.
</div>
{{ form.title.errors }}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use description from website.
</div>
{{ form.description.errors }}
</div>
<div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }}
<i class="form-icon"></i>
<span>Mark as unread</span>
</label>
{% csrf_token %}
{{ form.website_title }}
{{ form.website_description }}
{{ form.auto_close|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
{% if form.url.errors %}
<div class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div>
</div>
{% if request.user.profile.enable_sharing %}
<div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }}
<i class="form-icon"></i>
<span>Share</span>
</label>
<div class="form-input-hint">
Share this bookmark with other users.
</div>
{{ form.url.errors }}
</div>
{% endif %}
<br/>
<div class="form-group">
{% if auto_close %}
<input type="submit" value="Save and close" class="btn btn-primary mr-2">
{% else %}
<input type="submit" value="Save" class="btn btn-primary mr-2">
{% endif %}
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
<div class="form-input-hint bookmark-exists">
This URL is already bookmarked.
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
<div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
exist it will be
automatically created.
</div>
{{ form.tag_string.errors }}
</div>
<div class="form-group has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="has-icon-right">
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit title from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use title from website.
</div>
{{ form.title.errors }}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use description from website.
</div>
{{ form.description.errors }}
</div>
<div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }}
<i class="form-icon"></i>
<span>Mark as unread</span>
</label>
<div class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div>
</div>
{% if request.user.profile.enable_sharing %}
<div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }}
<i class="form-icon"></i>
<span>Share</span>
</label>
<div class="form-input-hint">
Share this bookmark with other users.
</div>
</div>
{% endif %}
<br/>
<div class="form-group">
{% if auto_close %}
<input type="submit" value="Save and close" class="btn btn-primary mr-2">
{% else %}
<input type="submit" value="Save" class="btn btn-primary mr-2">
{% endif %}
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
</div>
{# Replace tag input with auto-complete component #}
<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' %}')
{# Replace tag input with auto-complete component #}
<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
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">
/**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
* - Show hint if URL is already bookmarked
* - Setup buttons that allow editing of scraped website values
*/
(function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }};
function toggleLoadingIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
function updatePlaceholder(input, value) {
if (value) {
input.setAttribute('placeholder', value);
} else {
input.removeAttribute('placeholder');
}
}
function updateInput(input, value) {
input.value = value;
}
function updateCheckbox(input, value) {
input.checked = value;
}
function checkUrl() {
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
updatePlaceholder(titleInput, null);
updatePlaceholder(descriptionInput, null);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {
const metadata = data.metadata;
updatePlaceholder(titleInput, metadata.title);
updatePlaceholder(descriptionInput, metadata.description);
toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false);
// Prefill form and display hint if URL is already bookmarked
const existingBookmark = data.bookmark;
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
if (existingBookmark && !editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block';
updateInput(titleInput, existingBookmark.title);
updateInput(descriptionInput, existingBookmark.description);
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
updateCheckbox(unreadCheckbox, existingBookmark.unread);
updateCheckbox(sharedCheckbox, existingBookmark.shared);
} else {
bookmarkExistsHint.style['display'] = 'none';
}
});
}
function setupEditAutoValueButton(input) {
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
if (!editAutoValueButton) return;
editAutoValueButton.addEventListener('click', function (event) {
event.preventDefault();
input.value = input.getAttribute('placeholder');
input.focus();
input.select();
});
}
tagInput.parentElement.replaceChild(wrapper, tagInput);
</script>
<script type="application/javascript">
/**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
* - Show hint if URL is already bookmarked
* - Setup buttons that allow editing of scraped website values
*/
(function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }};
// Fetch initial website data if we have a URL, and we are not editing an existing bookmark
// For existing bookmarks we get the website metadata through hidden inputs
if (urlInput.value && !editedBookmarkId) {
checkUrl();
}
urlInput.addEventListener('input', checkUrl);
function toggleLoadingIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
// Set initial website title and description for edited bookmarks
if (editedBookmarkId) {
updatePlaceholder(titleInput, websiteTitleInput.value);
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
}
function updatePlaceholder(input, value) {
if (value) {
input.setAttribute('placeholder', value);
} else {
input.removeAttribute('placeholder');
}
}
function checkUrl() {
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
updatePlaceholder(titleInput, null);
updatePlaceholder(descriptionInput, null);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {
const metadata = data.metadata;
updatePlaceholder(titleInput, metadata.title);
updatePlaceholder(descriptionInput, metadata.description);
toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false);
// Display hint if URL is already bookmarked
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block';
editExistingBookmarkLink.href = data.bookmark.edit_url;
} else {
bookmarkExistsHint.style['display'] = 'none';
}
});
}
function setupEditAutoValueButton(input) {
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
if (!editAutoValueButton) return;
editAutoValueButton.addEventListener('click', function (event) {
event.preventDefault();
input.value = input.getAttribute('placeholder');
input.focus();
input.select();
});
}
if (urlInput.value) checkUrl();
urlInput.addEventListener('input', checkUrl);
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})();
</script>
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})();
</script>
</div>

View File

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

View File

@@ -5,61 +5,61 @@
{# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
{# Include specific theme variant based on user profile setting #}
{% if request.user.profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
{% elif request.user.profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
{% endif %}
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
{# Include specific theme variant based on user profile setting #}
{% if request.user.profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
{% elif request.user.profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
{% endif %}
</head>
<body>
<header>
{% if has_toasts %}
{% if has_toasts %}
<div class="toasts container grid-lg">
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %}
{% for toast in toast_messages %}
<div class="toast">
{{ toast.message }}
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
</div>
<div class="toast">
{{ toast.message }}
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
</div>
{% endfor %}
</form>
</div>
{% endif %}
<div class="navbar container grid-lg">
<section class="navbar-section">
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>
</section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
<section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %}
</section>
{% endif %}
<div class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>
</section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
<section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %}
</section>
{% endif %}
</div>
</div>
</header>
<div class="content container grid-lg">
{% block content %}
{% endblock %}
{% block content %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -1,93 +1,101 @@
{% load shared %}
{% htmlmin %}
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
Bookmarks
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" style="height:1rem;width:1rem;vertical-align: text-bottom;">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a>
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
</li>
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
<li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
Bookmarks
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
style="height:1rem;width:1rem;vertical-align: text-bottom;">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"/>
</svg>
</a>
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
</li>
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
<li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
</a>
<div class="dropdown dropdown-right">
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</a>
<div class="dropdown dropdown-right">
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<!-- menu component -->
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
</li>
<li>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</li>
</ul>
</div>
<!-- menu component -->
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
</li>
<li>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</li>
</ul>
</div>
</div>
{% endhtmlmin %}
<script>
// Hide mobile menu on outside click
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
// behaviour through Javascript
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
// Hide mobile menu on outside click
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
// behaviour through Javascript
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
function mobileNavMenuOutsideClickHandler(clickEvent) {
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
mobileNavMenuTrigger.blur();
}
function mobileNavMenuOutsideClickHandler(clickEvent) {
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
mobileNavMenuTrigger.blur();
}
mobileNavMenuTrigger.addEventListener('focus', function () {
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
})
mobileNavMenuTrigger.addEventListener('blur', function () {
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
})
mobileNavMenuTrigger.addEventListener('focus', function () {
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
})
mobileNavMenuTrigger.addEventListener('blur', function () {
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
})
</script>

View File

@@ -2,14 +2,14 @@
{% load bookmarks %}
{% block content %}
<div class="columns">
<section class="content-area column col-12">
<div class="content-area-header">
<h2>New bookmark</h2>
</div>
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form return_url auto_close=auto_close %}
</form>
</section>
</div>
<div class="columns">
<section class="content-area column col-12">
<div class="content-area-header">
<h2>New bookmark</h2>
</div>
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form return_url auto_close=auto_close %}
</form>
</section>
</div>
{% endblock %}

View File

@@ -1,35 +1,35 @@
{% load shared %}
<ul class="pagination">
{% if page.has_previous %}
<li class="page-item">
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<a href="#" tabindex="-1">Previous</a>
</li>
{% endif %}
{% if page.has_previous %}
<li class="page-item">
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<a href="#" tabindex="-1">Previous</a>
</li>
{% endif %}
{% for page_number in visible_page_numbers %}
{% if page_number >= 0 %}
<li class="page-item {% if page.number == page_number %}active{% endif %}">
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
</li>
{% else %}
<li class="page-item">
<span>...</span>
</li>
{% endif %}
{% endfor %}
{% if page.has_next %}
<li class="page-item">
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
</li>
{% for page_number in visible_page_numbers %}
{% if page_number >= 0 %}
<li class="page-item {% if page.number == page_number %}active{% endif %}">
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
</li>
{% else %}
<li class="page-item disabled">
<a href="#" tabindex="-1">Next</a>
</li>
<li class="page-item">
<span>...</span>
</li>
{% endif %}
{% endfor %}
{% if page.has_next %}
<li class="page-item">
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<a href="#" tabindex="-1">Next</a>
</li>
{% endif %}
</ul>

View File

@@ -1,43 +1,43 @@
<div class="search">
<form action="" method="get" role="search">
<div class="input-group">
<form action="" method="get" role="search">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ filters.query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}">
{% endif %}
</form>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}">
{% endif %}
</form>
</div>
{# Replace search input with auto-complete component #}
<script type="application/javascript">
window.addEventListener("load", function() {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const filters = {
q: '{{ filters.query }}',
user: '{{ filters.user }}',
}
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ filters.query }}',
tags: uniqueTags,
mode: '{{ mode }}',
apiClient,
filters,
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
});
window.addEventListener("load", function () {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const filters = {
q: '{{ filters.query }}',
user: '{{ filters.user }}',
}
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ filters.query }}',
tags: uniqueTags,
mode: '{{ mode }}',
apiClient,
filters,
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
});
</script>

View File

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

View File

@@ -1,35 +1,37 @@
{% load shared %}
<div class="tag-cloud">
{% htmlmin %}
<div class="tag-cloud">
{% if has_selected_tags %}
<p class="selected-tags">
{% for tag in selected_tags %}
<a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</a>
{% endfor %}
</p>
<p class="selected-tags">
{% for tag in selected_tags %}
<a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</a>
{% endfor %}
</p>
{% endif %}
<div class="unselected-tags">
{% for group in groups %}
<p class="group">
{% for tag in group.tags %}
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}
{% endfor %}
</p>
{% endfor %}
{% for group in groups %}
<p class="group">
{% for tag in group.tags %}
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}
{% endfor %}
</p>
{% endfor %}
</div>
</div>
</div>
{% endhtmlmin %}

View File

@@ -2,136 +2,170 @@
{% load widget_tweaks %}
{% block content %}
<div class="settings-page">
<div class="settings-page">
{% include 'settings/nav.html' %}
{% include 'settings/nav.html' %}
{# Profile section #}
<section class="content-area">
<h2>Profile</h2>
<p>
<a href="{% url 'change_password' %}">Change password</a>
</p>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label>
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
Machine</a>. This allows
to preserve, and later access, the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified.
</div>
</div>
<div class="form-group">
<label for="{{ form.enable_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_sharing }}
<i class="form-icon"></i> Enable bookmark sharing
</label>
<div class="form-input-hint">
Allows to share bookmarks with other users, and to view shared bookmarks.
Disabling this feature will hide all previously shared bookmarks from other users.
</div>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2">
</div>
</form>
</section>
{# Profile section #}
<section class="content-area">
<h2>Profile</h2>
<p>
<a href="{% url 'change_password' %}">Change password</a>
</p>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can
be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group">
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
{{ form.enable_favicons }}
<i class="form-icon"></i> Enable Favicons
</label>
<div class="form-input-hint">
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.
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
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.
</div>
{% if request.user.profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %}
{% if refresh_favicons_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ refresh_favicons_success_message }}
</p>
</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label>
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
Machine</a>.
This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified.
Please consider donating to the <a href="https://archive.org/donate/index.php" target="_blank"
rel="noopener">Internet Archive</a> if you make use of this feature.
</div>
</div>
<div class="form-group">
<label for="{{ form.enable_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_sharing }}
<i class="form-icon"></i> Enable bookmark sharing
</label>
<div class="form-input-hint">
Allows to share bookmarks with other users, and to view shared bookmarks.
Disabling this feature will hide all previously shared bookmarks from other users.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ update_profile_success_message }}
</p>
</div>
{% endif %}
</div>
</form>
</section>
{# Import section #}
<section class="content-area">
<h2>Import</h2>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
{% csrf_token %}
<div class="form-group">
<div class="input-group col-8 col-md-12">
<input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
</div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div>
</form>
</section>
{# Import section #}
<section class="content-area">
<h2>Import</h2>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
{% csrf_token %}
<div class="form-group">
<div class="input-group col-8 col-md-12">
<input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
</div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div>
</form>
</section>
{# Export section #}
<section class="content-area">
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
<p class="form-input-hint">
{{ export_error }}
</p>
</div>
{% endif %}
</section>
{# Export section #}
<section class="content-area">
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
<p class="form-input-hint">
{{ export_error }}
</p>
</div>
{% endif %}
</section>
{# About section #}
<section class="content-area about">
<h2>About</h2>
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ version_info }}</td>
</tr>
<tr>
<td rowspan="3" style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding#documentation"
target="_blank">Documentation</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a></td>
</tr>
</tbody>
</table>
</section>
</div>
{# About section #}
<section class="content-area about">
<h2>About</h2>
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ version_info }}</td>
</tr>
<tr>
<td rowspan="3" style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding#documentation"
target="_blank">Documentation</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a></td>
</tr>
</tbody>
</table>
</section>
</div>
{% endblock %}

View File

@@ -9,6 +9,7 @@
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>
@@ -18,5 +19,6 @@
</svg>
</a>
</li>
{% endif %}
</ul>
<br>

View File

@@ -1,3 +1,5 @@
import re
from django import template
from bookmarks import utils
@@ -48,6 +50,7 @@ def remove_from_query_param(context, **kwargs):
return query.urlencode()
@register.simple_tag(takes_context=True)
def replace_query_param(context, **kwargs):
query = context.request.GET.copy()
@@ -87,3 +90,22 @@ def humanize_relative_date(value):
if value in (None, ''):
return ''
return utils.humanize_relative_date(value)
@register.tag
def htmlmin(parser, token):
nodelist = parser.parse(('endhtmlmin',))
parser.delete_first_token()
return HtmlMinNode(nodelist)
class HtmlMinNode(template.Node):
def __init__(self, nodelist):
self.nodelist = nodelist
def render(self, context):
output = self.nodelist.render(context)
output = re.sub(r'\s+', ' ', output)
return output

View File

@@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
website_title: str = '',
website_description: str = '',
web_archive_snapshot_url: str = '',
favicon_file: str = '',
):
if not title:
title = get_random_string(length=32)
@@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
unread=unread,
shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url,
favicon_file=favicon_file,
)
bookmark.save()
for tag in tags:

View File

@@ -0,0 +1,29 @@
import importlib
import os
from unittest import mock
from django.test import TestCase
class AppOptionsTestCase(TestCase):
def setUp(self) -> None:
self.settings_module = importlib.import_module('siteroot.settings.base')
def test_empty_csrf_trusted_origins(self):
module = importlib.reload(self.settings_module)
self.assertFalse(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
@mock.patch.dict(os.environ, {'LD_CSRF_TRUSTED_ORIGINS': 'https://linkding.example.com'})
def test_single_csrf_trusted_origin(self):
module = importlib.reload(self.settings_module)
self.assertTrue(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
self.assertCountEqual(module.CSRF_TRUSTED_ORIGINS, ['https://linkding.example.com'])
@mock.patch.dict(os.environ, {'LD_CSRF_TRUSTED_ORIGINS': 'https://linkding.example.com,http://linkding.example.com'})
def test_multiple_csrf_trusted_origin(self):
module = importlib.reload(self.settings_module)
self.assertTrue(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
self.assertCountEqual(module.CSRF_TRUSTED_ORIGINS, ['https://linkding.example.com', 'http://linkding.example.com'])

View File

@@ -0,0 +1,46 @@
from unittest.mock import patch, PropertyMock
from django.test import TestCase, modify_settings
from django.urls import reverse
from bookmarks.models import User
from bookmarks.middlewares import CustomRemoteUserMiddleware
class AuthProxySupportTest(TestCase):
# Reproducing configuration from the settings logic here
# ideally this test would just override the respective options
@modify_settings(
MIDDLEWARE={'append': 'bookmarks.middlewares.CustomRemoteUserMiddleware'},
AUTHENTICATION_BACKENDS={'prepend': 'django.contrib.auth.backends.RemoteUserBackend'}
)
def test_auth_proxy_authentication(self):
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
headers = {'REMOTE_USER': user.username}
response = self.client.get(reverse('bookmarks:index'), **headers)
self.assertEqual(response.status_code, 200)
# Reproducing configuration from the settings logic here
# ideally this test would just override the respective options
@modify_settings(
MIDDLEWARE={'append': 'bookmarks.middlewares.CustomRemoteUserMiddleware'},
AUTHENTICATION_BACKENDS={'prepend': 'django.contrib.auth.backends.RemoteUserBackend'}
)
def test_auth_proxy_with_custom_header(self):
with patch.object(CustomRemoteUserMiddleware, 'header', new_callable=PropertyMock) as mock:
mock.return_value = 'Custom-User'
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
headers = {'Custom-User': user.username}
response = self.client.get(reverse('bookmarks:index'), **headers)
self.assertEqual(response.status_code, 200)
def test_auth_proxy_is_disabled_by_default(self):
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
headers = {'REMOTE_USER': user.username}
response = self.client.get(reverse('bookmarks:index'), **headers, follow=True)
self.assertRedirects(response, '/login/?next=%2Fbookmarks')

View File

@@ -40,8 +40,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
tags = bookmark.tags.order_by('name').all()
self.assertEqual(tags[0].name, 'editedtag1')
self.assertEqual(tags[1].name, 'editedtag2')
def test_should_edit_unread_state(self):
bookmark = self.setup_bookmark()
@@ -72,32 +73,43 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description')
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
website_title='website title', website_description='website description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML('<input type="text" name="url" '
'value="{0}" placeholder=" " '
'autofocus class="form-input" required '
'id="id_url">'.format(bookmark.url),
html)
self.assertInHTML(f'''
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
autofocus class="form-input" required id="id_url">
''', html)
tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML('<input type="text" name="tag_string" '
'value="{0}" autocomplete="off" '
'class="form-input" '
'id="id_tag_string">'.format(tag_string),
html)
self.assertInHTML(f'''
<input type="text" name="tag_string" value="{tag_string}"
autocomplete="off" class="form-input" id="id_tag_string">
''', html)
self.assertInHTML('<input type="text" name="title" maxlength="512" '
'autocomplete="off" class="form-input" '
'value="{0}" id="id_title">'.format(bookmark.title),
html)
self.assertInHTML(f'''
<input type="text" name="title" value="{bookmark.title}" maxlength="512" autocomplete="off"
class="form-input" id="id_title">
''', html)
self.assertInHTML('<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">{0}'
'</textarea>'.format(bookmark.description),
html)
self.assertInHTML(f'''
<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">
{bookmark.description}
</textarea>
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}">
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_description" id="id_website_description"
value="{bookmark.website_description}">
''', html)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()
@@ -172,4 +184,3 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span>
</label>
''', html, count=1)

View File

@@ -40,8 +40,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
tags = bookmark.tags.order_by('name').all()
self.assertEqual(tags[0].name, 'tag1')
self.assertEqual(tags[1].name, 'tag2')
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({'unread': True})

View File

@@ -1,4 +1,6 @@
import urllib.parse
from collections import OrderedDict
from unittest.mock import patch
from django.contrib.auth.models import User
from django.urls import reverse
@@ -6,6 +8,8 @@ from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark
from bookmarks.services import website_loader
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
@@ -353,6 +357,66 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
self.assertFalse(bookmark.is_archived)
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
bookmark_data = response.data['bookmark']
self.assertIsNone(bookmark_data)
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com',
'Scraped metadata',
'Scraped description'
)
mock_load_website_metadata.return_value = expected_metadata
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
metadata = response.data['metadata']
self.assertIsNotNone(metadata)
self.assertIsNotNone(expected_metadata.url, metadata['url'])
self.assertIsNotNone(expected_metadata.title, metadata['title'])
self.assertIsNotNone(expected_metadata.description, metadata['description'])
def test_check_returns_bookmark_if_url_is_bookmarked(self):
bookmark = self.setup_bookmark(url='https://example.com',
title='Example title',
description='Example description')
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
bookmark_data = response.data['bookmark']
self.assertIsNotNone(bookmark_data)
self.assertEqual(bookmark.id, bookmark_data['id'])
self.assertEqual(bookmark.url, bookmark_data['url'])
self.assertEqual(bookmark.title, bookmark_data['title'])
self.assertEqual(bookmark.description, bookmark_data['description'])
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
bookmark = self.setup_bookmark(url='https://example.com',
website_title='Existing title',
website_description='Existing description')
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
metadata = response.data['metadata']
mock_load_website_metadata.assert_not_called()
self.assertIsNotNone(metadata)
self.assertIsNotNone(bookmark.url, metadata['url'])
self.assertIsNotNone(bookmark.website_title, metadata['title'])
self.assertIsNotNone(bookmark.website_description, metadata['description'])
def test_can_only_access_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user)
@@ -396,3 +460,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
self.assertIsNone(response.data['bookmark'])

View File

@@ -0,0 +1,64 @@
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from django.test.utils import CaptureQueriesContext
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 BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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)
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
def test_list_bookmarks_max_queries(self):
# set up some bookmarks with associated tags
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(tags=[self.setup_tag()])
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
number_of_queries = context.final_queries
self.assertLess(number_of_queries, num_initial_bookmarks)
def test_list_archived_bookmarks_max_queries(self):
# set up some bookmarks with associated tags
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()])
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
number_of_queries = context.final_queries
self.assertLess(number_of_queries, num_initial_bookmarks)
def test_list_shared_bookmarks_max_queries(self):
# set up some bookmarks with associated tags
share_user = self.setup_user(enable_sharing=True)
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(user=share_user, shared=True, tags=[self.setup_tag()])
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
number_of_queries = context.final_queries
self.assertLess(number_of_queries, num_initial_bookmarks)

View File

@@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
</span>
''', html, count=count)
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 1)
def assertFaviconHidden(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 0)
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<img src="/static/{bookmark.favicon_file}" alt="">
''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory()
request = rf.get(url)
@@ -211,3 +222,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html)
def test_favicon_should_be_visible_when_favicons_enabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark])
self.assertFaviconVisible(html, bookmark)
def test_favicon_should_be_hidden_when_there_is_no_icon(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file='')
html = self.render_default_template([bookmark])
self.assertFaviconHidden(html, bookmark)
def test_favicon_should_be_hidden_when_favicons_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = False
profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark])
self.assertFaviconHidden(html, bookmark)

View File

@@ -5,10 +5,12 @@ from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
User = get_user_model()
@@ -18,6 +20,27 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.get_or_create_test_user()
def test_create_should_update_website_metadata(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com',
'Website title',
'Website description'
)
mock_load_website_metadata.return_value = expected_metadata
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
unread=True,
shared=True,
is_archived=True)
created_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
created_bookmark.refresh_from_db()
self.assertEqual(expected_metadata.title, created_bookmark.website_title)
self.assertEqual(expected_metadata.description, created_bookmark.website_description)
def test_create_should_update_existing_bookmark_with_same_url(self):
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
bookmark_data = Bookmark(url='https://example.com',
@@ -44,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
def test_create_should_load_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
@@ -60,6 +90,40 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_web_archive_snapshot.assert_not_called()
def test_update_should_update_website_metadata_if_url_did_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com/updated',
'Updated website title',
'Updated website description'
)
mock_load_website_metadata.return_value = expected_metadata
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.refresh_from_db()
mock_load_website_metadata.assert_called_once()
self.assertEqual(expected_metadata.title, bookmark.website_title)
self.assertEqual(expected_metadata.description, bookmark.website_description)
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_website_metadata.assert_not_called()
def test_update_should_update_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_archive_bookmark(self):
bookmark = Bookmark(
url='https://example.com',

View File

@@ -1,25 +1,52 @@
from unittest.mock import patch
import datetime
from dataclasses import dataclass
from unittest import mock
import waybackpy
from background_task.models import Task
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError
from bookmarks.models import Bookmark, UserProfile
import bookmarks.services.favicon_loader
import bookmarks.services.wayback
from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class MockWaybackMachineSaveAPI:
def __init__(self, archive_url: str):
def __init__(self, archive_url: str = 'https://example.com/created_snapshot', fail_on_save: bool = False):
self.archive_url = archive_url
self.fail_on_save = fail_on_save
def save(self):
if self.fail_on_save:
raise WaybackError
return self
class MockWaybackUrlWithSaveError:
def save(self):
raise NotImplementedError
@dataclass
class MockCdxSnapshot:
archive_url: str
datetime_timestamp: datetime.datetime
class MockWaybackMachineCDXServerAPI:
def __init__(self,
archive_url: str = 'https://example.com/newest_snapshot',
has_no_snapshot=False,
fail_loading_snapshot=False):
self.archive_url = archive_url
self.has_no_snapshot = has_no_snapshot
self.fail_loading_snapshot = fail_loading_snapshot
def newest(self):
if self.has_no_snapshot:
return None
if self.fail_loading_snapshot:
raise WaybackError
return MockCdxSnapshot(self.archive_url, datetime.datetime.now())
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
@@ -27,12 +54,14 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.enable_favicons = True
user.profile.save()
@disable_logging
def run_pending_task(self, task_function):
func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0]
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@@ -43,6 +72,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks = Task.objects.all()
for task in tasks:
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@@ -50,49 +80,130 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')) as mock_wayback_url:
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_wayback_url.assert_not_called()
def test_create_web_archive_snapshot_should_handle_wayback_save_error(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackUrlWithSaveError()):
with self.assertRaises(NotImplementedError):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_save_api.assert_not_called()
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://other.com')):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
mock_save_api.assert_not_called()
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://other.com')):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
bookmark = self.setup_bookmark()
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
bookmark = self.setup_bookmark()
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual('', bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
bookmark = self.setup_bookmark()
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual('', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
tasks._load_web_archive_snapshot_task(123)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
mock_cdx_api.assert_not_called()
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
mock_cdx_api.assert_not_called()
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
bookmark = self.setup_bookmark()
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
self.assertEqual('', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
bookmark = self.setup_bookmark()
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
self.assertEqual('', bookmark.web_archive_snapshot_url)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
@@ -109,33 +220,23 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_snapshots_should_create_snapshot_task_for_all_bookmarks_without_snapshot(self):
def test_schedule_bookmarks_without_snapshots_should_load_snapshot_for_all_bookmarks_without_snapshot(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_schedule_bookmarks_without_snapshots_should_not_update_bookmarks_with_existing_snapshot(self):
user = self.get_or_create_test_user()
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
for task in task_list:
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_web_archive_snapshot_task')
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(self):
user = self.get_or_create_test_user()
@@ -147,16 +248,11 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
for bookmark in Bookmark.objects.all().filter(owner=user):
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
for bookmark in Bookmark.objects.all().filter(owner=other_user):
self.assertEqual(bookmark.web_archive_snapshot_url, '')
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(self):
@@ -170,3 +266,157 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)
def test_load_favicon_should_create_favicon_file(self):
bookmark = self.setup_bookmark()
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.return_value = 'https_example_com.png'
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.run_pending_task(tasks._load_favicon_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
def test_load_favicon_should_update_favicon_file(self):
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.return_value = 'https_example_updated_com.png'
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.run_pending_task(tasks._load_favicon_task)
mock_load_favicon.assert_called()
bookmark.refresh_from_db()
self.assertEqual(bookmark.favicon_file, 'https_example_updated_com.png')
def test_load_favicon_should_handle_missing_bookmark(self):
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
tasks._load_favicon_task(123)
self.run_pending_task(tasks._load_favicon_task)
mock_load_favicon.assert_not_called()
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.assertEqual(Task.objects.count(), 0)
def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self):
self.user.profile.enable_favicons = False
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
tasks.schedule_bookmarks_without_favicons(user)
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
for task in task_list:
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
def test_schedule_bookmarks_without_favicons_should_only_update_user_owned_bookmarks(self):
user = self.get_or_create_test_user()
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
tasks.schedule_bookmarks_without_favicons(user)
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(self):
self.user.profile.enable_favicons = False
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
tasks.schedule_refresh_favicons(user)
self.run_pending_task(tasks._schedule_refresh_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 6)
for task in task_list:
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
def test_schedule_refresh_favicons_should_only_update_user_owned_bookmarks(self):
user = self.get_or_create_test_user()
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
tasks.schedule_refresh_favicons(user)
self.run_pending_task(tasks._schedule_refresh_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(self):
self.setup_bookmark()
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self):
self.setup_bookmark()
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(self):
self.user.profile.enable_favicons = False
self.user.profile.save()
self.setup_bookmark()
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)

View File

@@ -0,0 +1,53 @@
import importlib
from django.test import TestCase, override_settings
from django.urls import reverse
class MockUrlConf:
def __init__(self, module):
self.urlpatterns = module.urlpatterns
class ContextPathTestCase(TestCase):
def setUp(self):
self.siteroot_urls = importlib.import_module('siteroot.urls')
@override_settings(LD_CONTEXT_PATH=None)
def tearDown(self):
importlib.reload(self.siteroot_urls)
@override_settings(LD_CONTEXT_PATH='linkding/')
def test_route_with_context_path(self):
module = importlib.reload(self.siteroot_urls)
# pass mock config instead of actual module to prevent caching the
# url config in django.urls.reverse
urlconf = MockUrlConf(module)
test_cases = [
('bookmarks:index', '/linkding/bookmarks'),
('bookmarks:bookmark-list', '/linkding/api/bookmarks/'),
('login', '/linkding/login/'),
('admin:bookmarks_bookmark_changelist', '/linkding/admin/bookmarks/bookmark/'),
]
for url_name, expected_url in test_cases:
url = reverse(url_name, urlconf=urlconf)
self.assertEqual(expected_url, url)
@override_settings(LD_CONTEXT_PATH='')
def test_route_without_context_path(self):
module = importlib.reload(self.siteroot_urls)
# pass mock config instead of actual module to prevent caching the
# url config in django.urls.reverse
urlconf = MockUrlConf(module)
test_cases = [
('bookmarks:index', '/bookmarks'),
('bookmarks:bookmark-list', '/api/bookmarks/'),
('login', '/login/'),
('admin:bookmarks_bookmark_changelist', '/admin/bookmarks/bookmark/'),
]
for url_name, expected_url in test_cases:
url = reverse(url_name, urlconf=urlconf)
self.assertEqual(expected_url, url)

View File

@@ -0,0 +1,45 @@
import os
from unittest import mock
from django.test import TestCase
from bookmarks.models import User
from bookmarks.management.commands.create_initial_superuser import Command
class TestCreateInitialSuperuserCommand(TestCase):
@mock.patch.dict(os.environ, {'LD_SUPERUSER_NAME': 'john', 'LD_SUPERUSER_PASSWORD': 'password123'})
def test_create_with_password(self):
Command().handle()
self.assertEqual(1, User.objects.count())
user = User.objects.first()
self.assertEqual('john', user.username)
self.assertTrue(user.has_usable_password())
self.assertTrue(user.check_password('password123'))
@mock.patch.dict(os.environ, {'LD_SUPERUSER_NAME': 'john'})
def test_create_without_password(self):
Command().handle()
self.assertEqual(1, User.objects.count())
user = User.objects.first()
self.assertEqual('john', user.username)
self.assertFalse(user.has_usable_password())
def test_create_without_options(self):
Command().handle()
self.assertEqual(0, User.objects.count())
@mock.patch.dict(os.environ, {'LD_SUPERUSER_NAME': 'john', 'LD_SUPERUSER_PASSWORD': 'password123'})
def test_create_multiple_times(self):
Command().handle()
Command().handle()
Command().handle()
self.assertEqual(1, User.objects.count())

View File

@@ -0,0 +1,32 @@
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class ExporterPerformanceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
def test_export_max_queries(self):
# set up some bookmarks with associated tags
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(tags=[self.setup_tag()])
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.client.get(reverse('bookmarks:settings.export'),follow=True)
number_of_queries = context.final_queries
self.assertLess(number_of_queries, num_initial_bookmarks)

View File

@@ -0,0 +1,127 @@
import io
import os.path
import time
from pathlib import Path
from unittest import mock
from django.conf import settings
from django.test import TestCase
from bookmarks.services import favicon_loader
mock_icon_data = b'mock_icon'
class FaviconLoaderTestCase(TestCase):
def setUp(self) -> None:
self.ensure_favicon_folder()
self.clear_favicon_folder()
def create_mock_response(self, icon_data=mock_icon_data):
mock_response = mock.Mock()
mock_response.raw = io.BytesIO(icon_data)
return mock_response
def ensure_favicon_folder(self):
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
def clear_favicon_folder(self):
folder = Path(settings.LD_FAVICON_FOLDER)
for file in folder.iterdir():
file.unlink()
def get_icon_path(self, filename):
return Path(os.path.join(settings.LD_FAVICON_FOLDER, filename))
def icon_exists(self, filename):
return self.get_icon_path(filename).exists()
def get_icon_data(self, filename):
return self.get_icon_path(filename).read_bytes()
def count_icons(self):
files = os.listdir(settings.LD_FAVICON_FOLDER)
return len(files)
def test_load_favicon(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
# should create icon file
self.assertTrue(self.icon_exists('https_example_com.png'))
# should store image data
self.assertEqual(mock_icon_data, self.get_icon_data('https_example_com.png'))
def test_load_favicon_creates_folder_if_not_exists(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
folder = Path(settings.LD_FAVICON_FOLDER)
folder.rmdir()
self.assertFalse(folder.exists())
favicon_loader.load_favicon('https://example.com')
self.assertTrue(folder.exists())
def test_load_favicon_creates_single_icon_for_same_base_url(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
favicon_loader.load_favicon('https://example.com?foo=bar')
favicon_loader.load_favicon('https://example.com/foo')
self.assertEqual(1, self.count_icons())
self.assertTrue(self.icon_exists('https_example_com.png'))
def test_load_favicon_creates_multiple_icons_for_different_base_url(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
favicon_loader.load_favicon('https://sub.example.com')
favicon_loader.load_favicon('https://other-domain.com')
self.assertEqual(3, self.count_icons())
self.assertTrue(self.icon_exists('https_example_com.png'))
self.assertTrue(self.icon_exists('https_sub_example_com.png'))
self.assertTrue(self.icon_exists('https_other_domain_com.png'))
def test_load_favicon_caches_icons(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
mock_get.assert_called()
mock_get.reset_mock()
favicon_loader.load_favicon('https://example.com')
mock_get.assert_not_called()
def test_load_favicon_updates_stale_icon(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
icon_path = self.get_icon_path('https_example_com.png')
updated_mock_icon_data = b'updated_mock_icon'
mock_get.return_value = self.create_mock_response(icon_data=updated_mock_icon_data)
mock_get.reset_mock()
# change icon modification date so it is not stale yet
nearly_one_day_ago = time.time() - 60 * 60 * 23
os.utime(icon_path.absolute(), (nearly_one_day_ago, nearly_one_day_ago))
favicon_loader.load_favicon('https://example.com')
mock_get.assert_not_called()
# change icon modification date so it is considered stale
one_day_ago = time.time() - 60 * 60 * 24
os.utime(icon_path.absolute(), (one_day_ago, one_day_ago))
favicon_loader.load_favicon('https://example.com')
mock_get.assert_called()
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))

View File

@@ -35,13 +35,13 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertContains(response, '<title>All bookmarks</title>')
self.assertContains(response, '<description>All bookmarks</description>')
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>')
def test_all_returns_all_unarchived_bookmarks(self):
bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark(unread=True),
self.setup_bookmark(description='test description'),
self.setup_bookmark(website_description='test website description'),
self.setup_bookmark(unread=True, description='test description'),
]
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
@@ -117,7 +117,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertContains(response, '<title>Unread bookmarks</title>')
self.assertContains(response, '<description>All unread bookmarks</description>')
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>')
def test_unread_returns_unread_and_unarchived_bookmarks(self):
self.setup_bookmark(unread=False)
@@ -128,9 +128,9 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(unread=False, is_archived=True)
unread_bookmarks = [
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True, description='test description'),
self.setup_bookmark(unread=True, website_description='test website description'),
self.setup_bookmark(unread=True, description='test description'),
]
response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key]))

View File

@@ -0,0 +1,35 @@
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import FeedToken
from bookmarks.tests.helpers import BookmarkFactoryMixin
class FeedsPerformanceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0]
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
def test_all_max_queries(self):
# set up some bookmarks with associated tags
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(tags=[self.setup_tag()])
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
self.client.get(feed_url)
number_of_queries = context.final_queries
self.assertLess(number_of_queries, num_initial_bookmarks)

View File

@@ -0,0 +1,36 @@
from unittest.mock import patch
from django.db import connections
from django.test import TestCase
from bookmarks.views.settings import app_version
class HealthViewTestCase(TestCase):
def test_health_healthy(self):
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
response_body = response.json()
expected_body = {
'version': app_version,
'status': 'healthy'
}
self.assertDictEqual(response_body, expected_body)
def test_health_unhealhty(self):
with patch.object(connections['default'], 'ensure_connection') as mock_ensure_connection:
mock_ensure_connection.side_effect = Exception('Connection error')
response = self.client.get("/health")
self.assertEqual(response.status_code, 500)
response_body = response.json()
expected_body = {
'version': app_version,
'status': 'unhealthy'
}
self.assertDictEqual(response_body, expected_body)

View File

@@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
def test_schedule_favicon_loading(self):
user = self.get_or_create_test_user()
test_html = self.render_html(tags_html='')
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)

View File

@@ -270,25 +270,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.assertQueryResult(query, [owned_bookmarks])
def test_query_bookmarks_should_use_tag_projection(self):
self.setup_bookmark_search_data()
# Test projection on bookmarks with tags
query = queries.query_bookmarks(self.user, '#tag1 #tag2')
for bookmark in query:
self.assertEqual(bookmark.tag_count, 2)
self.assertEqual(bookmark.tag_string, 'tag1,tag2')
self.assertTrue(bookmark.tag_projection)
# Test projection on bookmarks without tags
query = queries.query_bookmarks(self.user, 'term2')
for bookmark in query:
self.assertEqual(bookmark.tag_count, 0)
self.assertEqual(bookmark.tag_string, None)
self.assertTrue(bookmark.tag_projection)
def test_query_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
tag = self.setup_tag()
untagged_bookmark = self.setup_bookmark()

View File

@@ -1,12 +1,13 @@
import random
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock
import requests
from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException
from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
@@ -17,6 +18,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_profile_form_data(self, overrides=None):
if not overrides:
overrides = {}
form_data = {
'theme': UserProfile.THEME_AUTO,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
'enable_sharing': False,
'enable_favicons': False,
}
return {**form_data, **overrides}
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.general'))
@@ -28,15 +43,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
def test_should_save_profile(self):
def test_update_profile(self):
form_data = {
'update_profile': '',
'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True,
'enable_favicons': True,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
self.user.profile.refresh_from_db()
@@ -46,6 +64,118 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
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.enable_sharing, form_data['enable_sharing'])
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
self.assertInHTML('''
<p class="form-input-hint">Profile updated</p>
''', html)
def test_update_profile_should_not_be_called_without_respective_form_action(self):
form_data = {
'theme': UserProfile.THEME_DARK,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
self.user.profile.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
self.assertInHTML('''
<p class="form-input-hint">Profile updated</p>
''', html, count=0)
def test_enable_favicons_should_schedule_icon_update(self):
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
# Enabling favicons schedules update
form_data = self.create_profile_form_data({
'update_profile': '',
'enable_favicons': True,
})
self.client.post(reverse('bookmarks:settings.general'), form_data)
mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user)
# No update scheduled if favicons are already enabled
mock_schedule_bookmarks_without_favicons.reset_mock()
self.client.post(reverse('bookmarks:settings.general'), form_data)
mock_schedule_bookmarks_without_favicons.assert_not_called()
# No update scheduled when disabling favicons
form_data = self.create_profile_form_data({
'enable_favicons': False,
})
self.client.post(reverse('bookmarks:settings.general'), form_data)
mock_schedule_bookmarks_without_favicons.assert_not_called()
def test_refresh_favicons(self):
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
form_data = {
'refresh_favicons': '',
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
mock_schedule_refresh_favicons.assert_called_once()
self.assertInHTML('''
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
''', html)
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
form_data = {
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
mock_schedule_refresh_favicons.assert_not_called()
self.assertInHTML('''
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
''', html, count=0)
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=1)
def test_refresh_favicons_should_not_be_visible_when_favicons_disabled_in_profile(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = False
profile.save()
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=0)
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
def test_refresh_favicons_should_not_be_visible_when_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=0)
def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general'))
@@ -59,13 +189,13 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
''', html)
def test_get_version_info_just_displays_latest_when_versions_are_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v{app_version}'})
latest_version_response_mock = Mock(status_code=200, json=lambda: {'name': f'v{app_version}'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest)')
def test_get_version_info_shows_latest_version_when_versions_are_not_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v123.0.1'})
latest_version_response_mock = Mock(status_code=200, json=lambda: {'name': f'v123.0.1'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest: 123.0.1)')
@@ -74,3 +204,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(requests, 'get', side_effect=RequestException()):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version}')
def test_get_version_info_handles_invalid_response(self):
latest_version_response_mock = Mock(status_code=403, json=lambda: {})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version)
latest_version_response_mock = Mock(status_code=200, json=lambda: {})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version)

View File

@@ -0,0 +1,84 @@
from unittest import mock
from bookmarks.services import website_loader
from django.test import TestCase
class MockStreamingResponse:
def __init__(self, num_chunks, chunk_size, insert_head_after_chunk=None):
self.chunks = []
for index in range(num_chunks):
chunk = ''.zfill(chunk_size)
self.chunks.append(chunk.encode('utf-8'))
if index == insert_head_after_chunk:
self.chunks.append('</head>'.encode('utf-8'))
def iter_content(self, **kwargs):
return self.chunks
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
pass
class WebsiteLoaderTestCase(TestCase):
def setUp(self):
# clear cached metadata before test run
website_loader.load_website_metadata.cache_clear()
def render_html_document(self, title, description):
return f'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<meta name="description" content="{description}">
</head>
<body></body>
</html>
'''
def test_load_page_returns_content(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024)
content = website_loader.load_page('https://example.com')
expected_content_size = 10 * 1024
self.assertEqual(expected_content_size, len(content))
def test_load_page_limits_large_documents(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000)
content = website_loader.load_page('https://example.com')
# Should have read six chunks, after which content exceeds the max of 5MB
expected_content_size = 6 * 1024 * 1000
self.assertEqual(expected_content_size, len(content))
def test_load_page_stops_reading_at_closing_head_tag(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000,
insert_head_after_chunk=0)
content = website_loader.load_page('https://example.com')
# Should have read first chunk, and second chunk containing closing head tag
expected_content_size = 1 * 1024 * 1000 + len('</head>')
self.assertEqual(expected_content_size, len(content))
def test_load_website_metadata(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
mock_load_page.return_value = self.render_html_document('test title', 'test description')
metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title)
self.assertEqual('test description', metadata.description)
def test_load_website_metadata_trims_title_and_description(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
mock_load_page.return_value = self.render_html_document(' test title ', ' test description ')
metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title)
self.assertEqual('test description', metadata.description)

View File

@@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import re_path
from django.urls import path, include
from django.views.generic import RedirectView
@@ -9,7 +9,7 @@ from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
app_name = 'bookmarks'
urlpatterns = [
# Redirect root to bookmarks index
url(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
# Bookmarks
path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
@@ -31,4 +31,6 @@ urlpatterns = [
# Feeds
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
# Health check
path('health', views.health, name='health')
]

View File

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

View File

@@ -73,8 +73,8 @@ def get_bookmark_view_context(request: WSGIRequest,
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
selected_tags = _get_selected_tags(tags, filters.query)
# Prefetch owner relation, this avoids n+1 queries when using the owner in templates
prefetch_related_objects(bookmarks.object_list, 'owner')
# 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

20
bookmarks/views/health.py Normal file
View File

@@ -0,0 +1,20 @@
from django.db import connections
from django.http import JsonResponse
from bookmarks.views.settings import app_version
def health(request):
code = 200
response = {
'version': app_version,
'status': 'healthy'
}
try:
connections['default'].ensure_connection()
except Exception:
response['status'] = 'unhealthy'
code = 500
return JsonResponse(response, status=code)

View File

@@ -3,8 +3,10 @@ import time
from functools import lru_cache
import requests
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render
from django.urls import reverse
@@ -12,7 +14,7 @@ from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
logger = logging.getLogger(__name__)
@@ -27,24 +29,48 @@ except Exception as exc:
@login_required
def general(request):
if request.method == 'POST':
form = UserProfileForm(request.POST, instance=request.user.profile)
if form.is_valid():
form.save()
else:
form = UserProfileForm(instance=request.user.profile)
profile_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
update_profile_success_message = None
refresh_favicons_success_message = None
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash())
if request.method == 'POST':
if 'update_profile' in request.POST:
profile_form = update_profile(request)
update_profile_success_message = 'Profile updated'
if 'refresh_favicons' in request.POST:
tasks.schedule_refresh_favicons(request.user)
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
if not profile_form:
profile_form = UserProfileForm(instance=request.user.profile)
return render(request, 'settings/general.html', {
'form': form,
'form': profile_form,
'enable_refresh_favicons': enable_refresh_favicons,
'update_profile_success_message': update_profile_success_message,
'refresh_favicons_success_message': refresh_favicons_success_message,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'version_info': version_info,
})
def update_profile(request):
user = request.user
profile = user.profile
favicons_were_enabled = profile.enable_favicons
form = UserProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
if profile.enable_favicons and not favicons_were_enabled:
tasks.schedule_bookmarks_without_favicons(request.user)
return form
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):
@@ -53,7 +79,8 @@ def get_version_info(ttl_hash=None):
latest_version_url = 'https://api.github.com/repos/sissbruecker/linkding/releases/latest'
response = requests.get(latest_version_url, timeout=5)
json = response.json()
latest_version = json['name'][1:]
if response.status_code == 200 and 'name' in json:
latest_version = json['name'][1:]
except requests.exceptions.RequestException:
pass
@@ -73,7 +100,7 @@ def get_ttl_hash(seconds=3600):
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")
application_url = request.build_absolute_uri(reverse('bookmarks:new'))
api_token = Token.objects.get_or_create(user=request.user)[0]
feed_token = FeedToken.objects.get_or_create(user=request.user)[0]
all_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.all', args=[feed_token.key]))
@@ -114,7 +141,9 @@ def bookmark_import(request):
def bookmark_export(request):
# noinspection PyBroadException
try:
bookmarks = query_bookmarks(request.user, '')
bookmarks = list(query_bookmarks(request.user, ''))
# Prefetch tags to prevent n+1 queries
prefetch_related_objects(bookmarks, 'tags')
file_content = exporter.export_netscape_html(bookmarks)
response = HttpResponse(content_type='text/plain; charset=UTF-8')

View File

@@ -5,11 +5,15 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
# Create data folder if it does not exist
mkdir -p data
# Create favicon folder if it does not exist
mkdir -p data/favicons
# Run database migration
python manage.py migrate
# Generate secret key file if it does not exist
python manage.py generate_secret_key
# Create initial superuser if defined in options / environment variables
python manage.py create_initial_superuser
# Ensure the DB folder is owned by the right user
chown -R www-data: /etc/linkding/data
@@ -20,4 +24,4 @@ if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then
fi
# Start uwsgi server
uwsgi --http :$LD_SERVER_PORT uwsgi.ini
exec uwsgi --http :$LD_SERVER_PORT uwsgi.ini

View File

@@ -49,6 +49,7 @@ Example response:
"website_description": "Website description",
"is_archived": false,
"unread": false,
"shared": false,
"tag_names": [
"tag1",
"tag2"
@@ -97,6 +98,7 @@ Example payload:
"description": "Example description",
"is_archived": false,
"unread": false,
"shared": false,
"tag_names": [
"tag1",
"tag2"

View File

@@ -25,6 +25,22 @@ All options need to be defined as environment variables in the environment that
## List of options
### `LD_SUPERUSER_NAME`
Values: `String` | Default = None
When set, creates an initial superuser with the specified username when starting the container.
Does nothing if the user already exists.
See [`LD_SUPERUSER_PASSWORD`](#ld_superuser_password) on how to configure the respective password.
### `LD_SUPERUSER_PASSWORD`
Values: `String` | Default = None
The password for the initial superuser.
When left undefined, the superuser will be created without a usable password, which means the user can not authenticate using credentials / through the login form, and can only be authenticated using proxy authentication (see [`LD_ENABLE_AUTH_PROXY`](#ld_enable_auth_proxy)).
### `LD_DISABLE_BACKGROUND_TASKS`
Values: `True`, `False` | Default = `False`
@@ -50,4 +66,98 @@ Configures the request timeout in the uwsgi application server. This can be usef
Values: Valid port number | Default = `9090`
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.
### `LD_CONTEXT_PATH`
Values: `String` | Default = None
Allows configuring the context path of the website. Useful for setting up Nginx reverse proxy.
The context path must end with a slash. For example: `linkding/`
### `LD_ENABLE_AUTH_PROXY`
Values: `True`, `False` | Default = `False`
Enables support for authentication proxies such as Authelia.
This effectively disables credentials-based authentication and instead authenticates users if a specific request header contains a known username.
You must make sure that your proxy (nginx, Traefik, Caddy, ...) forwards this header from your auth proxy to linkding. Check the documentation of your auth proxy and your reverse proxy on how to correctly set this up.
Note that this automatically creates new users in the database if they do not already exist.
Enabling this setting also requires configuring the following options:
- `LD_AUTH_PROXY_USERNAME_HEADER` - The name of the request header that the auth proxy passes to the proxied application (linkding in this case), so that the application can identify the user.
Check the documentation of your auth proxy to get this information.
Note that the request headers are rewritten in linkding: all HTTP headers are prefixed with `HTTP_`, all letters are in uppercase, and dashes are replaced with underscores.
For example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_AUTH_PROXY_USERNAME_HEADER` needs to be configured as `HTTP_REMOTE_USER`.
- `LD_AUTH_PROXY_LOGOUT_URL` - The URL that linkding should redirect to after a logout.
By default, the logout redirects to the login URL, which means the user will be automatically authenticated again.
Instead, you might want to configure the logout URL of the auth proxy here.
### `LD_CSRF_TRUSTED_ORIGINS`
Values: `String` | Default = None
List of trusted origins / host names to allow for `POST` requests, for example when logging in, or saving bookmarks.
For these type of requests, the `Origin` header must match the `Host` header, otherwise the request will fail with a `403` status code, and the message `CSRF verification failed.`
This option allows to declare a list of trusted origins that will be accepted even if the headers do not match. This can be the case when using a reverse proxy that rewrites the `Host` header, such as Nginx.
For example, to allow requests to https://linkding.mydomain.com, configure the setting to `https://linkding.mydomain.com`.
Note that the setting **must** include the correct protocol (`https` or `http`), and **must not** include the application / context path.
Multiple origins can be specified by separating them with a comma (`,`).
This setting is adopted from the Django framework used by linkding, more information on the setting is available in the [Django documentation](https://docs.djangoproject.com/en/4.0/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS).
### `LD_DB_ENGINE`
Values: `postgres` or `sqlite` | Default = `sqlite`
Database engine used by linkding to store data.
Currently, linkding supports SQLite and PostgreSQL.
By default, linkding uses SQLite, for which you don't need to configure anything.
All the other database variables below are only required for configured PostgresSQL.
### `LD_DB_DATABASE`
Values: `String` | Default = `linkding`
The name of the database.
### `LD_DB_USER`
Values: `String` | Default = `linkding`
The name of the user to connect to the database server.
### `LD_DB_PASSWORD`
Values: `String` | Default = None
The password of the user to connect to the database server.
The password must be configured when using a database other than SQLite, there is no default value.
### `LD_DB_HOST`
Values: `String` | Default = `localhost`
The hostname or IP of the database server.
### `LD_DB_PORT`
Values: `Integer` | Default = None
The port of the database server.
Should use the default port if left empty, for example `5432` for PostgresSQL.
### `LD_FAVICON_PROVIDER`
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
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.
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.
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.
See the default URL for an example.

View File

@@ -22,17 +22,17 @@ For more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-and
## Using HTTP Shortcuts app on Android
**Note** This allows you to share URL from any app to bookmark it to linkding
**Note** This allows you to share URL from any app to tag and bookmark it to linkding
- Install HTTP Shortcuts from [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts) or [F-Droid](https://f-droid.org/en/packages/ch.rmy.android.http_shortcuts/).
- Download [linkding_shortcut.json](/docs/linkding_shortcut.json) from this repository.
- Copy the URL of [linkding_shortcut.json](https://raw.githubusercontent.com/sissbruecker/linkding/master/docs/linkding_shortcut.json).
- Open HTTP Shortcuts, tap the 3-dot-button at the top-right corner, tap `Import/Export`, then tap `Import from file`.
- Open HTTP Shortcuts, tap the 3-dot-button at the top-right corner, tap `Import/Export`, then tap `Import from URL`.
- Select the json file you downloaded earlier, go back, tap the 3-dot-button again, then tap `Variables`.
- Paste the URL you copied earlier, tap OK, go back, tap the 3-dot-button again, then tap `Variables`.
- Edit the `values` of `linkding_instance`, `linkding_tag` and `linkding_api_token`.
- Edit the `values` of `linkding_instance` and `linkding_api_key`.
Try using share button on an app, a new item `Send to...` should appear on the share sheet. You can also manually share by tapping the shortcut inside the HTTP Shortcuts app itself.

View File

@@ -1,59 +1,95 @@
{
"categories": [
{
"id": "8f4299d4-4c30-4a8e-a3f9-c90694011713",
"id": "e260b423-db01-4743-a671-2cd38594c63c",
"layoutType": "wide_grid",
"name": "Shortcuts",
"shortcuts": [
{
"bodyContent": "{ \"url\": \"{{b2953f61-b302-4c79-b90d-39858a06d9a6}}\", \"tag_names\": [ \"{{c360f61f-ce17-47b4-bea3-1d8c3913ca52}}\" ] }",
"bodyContent": "{{7b26d228-4ad6-4b1c-8b7b-076dc03385cc}}",
"codeOnPrepare": "const sharedValue \u003d getVariable(\u0027text_and_url\u0027)\nconst matches \u003d sharedValue.match(/\\bhttps?:\\/\\/\\S+/gi);\nconst url \u003d matches[0];\nsetVariable(\u0027cleaned_url\u0027, url);",
"contentType": "application/json",
"description": "Bookmark to linkding",
"description": "bookmark link",
"headers": [
{
"id": "d235f7b4-fce2-41f4-a00f-72d5fde9e4b9",
"id": "b66dd9b9-13e8-4802-b527-6e32f3980f4b",
"key": "Authorization",
"value": "Token {{6a739a16-d16d-4a06-93a5-3457da3c3d20}}"
"value": "Token {{908e3a30-ae82-400d-93c8-561c36d11d6d}}"
}
],
"iconName": "flat_grey_ribbon",
"id": "1e047d02-a4a3-4cad-b4cc-123cc16c8398",
"launcherShortcut": true,
"iconName": "flat_grey_pin",
"id": "871c3219-9e9f-46bb-8a7f-78f1496f78fc",
"method": "POST",
"name": "Linkding",
"quickSettingsTileShortcut": true,
"responseHandling": {
"failureOutput": "simple",
"id": "61fa9fc3-8b7a-47ce-b43c-f24618a65e1e",
"uiType": "toast"
},
"url": "{{ea2db14b-b9ca-45d8-8555-403271a38f5a}}/api/bookmarks/"
"url": "{{26253fe2-d202-4ce8-acd1-55c1ad3ae7d1}}/api/bookmarks/"
}
]
}
],
"variables": [
{
"id": "ea2db14b-b9ca-45d8-8555-403271a38f5a",
"id": "26253fe2-d202-4ce8-acd1-55c1ad3ae7d1",
"key": "linkding_instance",
"value": "https://your.instance.tld.without.slashed.end"
"value": "https://your.linkding.host.no.slashed.end"
},
{
"flags": 1,
"id": "b2953f61-b302-4c79-b90d-39858a06d9a6",
"key": "linkding_add_url",
"title": "Enter URL",
"id": "a3c8efa2-3e3a-4bb4-8919-3e831f95fe6a",
"jsonEncode": true,
"key": "linkding_tag",
"message": "Comma separated",
"title": "One or more tags",
"type": "text"
},
{
"id": "c360f61f-ce17-47b4-bea3-1d8c3913ca52",
"key": "linkding_tag",
"value": "single-tag"
"id": "908e3a30-ae82-400d-93c8-561c36d11d6d",
"key": "linkding_api_key",
"value": "your_api_key_here"
},
{
"id": "6a739a16-d16d-4a06-93a5-3457da3c3d20",
"key": "linkding_api_token",
"value": "your_token_from_integrations_tab"
"id": "d76696e7-1ee1-4d98-b6f9-b570ec69ef40",
"key": "cleaned_url"
},
{
"flags": 1,
"id": "da66cdad-8118-4a87-9581-4db33852b610",
"key": "text_and_url",
"message": "Any text that contains one URL",
"title": "URL",
"type": "text"
},
{
"data": "{\"select\":{\"multi_select\":\"false\",\"separator\":\",\"}}",
"id": "7b26d228-4ad6-4b1c-8b7b-076dc03385cc",
"key": "tag_yes_no_default",
"options": [
{
"id": "9365e43e-0572-4621-ac06-caec1ccff09d",
"label": "Tagged",
"value": "{{5be61e61-d8f5-475b-b1b1-88ddaebf8fd5}}"
},
{
"id": "9f1caeaf-af57-42b4-8b10-4391354ad0f0",
"label": "Untagged and unread",
"value": "{{71ac9c4d-c03e-4b6f-ad75-9c112a591c50}}"
}
],
"title": "Tagged or unread?",
"type": "select"
},
{
"id": "5be61e61-d8f5-475b-b1b1-88ddaebf8fd5",
"key": "request_body_tagged",
"value": "{ \"url\": \"{{d76696e7-1ee1-4d98-b6f9-b570ec69ef40}}\", \"tag_names\": [ \"{{a3c8efa2-3e3a-4bb4-8919-3e831f95fe6a}}\" ] }"
},
{
"id": "71ac9c4d-c03e-4b6f-ad75-9c112a591c50",
"key": "request_body_untagged",
"value": "{ \"url\": \"{{d76696e7-1ee1-4d98-b6f9-b570ec69ef40}}\", \"unread\": true }"
}
],
"version": 45
}
"version": 56
}

View File

@@ -1,5 +1,13 @@
# Troubleshooting
## Login fails with `403 CSRF verfication failed`
This can be the case when using a reverse proxy that rewrites the `Host` header, such as Nginx.
Since linkding version 1.15, the application includes a CSRF check that verifies that the `Origin` request header matches the `Host` header.
If the `Host` header is modified by the reverse proxy then this check fails.
To fix this, check the [reverse proxy setup documentation](../README.md#reverse-proxy-setup) on how to configure header forwarding for your proxy server, or alternatively configure the [`LD_CSRF_TRUSTED_ORIGINS` option](Options.md#LD_CSRF_TRUSTED_ORIGINS) to the URL from which you are accessing your linkding instance.
## Import fails with `502 Bad Gateway`
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "linkding",
"version": "1.11.1",
"version": "1.16.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "linkding",
"version": "1.11.1",
"version": "1.16.0",
"license": "ISC",
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.2",
@@ -435,9 +435,9 @@
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
},
"node_modules/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -995,9 +995,9 @@
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}

View File

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

View File

@@ -1,25 +1,25 @@
asgiref==3.4.1
beautifulsoup4==4.7.1
certifi==2019.6.16
charset-normalizer==2.0.4
asgiref==3.5.2
beautifulsoup4==4.11.1
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
confusable-homoglyphs==3.2.0
Django==3.2.14
django-background-tasks==1.2.5
django-compat==1.0.15
Django==4.1.2
django-generate-secret-key==1.0.2
django-picklefield==3.0.1
django-registration==3.2
django-sass-processor==1.0.1
django-widget-tweaks==1.4.8
djangorestframework==3.12.4
idna==2.8
python-dateutil==2.8.1
pytz==2021.1
requests==2.26.0
soupsieve==1.9.2
django-registration==3.3
django-sass-processor==1.2.1
django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7
djangorestframework==3.13.1
idna==3.3
psycopg2==2.9.5
python-dateutil==2.8.2
pytz==2022.2.1
requests==2.28.1
soupsieve==2.3.2.post1
sqlparse==0.4.2
supervisor==4.2.2
supervisor==4.2.4
typing-extensions==3.10.0.0
urllib3==1.26.6
uWSGI==2.0.18
urllib3==1.26.11
uWSGI==2.0.20
waybackpy==3.0.6

View File

@@ -1,31 +1,31 @@
asgiref==3.4.1
beautifulsoup4==4.7.1
certifi==2019.6.16
charset-normalizer==2.0.4
asgiref==3.5.2
beautifulsoup4==4.11.1
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
confusable-homoglyphs==3.2.0
coverage==5.5
Django==3.2.14
django-appconf==1.0.4
django-background-tasks==1.2.5
django-compat==1.0.15
django-compressor==2.4.1
django-debug-toolbar==3.2.1
Django==4.1.2
django-appconf==1.0.5
django-compressor==4.1
django-debug-toolbar==3.6.0
django-generate-secret-key==1.0.2
django-picklefield==3.0.1
django-registration==3.2
django-sass-processor==1.0.1
django-widget-tweaks==1.4.8
djangorestframework==3.12.4
idna==2.8
django-registration==3.3
django-sass-processor==1.2.1
django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7
djangorestframework==3.13.1
idna==3.3
libsass==0.21.0
python-dateutil==2.8.1
pytz==2021.1
rcssmin==1.0.6
requests==2.26.0
rjsmin==1.1.0
psycopg2-binary==2.9.5
python-dateutil==2.8.2
pytz==2022.2.1
rcssmin==1.1.0
requests==2.28.1
rjsmin==1.2.0
six==1.16.0
soupsieve==1.9.2
soupsieve==2.3.2.post1
sqlparse==0.4.2
typing-extensions==3.10.0.0
urllib3==1.26.6
urllib3==1.26.11
waybackpy==3.0.6

View File

@@ -11,7 +11,8 @@ export default {
sourcemap: true,
format: 'iife',
name: 'linkding',
file: 'build/bundle.js'
// Generate bundle in static folder to that it is picked up by Django static files finder
file: 'bookmarks/static/bundle.js'
},
plugins: [
svelte({

View File

@@ -79,16 +79,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
WSGI_APPLICATION = 'siteroot.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
@@ -107,9 +97,12 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = '/bookmarks'
LOGOUT_REDIRECT_URL = '/login'
# Website context path.
LD_CONTEXT_PATH = os.getenv('LD_CONTEXT_PATH', '')
LOGIN_URL = '/' + LD_CONTEXT_PATH + 'login'
LOGIN_REDIRECT_URL = '/' + LD_CONTEXT_PATH + 'bookmarks'
LOGOUT_REDIRECT_URL = '/' + LD_CONTEXT_PATH + 'login'
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
@@ -127,7 +120,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = '/' + LD_CONTEXT_PATH + 'static/'
# Collect static files in static folder
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
@@ -135,7 +128,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# Turn off SASS compilation by default
SASS_PROCESSOR_ENABLED = False
# Location where generated CSS files are saved
SASS_PROCESSOR_ROOT = os.path.join(BASE_DIR, 'tmp', 'build', 'styles')
SASS_PROCESSOR_ROOT = os.path.join(BASE_DIR, 'bookmarks', 'static')
# Add SASS preprocessor finder to resolve generated CSS
STATICFILES_FINDERS = [
@@ -144,10 +137,10 @@ STATICFILES_FINDERS = [
'sass_processor.finders.CssFinder',
]
# Include SASS styles into static path, otherwise they can not be found by the SASS preprocessor
# Enable SASS processor to find custom folder for SCSS sources through static file finders
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'build'),
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
os.path.join(BASE_DIR, 'data', 'favicons'),
]
# REST framework
@@ -179,3 +172,60 @@ MAX_ATTEMPTS = 5
# specced systems like Raspberries. Should be OK as tasks are not time critical.
BACKGROUND_TASK_RUN_ASYNC = True
BACKGROUND_TASK_ASYNC_THREADS = 2
# Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv('LD_ENABLE_AUTH_PROXY', False) in (True, 'True', '1')
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv('LD_AUTH_PROXY_USERNAME_HEADER', 'REMOTE_USER')
LD_AUTH_PROXY_LOGOUT_URL = os.getenv('LD_AUTH_PROXY_LOGOUT_URL', None)
if LD_ENABLE_AUTH_PROXY:
# Add middleware that automatically authenticates requests that have a known username
# in the LD_AUTH_PROXY_USERNAME_HEADER request header
MIDDLEWARE.append('bookmarks.middlewares.CustomRemoteUserMiddleware')
# Configure auth backend that does not require a password credential
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.RemoteUserBackend',
]
# Configure logout URL
if LD_AUTH_PROXY_LOGOUT_URL:
LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL
# CSRF trusted origins
trusted_origins = os.getenv('LD_CSRF_TRUSTED_ORIGINS', '')
if trusted_origins:
CSRF_TRUSTED_ORIGINS = trusted_origins.split(',')
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
LD_DB_ENGINE = os.getenv('LD_DB_ENGINE', 'sqlite')
LD_DB_HOST = os.getenv('LD_DB_HOST', 'localhost')
LD_DB_DATABASE = os.getenv('LD_DB_DATABASE', 'linkding')
LD_DB_USER = os.getenv('LD_DB_USER', 'linkding')
LD_DB_PASSWORD = os.getenv('LD_DB_PASSWORD', None)
LD_DB_PORT = os.getenv('LD_DB_PORT', None)
if LD_DB_ENGINE == 'postgres':
default_database = {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': LD_DB_DATABASE,
'USER': LD_DB_USER,
'PASSWORD': LD_DB_PASSWORD,
'HOST': LD_DB_HOST,
'PORT': LD_DB_PORT,
}
else:
default_database = {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
}
DATABASES = {
'default': default_database
}
# Favicons
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON'
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')

View File

@@ -25,7 +25,7 @@ LOGGING = {
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '{levelname} {message}',
'format': '{levelname} {asctime} {module}: {message}',
'style': '{',
},
},
@@ -44,12 +44,7 @@ LOGGING = {
'level': 'ERROR', # Set to DEBUG to log all SQL calls
'handlers': ['console'],
},
'bookmarks.services.tasks': { # Log task output
'level': 'DEBUG',
'handlers': ['console'],
'propagate': False,
},
'bookmarks.services.importer': { # Log importer debug output
'bookmarks': { # Log importer debug output
'level': 'DEBUG',
'handlers': ['console'],
'propagate': False,

View File

@@ -28,6 +28,35 @@ if host_name:
else:
ALLOWED_HOSTS = ['*']
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '{asctime} {levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple'
}
},
'root': {
'handlers': ['console'],
'level': 'WARN',
},
'loggers': {
'bookmarks': {
'level': 'INFO',
'handlers': ['console'],
'propagate': False,
}
}
}
# Import custom settings
# noinspection PyUnresolvedReferences
from .custom import *

View File

@@ -13,6 +13,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.contrib.auth import views as auth_views
from django.urls import path, include
@@ -30,6 +31,9 @@ urlpatterns = [
path('', include('bookmarks.urls')),
]
if settings.LD_CONTEXT_PATH:
urlpatterns = [path(settings.LD_CONTEXT_PATH, include(urlpatterns))]
if DEBUG:
import debug_toolbar

View File

@@ -5,6 +5,7 @@ loglevel=info
[program:jobs]
user=www-data
command=sh background-tasks-wrapper.sh
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stdout_logfile=background_tasks.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=5
redirect_stderr=true

View File

@@ -1,8 +1,8 @@
[uwsgi]
chdir = /etc/linkding
module = siteroot.wsgi:application
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
static-map = /static=static
static-map = /static=data/favicons
processes = 2
threads = 2
pidfile = /tmp/linkding.pid
@@ -11,6 +11,12 @@ stats = 127.0.0.1:9191
uid = www-data
gid = www-data
buffer-size = 8192
die-on-term = true
if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static
static-map = /%(_)static=data/favicons
endif =
if-env = LD_REQUEST_TIMEOUT
http-timeout = %(_)

View File

@@ -1 +1 @@
1.13.0
1.17.0