Compare commits

...

74 Commits

Author SHA1 Message Date
Sascha Ißbrücker
f9496e2fe0 Bump version 2023-08-23 10:57:09 +02:00
Sascha Ißbrücker
62c40d1b7b Update cached styles and scripts after version change (#510) 2023-08-23 10:54:25 +02:00
Sascha Ißbrücker
e076747f85 Update CHANGELOG.md 2023-08-22 08:51:54 +02:00
Sascha Ißbrücker
f071423f1e Bump version 2023-08-22 07:51:08 +02:00
Sascha Ißbrücker
be789ea9e6 Avoid page reload when triggering actions in bookmark list (#506)
* Extract bookmark view contexts

* Implement basic partial updates for bookmark list and tag cloud

* Refactor confirm button JS into web component

* Refactor bulk edit JS into web component

* Refactor tag autocomplete JS into web component

* Refactor bookmark page JS into web component

* Refactor global shortcuts JS into web component

* Update tests

* Add E2E test for partial updates

* Add partial updates for archived bookmarks

* Add partial updates for shared bookmarks

* Cleanup helpers

* Improve naming in bulk edit

* Refactor shared components into behaviors

* Refactor bulk edit components into behaviors

* Refactor bookmark list components into behaviors

* Update tests

* Combine all scripts into bundle

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

* Add support for PRIVATE attribute in export

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

* Add domain placeholder for favicon providers

* Fix favicon loader to handle streaming response

* Handle different mime types for favicons

* Use 32px size by default

* Update documentation

* Skip mime-type test for now

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

* Allow unauthenticated access to shared bookmarks API

* Link shared bookmarks in unauthenticated layout

* Add public sharing setting

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

* Disable public sharing if sharing is disabled

* Show specific helper text when public sharing is enabled

* Fix tests

* Add more tests

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-29 10:37:10 +02:00
dependabot[bot]
b05bf2534c Bump certifi from 2022.12.7 to 2023.7.22 (#497)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.12.7 to 2023.7.22.
- [Commits](https://github.com/certifi/python-certifi/compare/2022.12.07...2023.07.22)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-29 10:32:51 +02:00
Sascha Ißbrücker
86a39e0433 Remove padding from multiline code blocks 2023-05-31 17:53:04 +02:00
Sascha Ißbrücker
4220ea0b4c Fix website loader content encoding detection (#482) 2023-05-30 22:04:54 +02:00
Sascha Ißbrücker
5d48c64b2b Enable WAL to avoid locked databases (#480) 2023-05-30 09:41:53 +02:00
acbgbca
424df155d8 Allow passing title and description to new bookmark form (#479)
* Added ability to set title and description #118

* Updated bookmarklet to pass site title #118

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-23 21:21:47 +02:00
acbgbca
cd66dcee7b Added Apple web-app meta tag #358 (#359)
* Added Apple web-app meta tag #358

* Added manifest file for web app

* Changed manifest to use template #358

* Small tweaks, add tests

---------

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

* Fixed container creation

* Added DevContainer detail to readme

* Ignoring dev container files

* Added playwright deps for tests

* Removed playwright installation #473
2023-05-21 13:35:00 +02:00
Sascha Ißbrücker
b28fc05d06 Update asset 2023-05-21 10:30:20 +02:00
Sascha Ißbrücker
17ab203f4f Document keyboard shortcuts 2023-05-20 21:08:04 +02:00
Sascha Ißbrücker
a06f9035cf Update README 2023-05-20 20:01:58 +02:00
Matt Sephton
5f28e87877 Update README.md to add Postman collection (#476)
I notice that for some time the list has not had items added in alphabetical order. I have not reordered any items.
2023-05-20 17:44:19 +02:00
Sascha Ißbrücker
f2ad826b11 Update CHANGELOG.md 2023-05-20 13:17:58 +02:00
Sascha Ißbrücker
047d3be1b5 Bump version 2023-05-20 13:02:19 +02:00
Sascha Ißbrücker
43115fd8f2 Add notes to bookmarks (#472)
* Add basic bookmark notes

* Add bookmark list JS to shared bookmarks page

* Allow testing through ngrok

* Improve CSS

* Set notes through API

* Improve notes editing

* Improve notes icon

* Remove transitions for now

* Update keyboard shortcut

* Add bookmark list tests

* Add setting for showing notes permanently

* Add test for toggling notes

* Update API docs

* Allow searching for notes content

* Skip test
2023-05-20 11:54:26 +02:00
Sascha Ißbrücker
67ee896a46 Update CHANGELOG.md 2023-05-18 12:37:01 +02:00
Sascha Ißbrücker
fd3070c6f3 Bump version 2023-05-18 11:15:30 +02:00
bah0
bc374e90a2 Add option to display URL below title (#365)
* Add feature to display URL below title

* updates pre-merging

* Bookmark URL Tests & solving pending migration

* cleanup after rebase

* add test for updating setting

---------

Co-authored-by: Bahadir Parmaksiz <bahadir.parmaksiz@tmconnected.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-05-18 10:18:15 +02:00
François Ménabé
a94eb5f85a Allow to log real client ip in logs when using a reverse proxy (#398)
* Allow to log real client ip in logs when using a reverse proxy

* rearrange options

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2023-05-18 09:34:55 +02:00
Paul Lockaby
d1819c6503 Add database options (#406)
* adding support for database connection options

* a better default
2023-05-18 09:31:13 +02:00
Daniel Henning
353ba433f0 Prevent zoom-in after focusing an input on small viewports on iOS devices (#440)
* base.scss: Prevent zoom-in on focusing inputs on small viewports

Adding a media query which sets the font-size for `.form-input` inputs
to 1rem. This aims to prevent the zoom-in on small viewports on iOS
devics which automatically zoom-in a website if the font-size in a
focused input is smaller than 16px.

* Update bookmarks/styles/base.scss

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2023-05-18 09:24:55 +02:00
Sascha Ißbrücker
3af4e07eb6 Allow searching for tags without hash character (#449)
* Allow searching for tags without hash character

* Allow removing selected tags without hash

* Add more tests
2023-05-18 09:06:22 +02:00
dependabot[bot]
e9061f373a Bump sqlparse from 0.4.2 to 0.4.4 (#455)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.2 to 0.4.4.
- [Release notes](https://github.com/andialbrecht/sqlparse/releases)
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.2...0.4.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-18 08:49:31 +02:00
dependabot[bot]
f87398742a Bump django from 4.1.7 to 4.1.9 (#466)
Bumps [django](https://github.com/django/django) from 4.1.7 to 4.1.9.
- [Commits](https://github.com/django/django/compare/4.1.7...4.1.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-18 08:49:03 +02:00
Andrew Moscardino
81dc19958c Add LinkThing iOS app to community section (#446)
I've released an iOS app for linkding called LinkThing. This update adds a link to it under the Community section of the readme
2023-03-22 15:21:55 +01:00
Sascha Ißbrücker
5049ff14cf Make search case-insensitive on Postgres (#432) 2023-02-20 22:49:08 +01:00
Sascha Ißbrücker
f9ab3d1f44 Update CHANGELOG.md 2023-02-18 20:37:30 +01:00
Sascha Ißbrücker
b89e150088 Bump version 2023-02-18 19:02:38 +01:00
Josh Dick
d17801ba84 Disable autocapitalization for tag input form (#395)
* Disable autocapitalization for tag input form

* Disable autocapitalize in tag auto complete

* Fix test

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-02-18 18:51:31 +01:00
mrex
7b52663383 fix: make health check in Dockerfile honor context path setting (#407) 2023-02-18 18:36:57 +01:00
dependabot[bot]
0c86587b5d Bump django from 4.1.2 to 4.1.7 (#427)
Bumps [django](https://github.com/django/django) from 4.1.2 to 4.1.7.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/4.1.2...4.1.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-18 18:26:42 +01:00
Sascha Ißbrücker
74134d3896 Escape texts in exported HTML (#429) 2023-02-18 18:25:54 +01:00
Sascha Ißbrücker
89a9271c71 Update CHANGELOG.md 2023-01-22 15:24:23 +01:00
Sascha Ißbrücker
794b6d8932 Bump version 2023-01-22 14:15:50 +01:00
Sascha Ißbrücker
6b4664117b Fix favicon being cleared by web archive snapshot task (#405) 2023-01-22 14:07:06 +01:00
Sascha Ißbrücker
621b497dc6 Add basic E2E test setup 2023-01-22 00:47:47 +01:00
Sascha Ißbrücker
4bb05f811b Update CHANGELOG.md 2023-01-21 17:15:28 +01:00
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
148 changed files with 6022 additions and 1969 deletions

View File

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

View File

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

View File

@@ -27,3 +27,23 @@ 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=
# Any additional options to pass to the database (default: {})
LD_DB_OPTIONS=

2
.gitattributes vendored Normal file
View File

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

View File

@@ -3,22 +3,48 @@ name: linkding CI
on: [push]
jobs:
run_tests:
name: Run Django Tests
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 14
- name: Install Python dependencies
run: pip install -r requirements.txt
node-version: 18
- name: Install Node dependencies
run: npm install
- name: Setup Python environment
run: pip install -r requirements.txt
- name: Run tests
run: python manage.py test
run: python manage.py test bookmarks.tests
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install Node dependencies
run: npm install
- name: Setup Python environment
run: |
pip install -r requirements.txt
playwright install chromium
- name: Run build
run: |
npm run build
python manage.py compilescss
python manage.py collectstatic --ignore=*.scss
- name: Run tests
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"

View File

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

View File

@@ -1,5 +1,160 @@
# Changelog
## v1.20.0 (22/08/2023)
### What's Changed
* Add option to share bookmarks publicly by @sissbruecker in https://github.com/sissbruecker/linkding/pull/503
* Various improvements to favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/504
* Add support for PRIVATE flag in import and export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/505
* Avoid page reload when triggering actions in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/506
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.1...v1.20.0
---
## v1.19.1 (29/07/2023)
### What's Changed
* Add Postman Collection to Community section of README by @gingerbeardman in https://github.com/sissbruecker/linkding/pull/476
* Added Dev Container support by @acbgbca in https://github.com/sissbruecker/linkding/pull/474
* Added Apple web-app meta tag #358 by @acbgbca in https://github.com/sissbruecker/linkding/pull/359
* Bump requests from 2.28.1 to 2.31.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/478
* Allow passing title and description to new bookmark form by @acbgbca in https://github.com/sissbruecker/linkding/pull/479
* Enable WAL to avoid locked database lock errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/480
* Fix website loader content encoding detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/482
* Bump certifi from 2022.12.7 to 2023.7.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/497
* Bump django from 4.1.9 to 4.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/494
### New Contributors
* @gingerbeardman made their first contribution in https://github.com/sissbruecker/linkding/pull/476
* @acbgbca made their first contribution in https://github.com/sissbruecker/linkding/pull/474
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.0...v1.19.1
---
## v1.19.0 (20/05/2023)
### What's Changed
* Add notes to bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/472
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.18.0...v1.19.0
---
## v1.18.0 (18/05/2023)
### What's Changed
* Make search case-insensitive on Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/432
* Allow searching for tags without hash character by @sissbruecker in https://github.com/sissbruecker/linkding/pull/449
* Prevent zoom-in after focusing an input on small viewports on iOS devices by @puresick in https://github.com/sissbruecker/linkding/pull/440
* Add database options by @plockaby in https://github.com/sissbruecker/linkding/pull/406
* Allow to log real client ip in logs when using a reverse proxy by @fmenabe in https://github.com/sissbruecker/linkding/pull/398
* Add option to display URL below title by @bah0 in https://github.com/sissbruecker/linkding/pull/365
* Add LinkThing iOS app to community section by @amoscardino in https://github.com/sissbruecker/linkding/pull/446
* Bump django from 4.1.7 to 4.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/466
* Bump sqlparse from 0.4.2 to 0.4.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/455
### New Contributors
* @amoscardino made their first contribution in https://github.com/sissbruecker/linkding/pull/446
* @puresick made their first contribution in https://github.com/sissbruecker/linkding/pull/440
* @plockaby made their first contribution in https://github.com/sissbruecker/linkding/pull/406
* @fmenabe made their first contribution in https://github.com/sissbruecker/linkding/pull/398
* @bah0 made their first contribution in https://github.com/sissbruecker/linkding/pull/365
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.2...v1.18.0
---
## v1.17.2 (18/02/2023)
### What's Changed
* Escape texts in exported HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/429
* Bump django from 4.1.2 to 4.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/427
* Make health check in Dockerfile honor context path setting by @mrex in https://github.com/sissbruecker/linkding/pull/407
* Disable autocapitalization for tag input form by @joshdick in https://github.com/sissbruecker/linkding/pull/395
### New Contributors
* @mrex made their first contribution in https://github.com/sissbruecker/linkding/pull/407
* @joshdick made their first contribution in https://github.com/sissbruecker/linkding/pull/395
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.1...v1.17.2
---
## v1.17.1 (22/01/2023)
### What's Changed
* Fix favicon being cleared by web archive snapshot task by @sissbruecker in https://github.com/sissbruecker/linkding/pull/405
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.0...v1.17.1
---
## v1.17.0 (21/01/2023)
### What's Changed
* Add Health Check endpoint by @mckennajones in https://github.com/sissbruecker/linkding/pull/392
* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401
* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402
* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0
---
## 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

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 ./
@@ -10,7 +10,7 @@ RUN npm run build
FROM python:3.10.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential
RUN apt-get update && apt-get -y install build-essential libpq-dev
WORKDIR /etc/linkding
@@ -34,7 +34,7 @@ RUN mkdir /opt/venv && \
FROM python:3.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support
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}/${LD_CONTEXT_PATH}health || exit 1
CMD ["./bootstrap.sh"]

View File

@@ -17,11 +17,12 @@
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
- [Community](#community)
- [Acknowledgements](#acknowledgements)
- [Development](#development)
## Introduction
linkding is a simple bookmark service that you can host yourself.
linkding is a bookmark manager that you can host yourself.
It's designed be to be minimal, fast, and easy to set up using Docker.
The name comes from:
@@ -30,22 +31,23 @@ The name comes from:
- ...so basically something for managing your links
**Feature Overview:**
- Clean UI optimized for readability
- Organize bookmarks with tags
- Add notes using Markdown
- Read it later functionality
- Share bookmarks with other users
- Bulk editing
- Bookmark archive
- Automatically provides titles and descriptions of bookmarked websites
- Automatically provides titles, descriptions and icons of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- Light and dark themes
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
- Easy setup using Docker, uses SQLite as database
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
**Demo:** https://demo.linkding.link/ (configured with open registration)
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
**Screenshot:**
@@ -53,21 +55,23 @@ 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
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
```shell
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
```
By default, the application runs on port `9090`, you can map it to a different host port by modifying the port mapping in the command above. If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
Note that the command above will store the linkding SQLite database in the container, which means that deleting the container, for example when upgrading the installation, will also remove the database. For hosting an actual installation you usually want to store the database on the host system, rather than in the container. To do so, run the following command, and replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database:
```shell
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
```
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
@@ -97,6 +101,8 @@ docker-compose exec linkding python manage.py createsuperuser --username=joe --e
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
### Reverse Proxy Setup
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.
@@ -104,10 +110,25 @@ When using a reverse proxy, such as Nginx or Apache, you may need to configure y
<details>
<summary>Apache</summary>
Not tested yet.
If you figure out a working setup, feel free to contribute it here.
Apache2 does not change the headers by default, and should not
need additional configuration.
In the meanwhile, use the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
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>
@@ -139,7 +160,7 @@ Instead of configuring header forwarding in your proxy, you can also configure t
### Managed Hosting Options
Self-hosting web applications on your own hardware (unfortunately) still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting linkding, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a (usually monthly) fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
Self-hosting web applications still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding)
@@ -152,6 +173,7 @@ Self-hosting web applications on your own hardware (unfortunately) still require
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
@@ -167,13 +189,15 @@ The extension is open-source as well, and can be found [here](https://github.com
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-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)
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
## Acknowledgements
@@ -223,3 +247,23 @@ Start the Django development server with:
python3 manage.py runserver
```
The frontend is now available under http://localhost:8000
### DevContainers
This repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
Once checked out, only the following commands are required to get started:
Create a user for the frontend:
```
python3 manage.py createsuperuser --username=joe --email=joe@example.com
```
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
```
npm run dev
```
Start the Django development server with:
```
python3 manage.py runserver
```
The frontend is now available under http://localhost:8000

View File

@@ -1,14 +1,14 @@
from django.urls import reverse
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.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,
@@ -19,12 +19,23 @@ class BookmarkViewSet(viewsets.GenericViewSet,
mixins.DestroyModelMixin):
serializer_class = BookmarkSerializer
def get_permissions(self):
# Allow unauthenticated access to shared bookmarks.
# The shared action should still filter bookmarks so that
# unauthenticated users only see bookmarks from users that have public
# sharing explicitly enabled
if self.action == 'shared':
return [AllowAny()]
# Otherwise use default permissions which should require authentication
return super().get_permissions()
def get_queryset(self):
user = self.request.user
# For list action, use query set that applies search and tag projections
if self.action == 'list':
query_string = self.request.GET.get('q')
return queries.query_bookmarks(user, query_string)
return queries.query_bookmarks(user, user.profile, query_string)
# For single entity actions use default query set without projections
return Bookmark.objects.all().filter(owner=user)
@@ -36,7 +47,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def archived(self, request):
user = request.user
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(user, query_string)
query_set = queries.query_archived_bookmarks(user, user.profile, query_string)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
@@ -46,7 +57,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def shared(self, request):
filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first()
query_set = queries.query_shared_bookmarks(user, filters.query)
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
@@ -68,15 +80,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

@@ -27,6 +27,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'url',
'title',
'description',
'notes',
'website_title',
'website_description',
'is_archived',
@@ -47,6 +48,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='')
notes = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
shared = serializers.BooleanField(required=False, default=False)
@@ -58,6 +60,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url']
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
bookmark.notes = validated_data['notes']
bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread']
bookmark.shared = validated_data['shared']
@@ -66,7 +69,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
for key in ['url', 'title', 'description', 'unread', 'shared']:
for key in ['url', 'title', 'description', 'notes', 'unread', 'shared']:
if key in validated_data:
setattr(instance, key, validated_data[key])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,32 @@
from bookmarks import queries
from bookmarks.models import Toast
from bookmarks import utils
def toasts(request):
user = request.user if hasattr(request, 'user') else None
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
user = request.user
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user.is_authenticated else []
has_toasts = len(toast_messages) > 0
return {
'has_toasts': has_toasts,
'toast_messages': toast_messages,
}
def public_shares(request):
# Only check for public shares for anonymous users
if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(None, request.user_profile, '', True)
has_public_shares = query_set.count() > 0
return {
'has_public_shares': has_public_shares,
}
return {}
def app_version(request):
return {
'app_version': utils.app_version
}

View File

View File

@@ -0,0 +1,67 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark(title='Existing title',
description='Existing description',
notes='Existing notes',
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
website_title='Existing website title',
website_description='Existing website description',
unread=True)
tag_names = ' '.join(existing_bookmark.tag_names)
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:new'))
# Enter bookmarked URL
page.get_by_label('URL').fill(existing_bookmark.url)
# Already bookmarked hint should be visible
page.get_by_text('This URL is already bookmarked.').wait_for(timeout=2000)
# Form should be pre-filled with data from existing bookmark
self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value())
self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value())
self.assertEqual(existing_bookmark.notes, page.get_by_label('Notes').input_value())
self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder'))
self.assertEqual(existing_bookmark.website_description,
page.get_by_label('Description').get_attribute('placeholder'))
self.assertEqual(tag_names, page.get_by_label('Tags').input_value())
self.assertTrue(tag_names, page.get_by_label('Mark as unread').is_checked())
# Enter non-bookmarked URL
page.get_by_label('URL').fill('https://example.com/unknown')
# Already bookmarked hint should be hidden
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden', timeout=2000)
browser.close()
def test_edit_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:edit', args=[bookmark.id]))
page.wait_for_timeout(timeout=1000)
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden')
def test_enter_url_of_existing_bookmark_should_show_notes(self):
bookmark = self.setup_bookmark(notes='Existing notes', description='Existing description')
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:new'))
details = page.locator('details.notes')
expect(details).not_to_have_attribute('open', value='')
page.get_by_label('URL').fill(bookmark.url)
expect(details).to_have_attribute('open', value='')

View File

@@ -0,0 +1,25 @@
from unittest import skip
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
@skip("Fails in CI, needs investigation")
def test_toggle_notes_should_show_hide_notes(self):
bookmark = self.setup_bookmark(notes='Test notes')
with sync_playwright() as p:
page = self.open(reverse('bookmarks:index'), p)
notes = self.locate_bookmark(bookmark.title).locator('.notes')
expect(notes).to_be_hidden()
toggle_notes = page.locator('li button.toggle-notes')
toggle_notes.click()
expect(notes).to_be_visible()
toggle_notes.click()
expect(notes).to_be_hidden()

View File

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

View File

@@ -0,0 +1,30 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
def test_focus_search(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
page.press('body', 's')
expect(page.get_by_placeholder('Search for words or #tags')).to_be_focused()
browser.close()
def test_add_bookmark(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
page.press('body', 'n')
expect(page).to_have_url(self.live_server_url + reverse('bookmarks:new'))
browser.close()

View File

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

45
bookmarks/e2e/helpers.py Normal file
View File

@@ -0,0 +1,45 @@
from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext, Playwright, Page
from bookmarks.tests.helpers import BookmarkFactoryMixin
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.client.force_login(self.get_or_create_test_user())
self.cookie = self.client.cookies['sessionid']
def setup_browser(self, playwright) -> BrowserContext:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context()
context.add_cookies([{
'name': 'sessionid',
'value': self.cookie.value,
'domain': self.live_server_url.replace('http:', ''),
'path': '/'
}])
return context
def open(self, url: str, playwright: Playwright) -> Page:
browser = self.setup_browser(playwright)
self.page = browser.new_page()
self.page.goto(self.live_server_url + url)
self.page.on('load', self.on_load)
self.num_loads = 0
return self.page
def on_load(self):
self.num_loads += 1
def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count)
def locate_bookmark(self, title: str):
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
return bookmark_tags.filter(has_text=title)
def locate_bulk_edit_bar(self):
return self.page.locator('.bulk-edit-bar')
def locate_bulk_edit_toggle(self):
return self.page.get_by_title('Bulk edit')

View File

@@ -18,7 +18,7 @@ class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
query_string = request.GET.get('q')
query_set = queries.query_bookmarks(feed_token.user, query_string)
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string)
return FeedContext(feed_token, query_set)
def item_title(self, item: Bookmark):

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import logging
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import connections
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Enable WAL journal mode when using an SQLite database"
def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
return
connection = connections['default']
with connection.cursor() as cursor:
cursor.execute("PRAGMA journal_mode")
current_mode = cursor.fetchone()[0]
logger.info(f'Current journal mode: {current_mode}')
if current_mode != 'wal':
cursor.execute("PRAGMA journal_mode=wal;")
logger.info('Switched to WAL journal mode')

View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1 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

@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-04-10 01:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0019_userprofile_enable_favicons'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='tag_search',
field=models.CharField(choices=[('strict', 'Strict'), ('lax', 'Lax')], default='strict', max_length=10),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-05-18 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0020_userprofile_tag_search'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='display_url',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-05-19 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0021_userprofile_display_url'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='notes',
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-05-20 08:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0022_bookmark_notes'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='permanent_notes',
field=models.BooleanField(default=False),
),
]

View File

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

View File

@@ -50,9 +50,11 @@ class Bookmark(models.Model):
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True)
notes = models.TextField(blank=True)
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)
@@ -109,6 +111,7 @@ class BookmarkForm(forms.ModelForm):
'tag_string',
'title',
'description',
'notes',
'website_title',
'website_description',
'unread',
@@ -116,6 +119,10 @@ class BookmarkForm(forms.ModelForm):
'auto_close',
]
@property
def has_notes(self):
return self.instance and self.instance.notes
class BookmarkFilters:
def __init__(self, request: WSGIRequest):
@@ -152,6 +159,12 @@ class UserProfile(models.Model):
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
]
TAG_SEARCH_STRICT = 'strict'
TAG_SEARCH_LAX = 'lax'
TAG_SEARCH_CHOICES = [
(TAG_SEARCH_STRICT, 'Strict'),
(TAG_SEARCH_LAX, 'Lax'),
]
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
@@ -160,13 +173,20 @@ class UserProfile(models.Model):
default=BOOKMARK_LINK_TARGET_BLANK)
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
default=TAG_SEARCH_STRICT)
enable_sharing = models.BooleanField(default=False, null=False)
enable_public_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False)
permanent_notes = 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', 'tag_search',
'enable_sharing', 'enable_public_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
@receiver(post_save, sender=get_user_model())

View File

@@ -1,29 +1,32 @@
from typing import Optional
from django.contrib.auth.models import User
from django.db.models import Q, QuerySet
from django.db.models import Q, QuerySet, Exists, OuterRef
from bookmarks.models import Bookmark, Tag
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.utils import unique
def query_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
.filter(is_archived=False)
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
.filter(is_archived=True)
def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(shared=True) \
.filter(owner__profile__enable_sharing=True)
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str,
public_only: bool) -> QuerySet:
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
if public_only:
conditions = conditions & Q(owner__profile__enable_public_sharing=True)
return _base_bookmarks_query(user, profile, query_string).filter(conditions)
def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
query_set = Bookmark.objects
# Filter for user
@@ -35,13 +38,17 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
# Filter for search terms and tags
for term in query['search_terms']:
query_set = query_set.filter(
Q(title__contains=term)
| Q(description__contains=term)
| Q(website_title__contains=term)
| Q(website_description__contains=term)
| Q(url__contains=term)
)
conditions = Q(title__icontains=term) \
| Q(description__icontains=term) \
| Q(notes__icontains=term) \
| Q(website_title__icontains=term) \
| Q(website_description__icontains=term) \
| Q(url__icontains=term)
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
conditions = conditions | Exists(Bookmark.objects.filter(id=OuterRef('id'), tags__name__iexact=term))
query_set = query_set.filter(conditions)
for tag_name in query['tag_names']:
query_set = query_set.filter(
@@ -65,32 +72,33 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
return query_set
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
bookmarks_query = query_bookmarks(user, query_string)
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_bookmarks(user, profile, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, query_string)
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, profile, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_shared_bookmark_tags(user: Optional[User], query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, query_string)
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str,
public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string, public_only)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_shared_bookmark_users(query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, query_string)
def query_shared_bookmark_users(profile: UserProfile, query_string: str, public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string, public_only)
query_set = User.objects.filter(bookmark__in=bookmarks_query)

View File

@@ -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
@@ -43,11 +45,15 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update dates
bookmark.date_modified = timezone.now()
bookmark.save()
# 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
@@ -116,6 +122,7 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description
to_bookmark.notes = from_bookmark.notes
to_bookmark.unread = from_bookmark.unread
to_bookmark.shared = from_bookmark.shared

View File

@@ -1,3 +1,4 @@
import html
from typing import List
from bookmarks.models import Bookmark
@@ -28,13 +29,14 @@ def append_list_start(doc: BookmarkDocument):
def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
url = bookmark.url
title = bookmark.resolved_title
desc = bookmark.resolved_description
title = html.escape(bookmark.resolved_title or '')
desc = html.escape(bookmark.resolved_description or '')
tags = ','.join(bookmark.tag_names)
toread = '1' if bookmark.unread else '0'
private = '0' if bookmark.shared else '1'
added = int(bookmark.date_added.timestamp())
doc.append(f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="0" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
doc.append(f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
if desc:
doc.append(f'<DD>{desc}')

View File

@@ -0,0 +1,83 @@
import logging
import mimetypes
import os.path
import re
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
logger = logging.getLogger(__name__)
# register mime type for .ico files, which is not included in the default
# mimetypes of the Docker image
mimetypes.add_type('image/x-icon', '.ico')
def _ensure_favicon_folder():
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
def _url_to_filename(url: str) -> str:
return re.sub(r'\W+', '_', url)
def _get_url_parameters(url: str) -> dict:
parsed_uri = urlparse(url)
return {
# https://example.com/foo?bar -> https://example.com
'url': f'{parsed_uri.scheme}://{parsed_uri.hostname}',
# https://example.com/foo?bar -> example.com
'domain': parsed_uri.hostname,
}
def _get_favicon_path(favicon_file: str) -> Path:
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
def _check_existing_favicon(favicon_name: str):
# return existing file if a file with the same name, ignoring extension,
# exists and is not stale
for filename in os.listdir(settings.LD_FAVICON_FOLDER):
file_base_name, _ = os.path.splitext(filename)
if file_base_name == favicon_name:
favicon_path = _get_favicon_path(filename)
return filename if not _is_stale(favicon_path) else None
return None
def _is_stale(path: Path) -> bool:
stat = path.stat()
file_age = time.time() - stat.st_mtime
return file_age >= max_file_age
def load_favicon(url: str) -> str:
url_parameters = _get_url_parameters(url)
# Create favicon folder if not exists
_ensure_favicon_folder()
# Use scheme+hostname as favicon filename to reuse icon for all pages on the same domain
favicon_name = _url_to_filename(url_parameters['url'])
favicon_file = _check_existing_favicon(favicon_name)
if not favicon_file:
# Load favicon from provider, save to file
favicon_url = settings.LD_FAVICON_PROVIDER.format(**url_parameters)
logger.debug(f'Loading favicon from: {favicon_url}')
with requests.get(favicon_url, stream=True) as response:
content_type = response.headers['Content-Type']
file_extension = mimetypes.guess_extension(content_type)
favicon_file = f'{favicon_name}{file_extension}'
favicon_path = _get_favicon_path(favicon_file)
with open(favicon_path, 'wb') as file:
for chunk in response.iter_content(chunk_size=8192):
file.write(chunk)
logger.debug(f'Saved favicon as: {favicon_path}')
return favicon_file

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ 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
@@ -9,6 +10,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import favicon_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__)
@@ -35,7 +37,7 @@ def _load_newest_snapshot(bookmark: Bookmark):
if existing_snapshot:
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
bookmark.save()
bookmark.save(update_fields=['web_archive_snapshot_url'])
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
except NoCDXRecordFound:
@@ -49,7 +51,7 @@ def _create_snapshot(bookmark: Bookmark):
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
archive.save()
bookmark.web_archive_snapshot_url = archive.archive_url
bookmark.save()
bookmark.save(update_fields=['web_archive_snapshot_url'])
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
@@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
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.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', exc_info=error)
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
exc_info=error)
# Load the newest snapshot as fallback
_load_newest_snapshot(bookmark)
@@ -105,3 +108,67 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int):
# 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_file = favicon_loader.load_favicon(bookmark.url)
if new_favicon_file != bookmark.favicon_file:
bookmark.favicon_file = new_favicon_file
bookmark.save(update_fields=['favicon_file'])
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon_file}')
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

@@ -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,70 @@ 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
end_of_head = '</head>'.encode('utf-8')
if end_of_head in content:
logger.debug(f'Found closing head tag after {size} bytes')
content = content.split(end_of_head)[0] + end_of_head
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

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

View File

@@ -72,6 +72,12 @@ a:visited:hover {
color: $link-color-dark;
}
code {
color: $gray-color-dark;
background-color: $code-bg-color;
box-shadow: 1px 1px 0 $code-shadow-color;
}
// Increase spacing between columns
.container > .columns > .column:not(:first-child) {
padding-left: 2rem;
@@ -102,3 +108,12 @@ a:visited:hover {
font-weight: bold;
}
}
// Increase input font size on small viewports to prevent zooming on focus the input
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
// viewport size
@media screen and (max-width: 430px) {
.form-input {
font-size: 16px;
}
}

View File

@@ -1,3 +1,4 @@
/* Bookmark search box */
.bookmarks-page .search {
$searchbox-width: 180px;
$searchbox-width-md: 300px;
@@ -37,17 +38,20 @@
}
}
.bookmarks-page .content-area-header {
span.btn {
margin-left: 8px;
}
}
/* Bookmark list */
ul.bookmark-list {
list-style: none;
margin: 0;
padding: 0;
}
/* Bookmarks */
li[ld-bookmark-item] {
position: relative;
[ld-bulk-edit-checkbox].form-checkbox {
display: none;
}
.title a {
display: inline-block;
@@ -58,6 +62,16 @@ ul.bookmark-list {
text-overflow: ellipsis;
}
.title img {
width: 16px;
height: 16px;
vertical-align: text-top;
}
.url-display {
color: $secondary-link-color;
}
.description {
color: $gray-color-dark;
@@ -66,31 +80,41 @@ ul.bookmark-list {
}
}
.actions > *:not(:last-child) {
margin-right: 0.1rem;
.actions {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.4rem;
}
.actions .date-label a {
color: $gray-color;
}
.actions {
a, button.btn-link {
color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
transition: none;
text-decoration: none;
.actions .btn-link {
color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
}
}
.bulk-edit-toggle {
display: none;
.separator {
align-self: flex-start;
}
.toggle-notes {
align-self: center;
display: flex;
align-items: center;
gap: 0.1rem;
}
}
}
@@ -170,16 +194,79 @@ ul.bookmark-list {
font-weight: bold;
}
}
details.notes textarea {
box-sizing: border-box;
}
}
/* Bookmark actions / bulk edit */
/* Bookmark notes */
ul.bookmark-list {
.notes {
display: none;
max-height: 300px;
margin: 4px 0;
overflow: auto;
}
&.show-notes .notes,
li.show-notes .notes {
display: block;
}
}
/* Bookmark notes markdown styles */
ul.bookmark-list .notes-content {
& {
padding: 0.4rem 0.6rem;
}
p, ul, ol, pre, blockquote {
margin: 0 0 0.4rem 0;
}
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
ul, ol {
margin-left: 0.8rem;
}
ul li, ol li {
margin-top: 0.2rem;
}
pre {
padding: 0.2rem 0.4rem;
background-color: $code-bg-color;
border-radius: 0.2rem;
}
pre code {
background: none;
box-shadow: none;
padding: 0;
}
> pre:first-child:last-child {
padding: 0;
background: none;
border-radius: 0;
}
}
/* Bookmark bulk edit */
$bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms;
.bookmarks-page form.bookmark-actions {
[ld-bulk-edit] {
.bulk-edit-bar {
margin-top: -17px;
margin-bottom: 16px;
@@ -189,56 +276,27 @@ $bulk-edit-transition-duration: 400ms;
transition: max-height $bulk-edit-transition-duration;
}
.bulk-edit-actions {
display: flex;
align-items: baseline;
padding: 4px 0;
border-top: solid 1px $border-color;
button:hover {
text-decoration: underline;
}
> label.form-checkbox {
min-height: 1rem;
}
> button {
padding: 0;
margin-left: 8px;
}
> span {
margin-left: 8px;
}
> input, .form-autocomplete {
width: auto;
margin-left: 4px;
max-width: 200px;
-webkit-appearance: none;
}
span.confirmation {
display: flex;
}
span.confirmation button {
padding: 0;
}
&.active .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px $border-color;
}
.bulk-edit-all-toggle {
/* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
&.active:not(.activating) .bulk-edit-bar {
overflow: visible;
}
/* All checkbox */
[ld-bulk-edit-checkbox][all].form-checkbox {
display: block;
width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0;
min-height: 1rem;
}
ul.bookmark-list li {
position: relative;
}
ul.bookmark-list li .bulk-edit-toggle {
/* Bookmark checkboxes */
li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
display: block;
position: absolute;
width: $bulk-edit-toggle-width;
@@ -250,22 +308,36 @@ $bulk-edit-transition-duration: 400ms;
opacity: 0;
transition: all $bulk-edit-transition-duration;
i {
.form-icon {
top: 0.2rem;
}
}
}
#bulk-edit-mode {
display: none;
}
&.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
visibility: visible;
opacity: 1;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle {
visibility: visible;
opacity: 1;
}
/* Actions */
.bulk-edit-actions {
display: flex;
align-items: baseline;
padding: 4px 0;
border-top: solid 1px $border-color;
gap: 8px;
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px $border-color;
button {
padding: 0 !important;
}
button:hover {
text-decoration: underline;
}
> input, .form-autocomplete {
width: auto;
max-width: 200px;
-webkit-appearance: none;
}
}
}

View File

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

View File

@@ -21,8 +21,13 @@ $link-color: $primary-color !default;
$link-color-dark: darken($link-color, 5%) !default;
$link-color-light: $link-color !default;
$secondary-link-color: rgba(168, 177, 255, 0.73);
$alternative-color: #59bdb9;
$alternative-color-dark: #73f1eb;
$code-bg-color: rgba(255, 255, 255, 0.1);
$code-shadow-color: rgba(255, 255, 255, 0.2);
/* Dark theme specific */
$dt-primary-button-color: #5761cb !default;

View File

@@ -2,3 +2,8 @@ $html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);
$secondary-link-color: rgba(87, 85, 217, 0.64);
$code-bg-color: rgba(0, 0, 0, 0.05);
$code-shadow-color: rgba(0, 0, 0, 0.15);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
<label for="bulk-edit-mode" class="hide-sm">
<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"/>
</svg>
</span>
</label>
<button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" 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"/>
</svg>
</button>

View File

@@ -15,13 +15,13 @@
</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.
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" }}
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize: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
@@ -67,6 +67,19 @@
</div>
{{ form.description.errors }}
</div>
<div class="form-group">
<details class="notes"{% if form.has_notes %} open{% endif %}>
<summary>
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
{{ form.notes.errors }}
</details>
</div>
<div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }}
@@ -77,7 +90,7 @@
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 %}
{% if request.user_profile.enable_sharing %}
<div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }}
@@ -85,7 +98,11 @@
<span>Share</span>
</label>
<div class="form-input-hint">
Share this bookmark with other users.
{% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users.
{% else %}
Share this bookmark with other registered users.
{% endif %}
</div>
</div>
{% endif %}
@@ -99,25 +116,7 @@
<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' %}')
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 src="{% static "bundle.js" %}?v={{ app_version }}"></script>
<script type="application/javascript">
/**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
@@ -128,6 +127,11 @@
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 notesDetails = document.querySelector('form details.notes');
const notesInput = document.getElementById('{{ form.notes.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 }};
@@ -145,6 +149,20 @@
}
}
function updateInput(input, value) {
if (!input) {
return;
}
input.value = value;
}
function updateCheckbox(input, value) {
if (!input) {
return;
}
input.checked = value;
}
function checkUrl() {
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
@@ -162,13 +180,19 @@
toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false);
// Display hint if URL is already bookmarked
// Prefill form and display hint if URL is already bookmarked
const existingBookmark = data.bookmark;
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
if (existingBookmark && !editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block';
editExistingBookmarkLink.href = data.bookmark.edit_url;
notesDetails.open = !!existingBookmark.notes;
updateInput(titleInput, existingBookmark.title);
updateInput(descriptionInput, existingBookmark.description);
updateInput(notesInput, existingBookmark.notes);
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
updateCheckbox(unreadCheckbox, existingBookmark.unread);
updateCheckbox(sharedCheckbox, existingBookmark.shared);
} else {
bookmarkExistsHint.style['display'] = 'none';
}
@@ -186,6 +210,9 @@
});
}
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
// 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) {
@@ -198,9 +225,6 @@
updatePlaceholder(titleInput, websiteTitleInput.value);
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
}
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})();
</script>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
{% load shared %}
{% htmlmin %}
<div class="tag-cloud">
{% if has_selected_tags %}
{% if tag_cloud.has_selected_tags %}
<p class="selected-tags">
{% for tag in selected_tags %}
<a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
{% for tag in tag_cloud.selected_tags %}
<a href="?{% remove_tag_from_query tag.name %}"
class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</a>
@@ -12,19 +12,19 @@
</p>
{% endif %}
<div class="unselected-tags">
{% for group in groups %}
{% for group in tag_cloud.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 %}"
<a href="?{% add_tag_to_query tag.name %}"
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 %}"
<a href="?{% add_tag_to_query tag.name %}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>

View File

@@ -29,6 +29,25 @@
be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }}
<i class="form-icon"></i> Show bookmark URL
</label>
<div class="form-input-hint">
When enabled, this setting displays the bookmark URL below the title.
</div>
</div>
<div class="form-group">
<label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox">
{{ form.permanent_notes }}
<i class="form-icon"></i> Show notes permanently
</label>
<div class="form-input-hint">
Whether to show bookmark notes permanently, without having to toggle them individually.
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
</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" }}
@@ -36,6 +55,40 @@
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
In strict mode, tags must be prefixed with a hash character (#).
In lax mode, tags can also be searched without the hash character.
Note that tags without the hash character are indistinguishable from search terms, which means the search
result will also include bookmarks where a search term matches otherwise.
</div>
</div>
<div 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#ld_favicon_provider"
target="_blank">options documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up.
</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>
@@ -61,7 +114,25 @@
</div>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2">
<label for="{{ form.enable_public_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_public_sharing }}
<i class="form-icon"></i> Enable public bookmark sharing
</label>
<div class="form-input-hint">
Makes shared bookmarks publicly accessible, without requiring a login.
That means that anyone with a link to this instance can view shared bookmarks via the <a
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
</div>
</div>
<div class="form-group">
<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>
@@ -73,6 +144,16 @@
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">
<label for="import_map_private_flag" class="form-checkbox">
<input type="checkbox" id="import_map_private_flag" name="map_private_flag">
<i class="form-icon"></i> Import public bookmarks as shared
</label>
<div class="form-input-hint">
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
Otherwise, all bookmarks will be imported as private bookmarks.
</div>
</div>
<div class="form-group">
<div class="input-group col-8 col-md-12">
<input class="form-input" type="file" name="import_file">
@@ -100,6 +181,10 @@
<section class="content-area">
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<p>
Note that exporting bookmark notes is currently not supported due to limitations of the format.
For proper backups please use a database backup as described in the documentation.
</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
@@ -137,4 +222,22 @@
</section>
</div>
<script>
// Automatically disable public bookmark sharing if bookmark sharing is disabled
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
function updatePublicSharing() {
if (enableSharing.checked) {
enablePublicSharing.disabled = false;
} else {
enablePublicSharing.disabled = true;
enablePublicSharing.checked = false;
}
}
updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing);
</script>
{% endblock %}

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

View File

@@ -1,8 +1,13 @@
import re
import bleach
import markdown
from bleach_allowlist import markdown_tags, markdown_attrs
from django import template
from django.utils.safestring import mark_safe
from bookmarks import utils
from bookmarks.models import UserProfile
register = template.Library()
@@ -19,36 +24,39 @@ def update_query_string(context, **kwargs):
@register.simple_tag(takes_context=True)
def append_to_query_param(context, **kwargs):
query = context.request.GET.copy()
def add_tag_to_query(context, tag_name: str):
params = context.request.GET.copy()
# Append to or create query param
for key in kwargs:
if query.__contains__(key):
value = query.__getitem__(key) + ' '
else:
value = ''
value = value + kwargs[key]
query.__setitem__(key, value)
# Append to or create query string
if params.__contains__('q'):
query_string = params.__getitem__('q') + ' '
else:
query_string = ''
query_string = query_string + '#' + tag_name
params.__setitem__('q', query_string)
return query.urlencode()
return params.urlencode()
@register.simple_tag(takes_context=True)
def remove_from_query_param(context, **kwargs):
query = context.request.GET.copy()
def remove_tag_from_query(context, tag_name: str):
params = context.request.GET.copy()
if params.__contains__('q'):
# Split query string into parts
query_string = params.__getitem__('q')
query_parts = query_string.split()
# Remove tag with hash
tag_name_with_hash = '#' + tag_name
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
# When using lax tag search, also remove tag without hash
profile = context.request.user_profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
# Rebuild query string
query_string = ' '.join(query_parts)
params.__setitem__('q', query_string)
# Remove item from query param
for key in kwargs:
if query.__contains__(key):
value = query.__getitem__(key)
parts = value.split()
part_to_remove = kwargs[key]
updated_parts = [part for part in parts if str.lower(part) != str.lower(part_to_remove)]
updated_value = ' '.join(updated_parts)
query.__setitem__(key, updated_value)
return query.urlencode()
return params.urlencode()
@register.simple_tag(takes_context=True)
@@ -109,3 +117,19 @@ class HtmlMinNode(template.Node):
output = re.sub(r'\s+', ' ', output)
return output
@register.simple_tag(name="markdown", takes_context=True)
def render_markdown(context, markdown_text):
# naive approach to reusing the renderer for a single request
# works for bookmark list for now
if not ('markdown_renderer' in context):
renderer = markdown.Markdown(extensions=['fenced_code', 'nl2br'])
context['markdown_renderer'] = renderer
else:
renderer = context['markdown_renderer']
as_html = renderer.convert(markdown_text)
sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)
return mark_safe(sanitized_html)

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
@@ -158,6 +158,37 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[1]])
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.user.profile.save()
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags, is_archived=True)
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True),

View File

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

View File

@@ -20,6 +20,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'notes': 'edited notes',
'unread': False,
'shared': False,
}
@@ -37,6 +38,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.notes, form_data['notes'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
@@ -74,7 +76,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
website_title='website title', website_description='website description')
notes='edited notes', website_title='website title',
website_description='website description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
@@ -86,8 +89,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML(f'''
<input type="text" name="tag_string" value="{tag_string}"
autocomplete="off" class="form-input" id="id_tag_string">
<input ld-tag-autocomplete type="text" name="tag_string" value="{tag_string}"
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
''', html)
self.assertInHTML(f'''
@@ -101,6 +104,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
</textarea>
''', html)
self.assertInHTML(f'''
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
{bookmark.notes}
</textarea>
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}">
@@ -184,3 +193,15 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span>
</label>
''', html, count=1)
def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1)
def test_should_show_notes_if_there_are_notes(self):
bookmark = self.setup_bookmark(notes='test notes')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes" open>', count=1)

View File

@@ -17,7 +17,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
@@ -155,7 +155,38 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
]
self.setup_bookmark(tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(title=tags[0].name, tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[1]])
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.user.profile.save()
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[0], tags[1]])
@@ -196,8 +227,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
<a href="{edit_url}?return_url={return_url}">Edit</a>
''', html)
# with query params
@@ -208,6 +238,5 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
<a href="{edit_url}?return_url={return_url}">Edit</a>
''', html)

View File

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

View File

@@ -19,6 +19,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'notes': 'test notes',
'unread': False,
'shared': False,
'auto_close': '',
@@ -37,6 +38,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.notes, form_data['notes'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
@@ -74,6 +76,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'id="id_url">',
html)
def test_should_prefill_title_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?title=Example%20Title')
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="title" value="Example Title" '
'class="form-input" maxlength="512" autocomplete="off" '
'id="id_title">',
html)
def test_should_prefill_description_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?description=Example%20Site%20Description')
html = response.content.decode()
self.assertInHTML(
'<textarea name="description" class="form-input" cols="40" '
'rows="2" id="id_description">Example Site Description</textarea>',
html)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(
reverse('bookmarks:new') + '?auto_close')
@@ -138,3 +159,33 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span>
</label>
''', html, count=1)
def test_should_show_respective_share_hint(self):
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('''
<div class="form-input-hint">
Share this bookmark with other registered users.
</div>
''', html)
self.user.profile.enable_public_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('''
<div class="form-input-hint">
Share this bookmark with other registered users and anonymous users.
</div>
''', html)
def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1)

View File

@@ -10,6 +10,8 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
rf = RequestFactory()
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
filters = BookmarkFilters(request)
context = RequestContext(request, {
'request': request,

View File

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

View File

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

View File

@@ -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,22 +8,26 @@ 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
class BookmarksApiTestCase(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)
self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2])
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
self.bookmark2 = self.setup_bookmark()
self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
def authenticate(self):
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
def assertBookmarkListEqual(self, data_list, bookmarks):
expectations = []
for bookmark in bookmarks:
@@ -32,6 +38,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['url'] = bookmark.url
expectation['title'] = bookmark.title
expectation['description'] = bookmark.description
expectation['notes'] = bookmark.notes
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
@@ -48,24 +55,34 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertCountEqual(data_list, expectations)
def test_list_bookmarks(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
def test_list_bookmarks_should_filter_by_query(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
def test_list_archived_bookmarks_should_filter_by_query(self):
self.authenticate()
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_list_shared_bookmarks(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
@@ -84,7 +101,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user1)
]
self.setup_bookmark(shared=True, user=user2)
self.setup_bookmark(shared=True, user=user2)
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
def test_list_shared_bookmarks_should_filter_by_query_and_user(self):
self.authenticate()
# Search by query
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -126,10 +159,13 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
def test_create_bookmark(self):
self.authenticate()
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'notes': 'Test notes',
'is_archived': False,
'unread': False,
'shared': False,
@@ -140,6 +176,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertEqual(bookmark.notes, data['notes'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread'])
self.assertFalse(bookmark.shared, data['shared'])
@@ -148,11 +185,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.authenticate()
original_bookmark = self.setup_bookmark()
data = {
'url': original_bookmark.url,
'title': 'Updated title',
'description': 'Updated description',
'notes': 'Updated notes',
'unread': True,
'shared': True,
'is_archived': True,
@@ -164,6 +204,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertEqual(bookmark.notes, data['notes'])
# Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(bookmark.is_archived)
self.assertEqual(bookmark.unread, data['unread'])
@@ -173,6 +214,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_replaces_whitespace_in_tag_names(self):
self.authenticate()
data = {
'url': 'https://example.com/',
'title': 'Test title',
@@ -185,10 +228,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
def test_create_bookmark_minimal_payload(self):
self.authenticate()
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_create_archived_bookmark(self):
self.authenticate()
data = {
'url': 'https://example.com/',
'title': 'Test title',
@@ -207,41 +254,55 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_is_not_archived_by_default(self):
self.authenticate()
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived)
def test_create_unread_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com/', 'unread': True}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.unread)
def test_create_bookmark_is_not_unread_by_default(self):
self.authenticate()
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.unread)
def test_create_shared_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com/', 'shared': True}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.shared)
def test_create_bookmark_is_not_shared_by_default(self):
self.authenticate()
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.shared)
def test_get_bookmark(self):
self.authenticate()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [self.bookmark1])
def test_update_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -249,11 +310,15 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self):
self.authenticate()
data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
self.authenticate()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -261,9 +326,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '')
self.assertEqual(updated_bookmark.notes, '')
self.assertEqual(updated_bookmark.tag_names, [])
def test_update_bookmark_unread_flag(self):
self.authenticate()
data = {'url': 'https://example.com/', 'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -271,6 +339,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.unread, True)
def test_update_bookmark_shared_flag(self):
self.authenticate()
data = {'url': 'https://example.com/', 'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
@@ -278,6 +348,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.shared, True)
def test_patch_bookmark(self):
self.authenticate()
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
@@ -296,6 +368,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description'])
data = {'notes': 'Updated notes'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.notes, data['notes'])
data = {'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
@@ -328,6 +406,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
@@ -337,23 +417,99 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
def test_delete_bookmark(self):
self.authenticate()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
def test_archive(self):
self.authenticate()
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertTrue(bookmark.is_archived)
def test_unarchive(self):
self.authenticate()
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
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):
self.authenticate()
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):
self.authenticate()
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):
self.authenticate()
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):
self.authenticate()
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):
self.authenticate()
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user)
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
@@ -396,3 +552,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,113 @@
import urllib.parse
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def authenticate(self) -> None:
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
def test_list_bookmarks_requires_authentication(self):
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
def test_list_archived_bookmarks_requires_authentication(self):
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
def test_list_shared_bookmarks_does_not_require_authentication(self):
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.authenticate()
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
def test_create_bookmark_requires_authentication(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'notes': 'Test notes',
'is_archived': False,
'unread': False,
'shared': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_get_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_update_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.put(url, data, expected_status_code=status.HTTP_200_OK)
def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_archive_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-archive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_unarchive_requires_authentication(self):
bookmark = self.setup_bookmark(is_archived=True)
url = reverse('bookmarks:bookmark-unarchive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_check_requires_authentication(self):
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate()
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)

View File

@@ -1,213 +0,0 @@
from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.urls import reverse
from django.utils import timezone, formats
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank', unread: bool = False):
self.assertInHTML(
f'''
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener"
class="{'text-italic' if unread else ''}">{bookmark.resolved_title}</a>
''',
html
)
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f'''
<span class="date-label text-gray text-sm">
<span>{label_content}</span>
</span>
<span class="text-gray text-sm">|</span>
''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f'''
<span class="date-label text-gray text-sm">
<a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
<span>{label_content}</span>
<span>∞</span>
</a>
</span>
<span class="text-gray text-sm">|</span>
''', html)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=1)
def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=0)
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
# Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f'''
<a href="{edit_url}?return_url=/test"
class="btn btn-link btn-sm">Edit</a>
''', html, count=count)
# Archive link
self.assertInHTML(f'''
<button type="submit" name="archive" value="{bookmark.id}"
class="btn btn-link btn-sm">Archive</button>
''', html, count=count)
# Delete link
self.assertInHTML(f'''
<button type="submit" name="remove" value="{bookmark.id}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
''', html, count=count)
def assertShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 1)
def assertNoShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 0)
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<span class="text-gray text-sm">Shared by
<a class="text-gray" href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory()
request = rf.get(url)
request.user = self.get_or_create_test_user()
paginator = Paginator(bookmarks, 10)
page = paginator.page(1)
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
return template.render(context)
def render_default_template(self, bookmarks: [Bookmark], url: str = '/test') -> str:
template = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
return self.render_template(bookmarks, template, url)
def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str:
template = Template(
f'''
{{% load bookmarks %}}
{{% bookmark_list bookmarks return_url '{link_target}' %}}
'''
)
return self.render_template(bookmarks, template)
def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = web_archive_url
bookmark.save()
user = self.get_or_create_test_user()
user.profile.bookmark_date_display = date_display_setting
user.profile.save()
return bookmark
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_default_template([bookmark])
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertDateLabel(html, formatted_date)
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark])
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
def test_should_respect_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_default_template([bookmark])
self.assertDateLabel(html, '1 week ago')
def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark])
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
self.assertBookmarksLink(html, bookmark, link_target='_blank')
def test_bookmark_link_target_should_respect_link_target_parameter(self):
bookmark = self.setup_bookmark()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_bookmark_link_target_should_respect_unread_flag(self):
bookmark = self.setup_bookmark()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=False)
bookmark = self.setup_bookmark(unread=True)
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=True)
def test_web_archive_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save()
html = self.render_default_template([bookmark])
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
def test_web_archive_link_target_respect_link_target_parameter(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
self.assertBookmarkActions(html, bookmark)
self.assertNoShareInfo(html, bookmark)
def test_show_share_info_for_non_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
html = self.render_default_template([bookmark])
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
def test_share_info_user_link_keeps_query_params(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
html = self.render_default_template([bookmark], url='/test?q=foo')
self.assertInHTML(f'''
<span class="text-gray text-sm">Shared by
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html)

View File

@@ -0,0 +1,442 @@
from typing import Type
from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.urls import reverse
from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.partials import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
unread = bookmark.unread
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
self.assertInHTML(
f'''
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener"
class="{'text-italic' if unread else ''}">
{favicon_img}
{bookmark.resolved_title}
</a>
''',
html
)
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f'''
<span>
<span>{label_content}</span>
</span>
<span class="separator">|</span>
''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f'''
<span>
<a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
<span>{label_content}</span>
</a>
</span>
<span class="separator">|</span>
''', html)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=1)
def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=0)
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
# Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f'''
<a href="{edit_url}?return_url=%2Fbookmarks">Edit</a>
''', html, count=count)
# Archive link
self.assertInHTML(f'''
<button type="submit" name="archive" value="{bookmark.id}"
class="btn btn-link btn-sm">Archive</button>
''', html, count=count)
# Delete link
self.assertInHTML(f'''
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
class="btn btn-link btn-sm">Remove</button>
''', html, count=count)
def assertShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 1)
def assertNoShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 0)
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<span>Shared by
<a href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
</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 assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
self.assertInHTML(f'''
<div class="url-path truncate">
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
class="url-display text-sm">
{bookmark.url}
</a>
</div>
''', html, count)
def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):
self.assertBookmarkURLCount(html, bookmark, count=1)
def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
self.assertBookmarkURLCount(html, bookmark, count=0)
def assertNotes(self, html: str, notes_html: str, count=1):
self.assertInHTML(f'''
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{notes_html}
</div>
</div>
''', html, count=count)
def assertNotesToggle(self, html: str, count=1):
self.assertInHTML(f'''
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</svg>
<span>Notes</span>
</button>
''', html, count=count)
def render_template(self,
url='/bookmarks',
context_type: Type[contexts.BookmarkListContext] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None) -> str:
rf = RequestFactory()
request = rf.get(url)
request.user = user or self.get_or_create_test_user()
middleware = UserProfileMiddleware(lambda r: HttpResponse())
middleware(request)
bookmark_list_context = context_type(request)
context = RequestContext(request, {'bookmark_list': bookmark_list_context})
template = Template(
"{% include 'bookmarks/bookmark_list.html' %}"
)
return template.render(context)
def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = web_archive_url
bookmark.save()
user = self.get_or_create_test_user()
user.profile.bookmark_date_display = date_display_setting
user.profile.save()
return bookmark
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertDateLabel(html, formatted_date)
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
def test_should_respect_relative_date_setting(self):
self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_template()
self.assertDateLabel(html, '1 week ago')
def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarksLink(html, bookmark, link_target='_blank')
def test_bookmark_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_web_archive_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save()
html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
def test_web_archive_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save()
html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
def test_should_respect_unread_flag(self):
bookmark = self.setup_bookmark(unread=True)
html = self.render_template()
self.assertBookmarksLink(html, bookmark)
def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarkActions(html, bookmark)
self.assertNoShareInfo(html, bookmark)
def test_show_share_info_for_non_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
other_user.profile.enable_sharing = True
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
def test_share_info_user_link_keeps_query_params(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
other_user.profile.enable_sharing = True
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True, title='foo')
html = self.render_template(url='/bookmarks?q=foo', context_type=contexts.SharedBookmarkListContext)
self.assertInHTML(f'''
<span>Shared by
<a 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_template()
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_template()
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_template()
self.assertFaviconHidden(html, bookmark)
def test_bookmark_url_should_be_hidden_by_default(self):
profile = self.get_or_create_test_user().profile
profile.save()
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarkURLHidden(html, bookmark)
def test_show_bookmark_url_when_enabled(self):
profile = self.get_or_create_test_user().profile
profile.display_url = True
profile.save()
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarkURLVisible(html, bookmark)
def test_hide_bookmark_url_when_disabled(self):
profile = self.get_or_create_test_user().profile
profile.display_url = False
profile.save()
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarkURLHidden(html, bookmark)
def test_without_notes(self):
self.setup_bookmark()
html = self.render_template()
self.assertNotes(html, '', 0)
self.assertNotesToggle(html, 0)
def test_with_notes(self):
self.setup_bookmark(notes='Test note')
html = self.render_template()
note_html = '<p>Test note</p>'
self.assertNotes(html, note_html, 1)
def test_note_renders_markdown(self):
self.setup_bookmark(notes='**Example:** `print("Hello world!")`')
html = self.render_template()
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1)
def test_note_cleans_html(self):
self.setup_bookmark(notes='<script>alert("test")</script>')
html = self.render_template()
note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
self.assertNotes(html, note_html, 1)
def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertIn('<ul class="bookmark-list">', html)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = False
profile.save()
self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertIn('<ul class="bookmark-list">', html)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = True
profile.save()
self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertIn('<ul class="bookmark-list show-notes">', html)
def test_toggle_notes_is_visible_by_default(self):
self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertNotesToggle(html, 1)
def test_toggle_notes_is_visible_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = False
profile.save()
self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertNotesToggle(html, 1)
def test_toggle_notes_is_hidden_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = True
profile.save()
self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertNotesToggle(html, 0)
def test_with_anonymous_user(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.enable_public_sharing = True
profile.save()
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com'
bookmark.notes = '**Example:** `print("Hello world!")`'
bookmark.favicon_file = 'https_example_com.png'
bookmark.shared = True
bookmark.save()
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
self.assertBookmarksLink(html, bookmark, link_target='_blank')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1)
self.assertFaviconVisible(html, bookmark)
def test_empty_state(self):
html = self.render_template()
self.assertInHTML('<p class="empty-title h5">You have no bookmarks yet</p>', html)

View File

@@ -5,11 +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 import website_loader
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
User = get_user_model()
@@ -19,11 +20,33 @@ 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',
title='Updated Title',
description='Updated description',
notes='Updated notes',
unread=True,
shared=True,
is_archived=True)
@@ -33,6 +56,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title)
self.assertEqual(updated_bookmark.description, bookmark_data.description)
self.assertEqual(updated_bookmark.notes, bookmark_data.notes)
self.assertEqual(updated_bookmark.unread, bookmark_data.unread)
self.assertEqual(updated_bookmark.shared, bookmark_data.shared)
# Saving a duplicate bookmark should not modify archive flag - right?
@@ -45,6 +69,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()
@@ -63,11 +94,21 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
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:
@@ -77,6 +118,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
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,6 +1,7 @@
import datetime
from dataclasses import dataclass
from unittest.mock import patch
from typing import Any
from unittest import mock
import waybackpy
from background_task.models import Task
@@ -8,21 +9,21 @@ from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError
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 = 'https://example.com/created_snapshot', fail_on_save: bool = False):
self.archive_url = archive_url
self.fail_on_save = fail_on_save
def create_wayback_machine_save_api_mock(archive_url: str = 'https://example.com/created_snapshot',
fail_on_save: bool = False):
mock_api = mock.Mock(archive_url=archive_url)
def save(self):
if self.fail_on_save:
raise WaybackError
return self
if fail_on_save:
mock_api.save.side_effect = WaybackError
return mock_api
@dataclass
@@ -31,21 +32,18 @@ class MockCdxSnapshot:
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 create_cdx_server_api_mock(archive_url: str | None = 'https://example.com/newest_snapshot',
fail_loading_snapshot=False):
mock_api = mock.Mock()
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())
if fail_loading_snapshot:
mock_api.newest.side_effect = WaybackError
elif archive_url:
mock_api.newest.return_value = MockCdxSnapshot(archive_url, datetime.datetime.now())
else:
mock_api.newest.return_value = None
return mock_api
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
@@ -53,49 +51,55 @@ 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):
def run_pending_task(self, task_function: Any):
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()
@disable_logging
def run_all_pending_tasks(self, task_function):
def run_all_pending_tasks(self, task_function: Any):
func = getattr(task_function, 'task_function', None)
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()
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=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()
mock_save_api.save.assert_called_once()
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()) as mock_save_api:
mock_save_api = create_wayback_machine_save_api_mock()
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_save_api.assert_not_called()
mock_save_api.save.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')
mock_save_api = create_wayback_machine_save_api_mock()
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=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)
@@ -103,9 +107,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
mock_save_api = create_wayback_machine_save_api_mock(archive_url='https://other.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI('https://other.com')):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
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()
@@ -114,24 +118,27 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_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()
mock_cdx_api.newest.assert_called_once()
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()
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
@@ -140,51 +147,78 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_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)
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock()
# update bookmark during API call to check that saving
# the snapshot does not overwrite updated bookmark data
def mock_save_impl():
bookmark.title = 'Updated title'
bookmark.save()
mock_save_api.save.side_effect = mock_save_impl
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=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.title, 'Updated title')
self.assertEqual('https://example.com/created_snapshot', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
bookmark.refresh_from_db()
mock_cdx_api.newest.assert_called_once()
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 patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
mock_cdx_api = create_cdx_server_api_mock()
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=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()
mock_cdx_api.newest.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')
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=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()
mock_cdx_api.newest.assert_not_called()
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
@@ -192,14 +226,37 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
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_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock()
# update bookmark during API call to check that saving
# the snapshot does not overwrite updated bookmark data
def mock_newest_impl():
bookmark.title = 'Updated title'
bookmark.save()
return mock.DEFAULT
mock_cdx_api.newest.side_effect = mock_newest_impl
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual('Updated title', bookmark.title)
self.assertEqual('https://example.com/newest_snapshot', 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()
@@ -262,3 +319,177 @@ 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()
def test_load_favicon_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
# update bookmark during API call to check that saving
# the favicon does not overwrite updated bookmark data
def mock_load_favicon_impl(url):
bookmark.title = 'Updated title'
bookmark.save()
return 'https_example_com.png'
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.side_effect = mock_load_favicon_impl
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.title, 'Updated title')
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
@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,54 @@
from django.test import TestCase
from django.utils import timezone
from bookmarks.services import exporter
from bookmarks.tests.helpers import BookmarkFactoryMixin
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
def test_export_bookmarks(self):
added = timezone.now()
timestamp = int(added.timestamp())
bookmarks = [
self.setup_bookmark(url='https://example.com/1', title='Title 1', added=added,
description='Example description'),
self.setup_bookmark(url='https://example.com/2', title='Title 2', added=added,
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2'),
self.setup_tag(name='tag3')]),
self.setup_bookmark(url='https://example.com/3', title='Title 3', added=added, unread=True),
self.setup_bookmark(url='https://example.com/4', title='Title 4', added=added, shared=True),
]
html = exporter.export_netscape_html(bookmarks)
lines = [
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
'<DD>Example description',
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
]
self.assertIn('\n\r'.join(lines), html)
def test_escape_html_in_title_and_description(self):
bookmark = self.setup_bookmark(
title='<style>: The Style Information element',
description='The <style> HTML element contains style information for a document, or part of a document.'
)
html = exporter.export_netscape_html([bookmark])
self.assertIn('&lt;style&gt;: The Style Information element', html)
self.assertIn(
'The &lt;style&gt; HTML element contains style information for a document, or part of a document.',
html
)
def test_handle_empty_values(self):
bookmark = self.setup_bookmark()
bookmark.title = ''
bookmark.description = ''
bookmark.website_title = None
bookmark.website_description = None
bookmark.save()
exporter.export_netscape_html([bookmark])

View File

@@ -0,0 +1,176 @@
import io
import os.path
import time
from pathlib import Path
from unittest import mock, skip
from django.conf import settings
from django.test import TestCase, override_settings
from bookmarks.services import favicon_loader
mock_icon_data = b'mock_icon'
class MockStreamingResponse:
def __init__(self, data=mock_icon_data, content_type='image/png'):
self.chunks = [data]
self.headers = {'Content-Type': content_type}
def iter_content(self, **kwargs):
return self.chunks
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
pass
class FaviconLoaderTestCase(TestCase):
def setUp(self) -> None:
self.ensure_favicon_folder()
self.clear_favicon_folder()
def create_mock_response(self, icon_data=mock_icon_data, content_type='image/png'):
mock_response = mock.Mock()
mock_response.raw = io.BytesIO(icon_data)
return MockStreamingResponse(icon_data, content_type)
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_file = favicon_loader.load_favicon('https://example.com')
mock_get.assert_called()
self.assertEqual(favicon_file, 'https_example_com.png')
mock_get.reset_mock()
updated_favicon_file = favicon_loader.load_favicon('https://example.com')
mock_get.assert_not_called()
self.assertEqual(favicon_file, updated_favicon_file)
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'))
@override_settings(LD_FAVICON_PROVIDER='https://custom.icons.com/?url={url}')
def test_custom_provider_with_url_param(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com/foo?bar=baz')
mock_get.assert_called_with('https://custom.icons.com/?url=https://example.com', stream=True)
@override_settings(LD_FAVICON_PROVIDER='https://custom.icons.com/?url={domain}')
def test_custom_provider_with_domain_param(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com/foo?bar=baz')
mock_get.assert_called_with('https://custom.icons.com/?url=example.com', stream=True)
def test_guess_file_extension(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response(content_type='image/png')
favicon_loader.load_favicon('https://example.com')
self.assertTrue(self.icon_exists('https_example_com.png'))
self.clear_favicon_folder()
self.ensure_favicon_folder()
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response(content_type='image/x-icon')
favicon_loader.load_favicon('https://example.com')
self.assertTrue(self.icon_exists('https_example_com.ico'))

View File

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

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

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

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