Compare commits

..

89 Commits

Author SHA1 Message Date
Sascha Ißbrücker
e487cf726a Bump version 2025-05-17 10:53:04 +02:00
Bastian
f2800efc1a Allow pre-filling tags in new bookmark form (#1060)
* feat - Allow tag_string as query for BookmarkForm in order to set tags via bookmark snippets

* add test

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-05-17 10:13:07 +02:00
Johannes Zorn
9a00ae4b93 Add opensearch declaration (#1058)
* feat: Add opensearch declaration

* cleanup

---------

Co-authored-by: Johannes Zorn <johannes.zorn@zollsoft.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-05-17 09:52:26 +02:00
dependabot[bot]
da9371e33c Bump django from 5.1.8 to 5.1.9 (#1059)
Bumps [django](https://github.com/django/django) from 5.1.8 to 5.1.9.
- [Commits](https://github.com/django/django/compare/5.1.8...5.1.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 09:03:58 +02:00
Jakob Krigovsky
5b3f2f6563 Linkify plain URLs in notes (#1051)
* Linkify plain URLs in notes

* add test case

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-05-17 09:03:40 +02:00
Kazi
04065f8079 Add how-to for using linkding PWA in native Android share sheet (#1055) 2025-05-17 08:58:29 +02:00
Vincent Ging Ho Yim
d986ff0900 Fix typo in index.mdx tagline (#1052) 2025-05-17 08:56:42 +02:00
dependabot[bot]
51a85bbaf1 Bump esbuild, @astrojs/starlight and astro in /docs (#1037)
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.25.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@astrojs/starlight](https://github.com/withastro/starlight/tree/HEAD/packages/starlight) and [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.25.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.2)

Updates `@astrojs/starlight` from 0.27.1 to 0.32.5
- [Release notes](https://github.com/withastro/starlight/releases)
- [Changelog](https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md)
- [Commits](https://github.com/withastro/starlight/commits/@astrojs/starlight@0.32.5/packages/starlight)

Updates `astro` from 4.16.18 to 5.6.0
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.6.0/packages/astro)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.2
  dependency-type: indirect
- dependency-name: "@astrojs/starlight"
  dependency-version: 0.32.5
  dependency-type: direct:production
- dependency-name: astro
  dependency-version: 5.6.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:48:00 +02:00
dependabot[bot]
39b911880d Bump vite from 5.4.14 to 5.4.17 in /docs (#1036)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.14 to 5.4.17.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.17/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:33:58 +02:00
dependabot[bot]
9db3fa1248 Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs (#1035)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.26.7 to 7.27.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-version: 7.27.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:33:44 +02:00
dependabot[bot]
77689366a0 Bump prismjs from 1.29.0 to 1.30.0 in /docs (#1034)
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.29.0 to 1.30.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-version: 1.30.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 08:33:31 +02:00
Cayce House
f2e6014ca4 Push Docker images to GHCR in addition to Docker Hub (#1024) 2025-05-17 08:33:11 +02:00
haondt
da98929f07 Adding linktiles to community projects (#1025)
* added linktiles link

* switched to github link
2025-05-17 08:26:03 +02:00
Sascha Ißbrücker
1b0684bd6c Allow auto tagging rules to match URL fragments (#1045) 2025-04-13 09:43:11 +02:00
dependabot[bot]
8928c78530 Bump tar-fs in /docs (#1028)
Bumps  and [tar-fs](https://github.com/mafintosh/tar-fs). These dependencies needed to be updated together.

Updates `tar-fs` from 2.1.1 to 3.0.8
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.1...v3.0.8)

Updates `tar-fs` from 3.0.6 to 3.0.8
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.1...v3.0.8)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-type: indirect
- dependency-name: tar-fs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 21:17:32 +02:00
dependabot[bot]
61108234b4 Bump django from 5.1.7 to 5.1.8 (#1030)
Bumps [django](https://github.com/django/django) from 5.1.7 to 5.1.8.
- [Commits](https://github.com/django/django/compare/5.1.7...5.1.8)

---
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>
2025-04-03 21:17:19 +02:00
Sascha Ißbrücker
7b098d4549 Fix bookmark asset download endpoint (#1033) 2025-04-03 21:16:59 +02:00
Sascha Ißbrücker
648e67bd91 Update troubleshooting.md 2025-03-22 22:19:23 +01:00
Sascha Ißbrücker
6bba4f35c8 Prefer local snapshot over web archive link in bookmark list links (#1021)
* Prefer local snapshot over web archive link

* Update latest snapshot when it is deleted

* fix filter in migration

* improve migration performance
2025-03-22 19:07:05 +01:00
Sascha Ißbrücker
6d9d0e19f1 Add E2E tests for refresh button 2025-03-22 12:16:48 +01:00
Josh S
a23c357f2f Add bulk and single bookmark metadata refresh (#999)
* Add url create/edit query paramter to clear cache

* Add refresh bookmark metadata button in create/edit bookmark page

* Fix refresh bookmark metadata when editing existing bookmark

* Add bulk refresh metadata functionality

* Fix test cases for bulk view dropdown selection list

* Allow bulk metadata refresh when background tasks are disabled

* Move load preview image call on refresh metadata

* Update bookmark modified time on metadata refresh

* Rename function to align with convention

* Add tests for refresh task

* Add tests for bookmarks service refresh metadata

* Add tests for bookmarks api disable cache on check

* Remove bulk refresh metadata when background tasks disabled

* Refactor refresh metadata task

* Remove unnecessary call

* Fix testing mock name

* Abstract clearing metadata cache

* Add test to check if load page is called twice when cache disabled

* Remove refresh button for new bookmarks

* Remove strict disable cache is true check

* Refactor refresh metadata form logic into its own function

* move button and highlight changes

* polish and update tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-03-22 11:34:10 +01:00
Jose Alvarez
f1acb4f7c9 Handle lowercase "true" in environment variables (#1020) 2025-03-22 08:27:35 +01:00
Sascha Ißbrücker
48fc499aed Add test for OIDC login link 2025-03-19 19:27:59 +01:00
Stefan Foerster
2a55800e18 Fix OIDC login link (#1019)
Fixes #1016.
2025-03-19 19:25:30 +01:00
Sascha Ißbrücker
e45dffb9cb Improve announcements after navigation (#1015) 2025-03-16 12:24:25 +01:00
Sascha Ißbrücker
226eb69f8b Accessibility improvements in page structure (#1014)
* Change app link to not use heading

* Use main and h1 for main content

* Update settings page structure

* Fix responsive styles

* Update bookmark form page structure

* Update auth page structure

* Add some basic page titles

* Expose side panel section

* Add page title for bookmark details

* Expose more sections

* Improve region names
2025-03-16 10:25:01 +01:00
Sascha Ißbrücker
b9bee24047 Move more logic into bookmark form 2025-03-09 23:11:48 +01:00
Sascha Ißbrücker
9dfc9b03b4 Fix E2E job 2025-03-09 12:30:10 +01:00
Sascha Ißbrücker
6ab6a031c7 Extract access checks 2025-03-09 12:21:22 +01:00
Sascha Ißbrücker
1a1092d03a Fix some type hints 2025-03-09 11:30:13 +01:00
Sascha Ißbrücker
4260dfce79 Remove duplicate URL calls in settings nav 2025-03-09 05:53:55 +01:00
Sascha Ißbrücker
2d3bd13a12 Merge siteroot application 2025-03-09 05:50:05 +01:00
Sascha Ißbrücker
b037de14c9 Move e2e tests 2025-03-09 05:46:26 +01:00
Sascha Ißbrücker
bbf173c135 Remove some duplication in bookmark routes 2025-03-09 05:45:50 +01:00
dependabot[bot]
002fec37d0 Bump django from 5.1.5 to 5.1.7 (#1007)
Bumps [django](https://github.com/django/django) from 5.1.5 to 5.1.7.
- [Commits](https://github.com/django/django/compare/5.1.5...5.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>
2025-03-08 09:21:10 +01:00
Sascha Ißbrücker
996e2b6e19 Add docs for auto tagging (#1009) 2025-03-08 09:20:35 +01:00
Sascha Ißbrücker
6838e45e99 Update api.md 2025-03-06 21:44:10 +01:00
Sascha Ißbrücker
5b2a2c2b0d Bump version 2025-03-06 19:39:51 +01:00
Sascha Ißbrücker
988468f3e5 fix path context 2025-03-06 19:35:35 +01:00
Sascha Ißbrücker
3ac0503843 Add documentation for archiving web pages (#1004) 2025-03-06 19:13:35 +01:00
Sascha Ißbrücker
6d3755f46a Update version 2025-03-06 17:36:10 +01:00
Sascha Ißbrücker
25342e5fb6 Update version 2025-03-06 17:32:02 +01:00
Sascha Ißbrücker
be548a95a0 Update build job 2025-03-06 17:31:14 +01:00
Sascha Ißbrücker
978fba4cf5 Update build job 2025-03-06 09:48:04 +01:00
Sascha Ißbrücker
8a3572ba4b Add bookmark assets API (#1003)
* Add list, details and download endpoints

* Avoid using multiple DefaultRoute instances

* Add upload endpoint

* Add docs

* Allow configuring max request content length

* Add option for disabling uploads

* Remove gzip field

* Add delete endpoint
2025-03-06 09:09:53 +01:00
Sascha Ißbrücker
b21812c30a Update build job 2025-03-06 09:09:16 +01:00
Nick Sartor
72fbf6a590 Add linklater to community section (#1002)
Adding android client linklater to community projects md. Fixes #741
2025-03-04 11:33:56 +01:00
jvt
31ac796d6d Add Telegram bot to community section (#1001)
add my telegram bot project to send bookmarks directly to Linkding.
2025-03-04 11:31:45 +01:00
Sascha Ißbrücker
2d81ea6f6e Add REST endpoint for uploading snapshots from the Singlefile extension (#996)
* Extract asset logic

* Allow disabling HTML snapshot when creating bookmark

* Add endpoint for uploading singlefile snapshots

* Add URL parameter to disable HTML snapshots

* Allow using asset list in base Docker image

* Expose app version through profile
2025-02-23 22:58:14 +01:00
Sascha Ißbrücker
2e97b13bad Allow providing REST API authentication token with Bearer keyword (#995) 2025-02-22 19:59:53 +01:00
Sascha Ißbrücker
30f85103cd Update CHANGELOG.md 2025-02-22 19:51:00 +01:00
Sascha Ißbrücker
cfe4ff113d Bump version 2025-02-22 19:28:47 +01:00
Sascha Ißbrücker
757dc56277 Bump base images 2025-02-19 16:14:34 +01:00
Sascha Ißbrücker
dfbb367857 Fix auth proxy logout (#994) 2025-02-19 07:27:04 +01:00
Sascha Ißbrücker
2276832465 Return web archive fallback URL from REST API (#993) 2025-02-19 06:44:21 +01:00
Chris M
9d61bdce52 Add note about OIDC and LD_SUPERUSER_NAME combination (#992)
* docs: add note about OIDC and LD_SUPERUSER_NAME combination

Resolves #988

* tweak text

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-02-18 22:45:26 +01:00
Sascha Ißbrücker
1274a9ae0a Try limit uwsgi memory usage by configuring file descriptor limit (#990) 2025-02-15 08:49:58 +01:00
Sascha Ißbrücker
5e7172d17e Remove preview image when bookmark is deleted (#989) 2025-02-15 08:26:58 +01:00
Sascha Ißbrücker
78608135d9 Update CHANGELOG.md 2025-02-09 10:47:02 +01:00
Sascha Ißbrücker
51acd1da3f add build script 2025-02-08 18:20:15 +01:00
Sascha Ißbrücker
016ff2da66 Bump version 2025-02-08 10:54:46 +01:00
Sascha Ißbrücker
77d7e6e66a Add RSS link to shared bookmarks page (#984) 2025-02-08 10:51:17 +01:00
Sascha Ißbrücker
c5a300a435 Convert tag modal into drawer (#977)
* change tag modal into drawer

* improve scroll handling

* teleport all side bar content

* improve naming

* fix focus trap in filter drawer
2025-02-02 20:42:28 +01:00
Sascha Ißbrücker
0d4c47eb81 Add option to collapse side panel (#975) 2025-02-02 11:28:35 +01:00
Sascha Ißbrücker
17442eeb9a Improve accessibility of modal dialogs (#974)
* improve details modal accessibility

* improve tag modal accessibility

* fix overlays in archive and shared pages

* update tests

* use buttons for closing dialogs

* replace description list

* hide preview image from screen readers

* update tests
2025-02-02 00:28:17 +01:00
Kyuuk
2973812626 Allow customizing username when creating user through OIDC (#971)
* add ability to cutomize claim user for username generation on oidc login

* update documentation with new OIDC options

* oidc: also normalize custom claim as username

* improve tests

* improve docs

* some more cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-01-30 03:40:52 +01:00
Josh Dick
fc48b266a8 Add Additional iOS Shortcut to community section (#968) 2025-01-28 13:29:44 +01:00
Sascha Ißbrücker
7b42241026 Fix nav menu closing on mousedown in Safari (#965) 2025-01-27 09:05:19 +01:00
Sascha Ißbrücker
9c648dc67f Update CHANGELOG.md 2025-01-27 00:02:14 +01:00
Sascha Ißbrücker
1624128132 Bump versions to make Docker build 2025-01-26 23:52:36 +01:00
Sascha Ißbrücker
d1dd85538b Bump version 2025-01-26 19:19:48 +01:00
dependabot[bot]
c5aab3886e Bump nanoid from 3.3.7 to 3.3.8 in /docs (#962)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:09:02 +01:00
dependabot[bot]
3f2739e5a6 Bump astro from 4.16.3 to 4.16.18 in /docs (#929)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.3 to 4.16.18.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/astro@4.16.18/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.18/packages/astro)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:08:50 +01:00
dependabot[bot]
f1ed89a0ba Bump nanoid from 3.3.7 to 3.3.8 (#928)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:51:40 +01:00
dependabot[bot]
a59a7a777c Bump django from 5.1.1 to 5.1.5 (#947)
Bumps [django](https://github.com/django/django) from 5.1.1 to 5.1.5.
- [Commits](https://github.com/django/django/compare/5.1.1...5.1.5)

---
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>
2025-01-26 18:50:51 +01:00
dependabot[bot]
9a5c535872 Bump vite from 5.4.9 to 5.4.14 in /docs (#953)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.9 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:34 +01:00
Sascha Ißbrücker
e6ebca1436 Add default robots.txt to block crawlers (#959) 2025-01-26 09:58:58 +01:00
Justus Grunow
085d67e9f4 Update community.md (#897)
Added link to iOS App Store for Linkdy.

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:10:16 +01:00
Rostislav
68825444fb Add a rust client library to community.md (#914)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:04:28 +01:00
Sebastien Wains
b2ca16ec9c Update community.md (#949) 2025-01-15 22:02:23 +01:00
Sascha Ißbrücker
649f4154e5 Provide accessible name to radio groups (#945) 2025-01-12 22:10:14 +01:00
Sascha Ißbrücker
d2e8a95e3c Fix menu dropdown focus traps (#944) 2025-01-11 12:44:20 +01:00
Sascha Ißbrücker
c3149409b0 Fix how to for changing font size 2024-10-24 22:24:58 +02:00
Vadim Khitrin
4626fa1c67 docs: Add cosmicding To Community Resources (#892)
* docs: Add cosmicding To Community Resources

* sort alphabetically

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-10-24 22:16:22 +02:00
Dany Marcoux
6548e16baa Add option to disable request logs (#887)
Closes #785
2024-10-17 21:47:56 +02:00
dependabot[bot]
c177de164a Bump astro from 4.15.8 to 4.16.3 in /docs (#884)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.15.8 to 4.16.3.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.3/packages/astro)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-16 21:08:46 +02:00
Xuanli
e9ecad38ac Add serchding to community projects (#880) 2024-10-12 18:13:50 +02:00
Sascha Ißbrücker
621aedd8eb Update donations 2024-10-04 18:43:02 +02:00
Sascha Ißbrücker
4187141ac8 Update CHANGELOG.md 2024-10-03 00:09:51 +02:00
182 changed files with 7563 additions and 4278 deletions

View File

@@ -3,7 +3,6 @@
# Include files required for build or at runtime # Include files required for build or at runtime
!/bookmarks !/bookmarks
!/siteroot
!/bootstrap.sh !/bootstrap.sh
!/LICENSE.txt !/LICENSE.txt
@@ -19,4 +18,4 @@
!/version.txt !/version.txt
# Remove dev settings # Remove dev settings
/siteroot/settings/dev.py /bookmarks/settings/dev.py

89
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,89 @@
name: build
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version from file
id: get_version
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build latest
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest
sissbruecker/linkding:${{ env.VERSION }}
ghcr.io/sissbruecker/linkding:latest
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
target: linkding
push: true
- name: Build latest-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-alpine
sissbruecker/linkding:${{ env.VERSION }}-alpine
ghcr.io/sissbruecker/linkding:latest-alpine
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
target: linkding
push: true
- name: Build latest-plus
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus
sissbruecker/linkding:${{ env.VERSION }}-plus
ghcr.io/sissbruecker/linkding:latest-plus
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
target: linkding-plus
push: true
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus-alpine
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
ghcr.io/sissbruecker/linkding:latest-plus-alpine
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
target: linkding-plus
push: true

View File

@@ -55,4 +55,4 @@ jobs:
npm run build npm run build
python manage.py collectstatic python manage.py collectstatic
- name: Run tests - name: Run tests
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py" run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"

View File

@@ -1,6 +1,95 @@
# Changelog # Changelog
## (23/09/2024) ## v1.38.1 (22/02/2025)
### What's Changed
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
### New Contributors
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
---
## v1.38.0 (09/02/2025)
### What's Changed
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
### New Contributors
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
---
## v1.37.0 (26/01/2025)
### What's Changed
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
### New Contributors
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
---
## v1.36.0 (02/10/2024)
### What's Changed
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
### New Contributors
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
---
## v1.35.0 (23/09/2024)
### What's Changed ### What's Changed
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835 * Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835

View File

@@ -4,13 +4,12 @@ serve:
python manage.py runserver python manage.py runserver
tasks: tasks:
python manage.py process_tasks python manage.py run_huey
test: test:
pytest -n auto pytest -n auto
format: format:
black bookmarks black bookmarks
black siteroot
npx prettier bookmarks/frontend --write npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write npx prettier bookmarks/styles --write

View File

@@ -58,7 +58,7 @@ Small improvements, bugfixes and documentation improvements are always welcome.
## Development ## Development
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites ### Prerequisites
- Python 3.12 - Python 3.12

34
bookmarks/api/auth.py Normal file
View File

@@ -0,0 +1,34 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication, get_authorization_header
class LinkdingTokenAuthentication(TokenAuthentication):
"""
Extends DRF TokenAuthentication to add support for multiple keywords
"""
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() not in self.keywords:
return None
if len(auth) == 1:
msg = _("Invalid token header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _("Invalid token header. Token string should not contain spaces.")
raise exceptions.AuthenticationFailed(msg)
try:
token = auth[1].decode()
except UnicodeError:
msg = _(
"Invalid token header. Token string should not contain invalid characters."
)
raise exceptions.AuthenticationFailed(msg)
return self.authenticate_credentials(token)

View File

@@ -1,25 +1,26 @@
import gzip
import logging import logging
import os
from django.conf import settings
from django.http import Http404, StreamingHttpResponse
from rest_framework import viewsets, mixins, status from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import DefaultRouter from rest_framework.routers import SimpleRouter, DefaultRouter
from bookmarks import queries from bookmarks import queries
from bookmarks.api.serializers import ( from bookmarks.api.serializers import (
BookmarkSerializer, BookmarkSerializer,
BookmarkAssetSerializer,
TagSerializer, TagSerializer,
UserProfileSerializer, UserProfileSerializer,
) )
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
from bookmarks.services import auto_tagging from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
from bookmarks.services.bookmarks import ( from bookmarks.type_defs import HttpRequest
archive_bookmark, from bookmarks.views import access
unarchive_bookmark,
website_loader,
)
from bookmarks.services.website_loader import WebsiteMetadata
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,6 +33,7 @@ class BookmarkViewSet(
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
): ):
request: HttpRequest
serializer_class = BookmarkSerializer serializer_class = BookmarkSerializer
def get_permissions(self): def get_permissions(self):
@@ -46,67 +48,63 @@ class BookmarkViewSet(
return super().get_permissions() return super().get_permissions()
def get_queryset(self): def get_queryset(self):
# Provide filtered queryset for list actions
user = self.request.user user = self.request.user
# For list action, use query set that applies search and tag projections search = BookmarkSearch.from_request(self.request.GET)
if self.action == "list": if self.action == "list":
search = BookmarkSearch.from_request(self.request.GET)
return queries.query_bookmarks(user, user.profile, search) return queries.query_bookmarks(user, user.profile, search)
elif self.action == "archived":
return queries.query_archived_bookmarks(user, user.profile, search)
elif self.action == "shared":
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, search, public_only
)
# For single entity actions use default query set without projections # For single entity actions return user owned bookmarks
return Bookmark.objects.all().filter(owner=user) return Bookmark.objects.all().filter(owner=user)
def get_serializer_context(self): def get_serializer_context(self):
disable_scraping = "disable_scraping" in self.request.GET disable_scraping = "disable_scraping" in self.request.GET
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
return { return {
"request": self.request, "request": self.request,
"user": self.request.user, "user": self.request.user,
"disable_scraping": disable_scraping, "disable_scraping": disable_scraping,
"disable_html_snapshot": disable_html_snapshot,
} }
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def archived(self, request): def archived(self, request: HttpRequest):
user = request.user return self.list(request)
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def shared(self, request): def shared(self, request: HttpRequest):
search = BookmarkSearch.from_request(request.GET) return self.list(request)
user = User.objects.filter(username=search.user).first()
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(
user, request.user_profile, search, public_only
)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["post"], detail=True) @action(methods=["post"], detail=True)
def archive(self, request, pk): def archive(self, request: HttpRequest, pk):
bookmark = self.get_object() bookmark = self.get_object()
archive_bookmark(bookmark) bookmarks.archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["post"], detail=True) @action(methods=["post"], detail=True)
def unarchive(self, request, pk): def unarchive(self, request: HttpRequest, pk):
bookmark = self.get_object() bookmark = self.get_object()
unarchive_bookmark(bookmark) bookmarks.unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def check(self, request): def check(self, request: HttpRequest):
url = request.GET.get("url") url = request.GET.get("url")
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = ( existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None self.get_serializer(bookmark).data if bookmark else None
) )
metadata = website_loader.load_website_metadata(url) metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)
# Return tags that would be automatically applied to the bookmark # Return tags that would be automatically applied to the bookmark
profile = request.user.profile profile = request.user.profile
@@ -129,6 +127,119 @@ class BookmarkViewSet(
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@action(methods=["post"], detail=False)
def singlefile(self, request: HttpRequest):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
url = request.POST.get("url")
file = request.FILES.get("file")
if not url or not file:
return Response(
{"error": "Both 'url' and 'file' parameters are required."},
status=status.HTTP_400_BAD_REQUEST,
)
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
if not bookmark:
bookmark = Bookmark(url=url)
bookmark = bookmarks.create_bookmark(
bookmark, "", request.user, disable_html_snapshot=True
)
bookmarks.enhance_with_website_metadata(bookmark)
assets.upload_snapshot(bookmark, file.read())
return Response(
{"message": "Snapshot uploaded successfully."},
status=status.HTTP_201_CREATED,
)
class BookmarkAssetViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
request: HttpRequest
serializer_class = BookmarkAssetSerializer
def get_queryset(self):
user = self.request.user
# limit access to assets to the owner of the bookmark for now
bookmark = access.bookmark_write(self.request, self.kwargs["bookmark_id"])
return BookmarkAsset.objects.filter(
bookmark_id=bookmark.id, bookmark__owner=user
)
def get_serializer_context(self):
return {"user": self.request.user}
@action(detail=True, methods=["get"], url_path="download")
def download(self, request: HttpRequest, bookmark_id, pk):
asset = self.get_object()
try:
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
content_type = asset.content_type
file_stream = (
gzip.GzipFile(file_path, mode="rb")
if asset.gzip
else open(file_path, "rb")
)
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
)
response = StreamingHttpResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
return response
except FileNotFoundError:
raise Http404("Asset file does not exist")
except Exception as e:
logger.error(
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
exc_info=e,
)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(methods=["post"], detail=False)
def upload(self, request: HttpRequest, bookmark_id):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
bookmark = access.bookmark_write(request, bookmark_id)
upload_file = request.FILES.get("file")
if not upload_file:
return Response(
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
)
try:
asset = assets.upload_asset(bookmark, upload_file)
serializer = self.get_serializer(asset)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
exc_info=e,
)
return Response(
{"error": "Failed to upload asset."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
def perform_destroy(self, instance):
assets.remove_asset(instance)
class TagViewSet( class TagViewSet(
viewsets.GenericViewSet, viewsets.GenericViewSet,
@@ -136,6 +247,7 @@ class TagViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.CreateModelMixin, mixins.CreateModelMixin,
): ):
request: HttpRequest
serializer_class = TagSerializer serializer_class = TagSerializer
def get_queryset(self): def get_queryset(self):
@@ -148,11 +260,23 @@ class TagViewSet(
class UserViewSet(viewsets.GenericViewSet): class UserViewSet(viewsets.GenericViewSet):
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def profile(self, request): def profile(self, request: HttpRequest):
return Response(UserProfileSerializer(request.user.profile).data) return Response(UserProfileSerializer(request.user.profile).data)
router = DefaultRouter() # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark") # Instead create separate routers for each view set and manually register them in urls.py
router.register(r"tags", TagViewSet, basename="tag") # The default router is only used to allow reversing a URL for the API root
router.register(r"user", UserViewSet, basename="user") default_router = DefaultRouter()
bookmark_router = SimpleRouter()
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
tag_router = SimpleRouter()
tag_router.register("", TagViewSet, basename="tag")
user_router = SimpleRouter()
user_router.register("", UserViewSet, basename="user")
bookmark_asset_router = SimpleRouter()
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")

View File

@@ -3,13 +3,11 @@ from django.templatetags.static import static
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ListSerializer from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
from bookmarks.services.bookmarks import ( from bookmarks.services import bookmarks
create_bookmark,
update_bookmark,
enhance_with_website_metadata,
)
from bookmarks.services.tags import get_or_create_tag from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version
class TagListField(serializers.ListField): class TagListField(serializers.ListField):
@@ -24,6 +22,11 @@ class BookmarkListSerializer(ListSerializer):
return super().to_representation(data) return super().to_representation(data)
class EmtpyField(serializers.ReadOnlyField):
def to_representation(self, value):
return None
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Bookmark model = Bookmark
@@ -59,12 +62,13 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Custom tag_names field to allow passing a list of tag names to create/update # Custom tag_names field to allow passing a list of tag names to create/update
tag_names = TagListField(required=False) tag_names = TagListField(required=False)
# Custom fields to return URLs for favicon and preview image # Custom fields to generate URLs for favicon, preview image, and web archive snapshot
favicon_url = serializers.SerializerMethodField() favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField() preview_image_url = serializers.SerializerMethodField()
web_archive_snapshot_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty # Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = serializers.SerializerMethodField() website_title = EmtpyField()
website_description = serializers.SerializerMethodField() website_description = EmtpyField()
def get_favicon_url(self, obj: Bookmark): def get_favicon_url(self, obj: Bookmark):
if not obj.favicon_file: if not obj.favicon_file:
@@ -82,23 +86,31 @@ class BookmarkSerializer(serializers.ModelSerializer):
preview_image_url = request.build_absolute_uri(preview_image_file_path) preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url return preview_image_url
def get_website_title(self, obj: Bookmark): def get_web_archive_snapshot_url(self, obj: Bookmark):
return None if obj.web_archive_snapshot_url:
return obj.web_archive_snapshot_url
def get_website_description(self, obj: Bookmark): return generate_fallback_webarchive_url(obj.url, obj.date_added)
return None
def create(self, validated_data): def create(self, validated_data):
tag_names = validated_data.pop("tag_names", []) tag_names = validated_data.pop("tag_names", [])
tag_string = build_tag_string(tag_names) tag_string = build_tag_string(tag_names)
bookmark = Bookmark(**validated_data) bookmark = Bookmark(**validated_data)
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"]) disable_scraping = self.context.get("disable_scraping", False)
disable_html_snapshot = self.context.get("disable_html_snapshot", False)
saved_bookmark = bookmarks.create_bookmark(
bookmark,
tag_string,
self.context["user"],
disable_html_snapshot=disable_html_snapshot,
)
# Unless scraping is explicitly disabled, enhance bookmark with website # Unless scraping is explicitly disabled, enhance bookmark with website
# metadata to preserve backwards compatibility with clients that expect # metadata to preserve backwards compatibility with clients that expect
# title and description to be populated automatically when left empty # title and description to be populated automatically when left empty
if not self.context.get("disable_scraping", False): if not disable_scraping:
enhance_with_website_metadata(saved_bookmark) bookmarks.enhance_with_website_metadata(saved_bookmark)
return saved_bookmark return saved_bookmark
def update(self, instance: Bookmark, validated_data): def update(self, instance: Bookmark, validated_data):
@@ -109,7 +121,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
if not field.read_only and field_name in validated_data: if not field.read_only and field_name in validated_data:
setattr(instance, field_name, validated_data[field_name]) setattr(instance, field_name, validated_data[field_name])
return update_bookmark(instance, tag_string, self.context["user"]) return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
def validate(self, attrs): def validate(self, attrs):
# When creating a bookmark, the service logic prevents duplicate URLs by # When creating a bookmark, the service logic prevents duplicate URLs by
@@ -130,6 +142,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
return attrs return attrs
class BookmarkAssetSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkAsset
fields = [
"id",
"bookmark",
"date_created",
"file_size",
"asset_type",
"content_type",
"display_name",
"status",
]
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Tag model = Tag
@@ -155,4 +182,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
"display_url", "display_url",
"permanent_notes", "permanent_notes",
"search_preferences", "search_preferences",
"version",
] ]
version = serializers.ReadOnlyField(default=app_version)

View File

@@ -1,6 +1,5 @@
from bookmarks import queries
from bookmarks.models import BookmarkSearch, Toast
from bookmarks import utils from bookmarks import utils
from bookmarks.models import Toast
def toasts(request): def toasts(request):

View File

@@ -1,166 +0,0 @@
from unittest.mock import patch
from urllib.parse import quote
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.services import website_loader
mock_website_metadata = website_loader.WebsiteMetadata(
url="https://example.com",
title="Example Domain",
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
preview_image=None,
)
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def setUp(self) -> None:
super().setUp()
self.website_loader_patch = patch.object(
website_loader, "load_website_metadata", return_value=mock_website_metadata
)
self.website_loader_patch.start()
def tearDown(self) -> None:
super().tearDown()
self.website_loader_patch.stop()
def test_enter_url_prefills_title_and_description(self):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
url.fill("https://example.com")
expect(title).to_have_value("Example Domain")
expect(description).to_have_value(
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
)
def test_enter_url_does_not_overwrite_modified_title_and_description(self):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
title.fill("Modified title")
description.fill("Modified description")
url.fill("https://example.com")
page.wait_for_timeout(timeout=1000)
expect(title).to_have_value("Modified title")
expect(description).to_have_value("Modified description")
def test_with_initial_url_prefills_title_and_description(self):
with sync_playwright() as p:
page_url = reverse("bookmarks:new") + f"?url={quote('https://example.com')}"
page = self.open(page_url, p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
page.wait_for_timeout(timeout=1000)
expect(url).to_have_value("https://example.com")
expect(title).to_have_value("Example Domain")
expect(description).to_have_value(
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
)
def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
self,
):
with sync_playwright() as p:
page_url = (
reverse("bookmarks:new")
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
)
page = self.open(page_url, p)
url = page.get_by_label("URL")
title = page.get_by_label("Title")
description = page.get_by_label("Description")
page.wait_for_timeout(timeout=1000)
expect(url).to_have_value("https://example.com")
expect(title).to_have_value("Initial title")
expect(description).to_have_value("Initial description")
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")],
unread=True,
)
tag_names = " ".join(existing_bookmark.tag_names)
with sync_playwright() as p:
page = self.open(reverse("bookmarks:new"), p)
# 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(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
)
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:
page = self.open(reverse("bookmarks:new"), p)
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="")
def test_create_should_preview_auto_tags(self):
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = "github.com dev github"
profile.save()
with sync_playwright() as p:
# Open page with URL that should have auto tags
url = (
reverse("bookmarks:new")
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
)
page = self.open(url, p)
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
expect(auto_tags_hint).to_be_visible()
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
# Change to URL without auto tags
page.get_by_label("URL").fill("https://example.com")
expect(auto_tags_hint).to_be_hidden()

View File

@@ -74,7 +74,7 @@ class AllBookmarksFeed(BaseBookmarksFeed):
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search) return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.all", args=[context.feed_token.key]) return reverse("linkding:feeds.all", args=[context.feed_token.key])
class UnreadBookmarksFeed(BaseBookmarksFeed): class UnreadBookmarksFeed(BaseBookmarksFeed):
@@ -87,7 +87,7 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
).filter(unread=True) ).filter(unread=True)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key]) return reverse("linkding:feeds.unread", args=[context.feed_token.key])
class SharedBookmarksFeed(BaseBookmarksFeed): class SharedBookmarksFeed(BaseBookmarksFeed):
@@ -100,7 +100,7 @@ class SharedBookmarksFeed(BaseBookmarksFeed):
) )
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key]) return reverse("linkding:feeds.shared", args=[context.feed_token.key])
class PublicSharedBookmarksFeed(BaseBookmarksFeed): class PublicSharedBookmarksFeed(BaseBookmarksFeed):
@@ -114,4 +114,4 @@ class PublicSharedBookmarksFeed(BaseBookmarksFeed):
return queries.query_shared_bookmarks(None, UserProfile(), search, True) return queries.query_shared_bookmarks(None, UserProfile(), search, True)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared") return reverse("linkding:feeds.public_shared")

95
bookmarks/forms.py Normal file
View File

@@ -0,0 +1,95 @@
from django import forms
from bookmarks.models import Bookmark, build_tag_string
from bookmarks.validators import BookmarkURLValidator
from bookmarks.type_defs import HttpRequest
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = [
"url",
"tag_string",
"title",
"description",
"notes",
"unread",
"shared",
"auto_close",
]
def __init__(self, request: HttpRequest, instance: Bookmark = None):
self.request = request
initial = None
if instance is None and request.method == "GET":
initial = {
"url": request.GET.get("url"),
"title": request.GET.get("title"),
"description": request.GET.get("description"),
"notes": request.GET.get("notes"),
"tag_string": request.GET.get("tags"),
"auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread,
}
if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None
super().__init__(data, instance=instance, initial=initial)
@property
def is_auto_close(self):
return self.data.get("auto_close", False) == "True" or self.initial.get(
"auto_close", False
)
@property
def has_notes(self):
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
def save(self, commit=False):
tag_string = convert_tag_string(self.data["tag_string"])
bookmark = super().save(commit=False)
if self.instance.pk:
return update_bookmark(bookmark, tag_string, self.request.user)
else:
return create_bookmark(bookmark, tag_string, self.request.user)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings
return tag_string.replace(" ", ",")

View File

@@ -1,60 +1,22 @@
import { Behavior, registerBehavior } from "./index"; import { registerBehavior } from "./index";
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends Behavior { class DetailsModalBehavior extends ModalBehavior {
constructor(element) { doClose() {
super(element); super.doClose();
this.onClose = this.onClose.bind(this); // Navigate to close URL
this.onKeyDown = this.onKeyDown.bind(this); const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
this.overlayLink = element.querySelector("a:has(.modal-overlay)"); // Try restore focus to view details to view details link of respective bookmark
this.buttonLink = element.querySelector("a:has(button.close)"); const bookmarkId = this.element.dataset.bookmarkId;
setAfterPageLoadFocusTarget(
this.overlayLink.addEventListener("click", this.onClose); `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
this.buttonLink.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.overlayLink.removeEventListener("click", this.onClose);
this.buttonLink.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
}
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;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.element.remove();
const closeUrl = this.overlayLink.href;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
}
},
{ once: true },
); );
} }
} }

View File

@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
this.opened = false; this.opened = false;
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
// Prevent opening the dropdown automatically on focus, so that it only
// opens on click then JS is enabled
this.element.style.setProperty("--dropdown-focus-display", "none");
this.element.addEventListener("keydown", this.onEscape);
this.element.addEventListener("focusout", this.onFocusOut);
this.toggle = element.querySelector(".dropdown-toggle"); this.toggle = element.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick); this.toggle.addEventListener("click", this.onClick);
} }
destroy() { destroy() {
this.close(); this.close();
this.toggle.removeEventListener("click", this.onClick); this.toggle.removeEventListener("click", this.onClick);
this.element.removeEventListener("keydown", this.onEscape);
this.element.removeEventListener("focusout", this.onFocusOut);
} }
open() { open() {
this.opened = true;
this.element.classList.add("active"); this.element.classList.add("active");
this.toggle.setAttribute("aria-expanded", "true");
document.addEventListener("click", this.onOutsideClick); document.addEventListener("click", this.onOutsideClick);
} }
close() { close() {
this.opened = false;
this.element.classList.remove("active"); this.element.classList.remove("active");
this.toggle.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onOutsideClick); document.removeEventListener("click", this.onOutsideClick);
} }
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
this.close(); this.close();
} }
} }
onEscape(event) {
if (event.key === "Escape" && this.opened) {
event.preventDefault();
this.close();
this.toggle.focus();
}
}
onFocusOut(event) {
if (!this.element.contains(event.relatedTarget)) {
this.close();
}
}
} }
registerBehavior("ld-dropdown", DropdownBehavior); registerBehavior("ld-dropdown", DropdownBehavior);

View File

@@ -0,0 +1,97 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector(".side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector(".side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -0,0 +1,124 @@
let keyboardActive = false;
window.addEventListener(
"keydown",
() => {
keyboardActive = true;
},
{ capture: true },
);
window.addEventListener(
"mousedown",
() => {
keyboardActive = false;
},
{ capture: true },
);
export function isKeyboardActive() {
return keyboardActive;
}
export class FocusTrapController {
constructor(element) {
this.element = element;
this.focusableElements = this.element.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
);
this.firstFocusableElement = this.focusableElements[0];
this.lastFocusableElement =
this.focusableElements[this.focusableElements.length - 1];
this.onKeyDown = this.onKeyDown.bind(this);
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
if (event.key !== "Tab") {
return;
}
if (event.shiftKey) {
if (document.activeElement === this.firstFocusableElement) {
event.preventDefault();
this.lastFocusableElement.focus();
}
} else {
if (document.activeElement === this.lastFocusableElement) {
event.preventDefault();
this.firstFocusableElement.focus();
}
}
}
}
let afterPageLoadFocusTarget = [];
let firstPageLoad = true;
export function setAfterPageLoadFocusTarget(...targets) {
afterPageLoadFocusTarget = targets;
}
function programmaticFocus(element) {
// Ensure element is focusable
// Hide focus outline if element is not focusable by default - might
// reconsider this later
const isFocusable = element.tabIndex >= 0;
if (!isFocusable) {
// Apparently the default tabIndex is -1, even though an element is still
// not focusable with that. Setting an explicit -1 also sets the attribute
// and the element becomes focusable.
element.tabIndex = -1;
// `focusVisible` is not supported in all browsers, so hide the outline manually
element.style["outline"] = "none";
}
element.focus({
focusVisible: isKeyboardActive() && isFocusable,
preventScroll: true,
});
}
// Register global listener for navigation and try to focus an element that
// results in a meaningful announcement.
document.addEventListener("turbo:load", () => {
// Ignore initial page load to let the browser handle announcements
if (firstPageLoad) {
firstPageLoad = false;
return;
}
// Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target);
if (element) {
programmaticFocus(element);
return;
}
}
afterPageLoadFocusTarget = [];
// If there is some autofocus element, let the browser handle it
const autofocus = document.querySelector("[autofocus]");
if (autofocus) {
return;
}
// If there is a toast as a result of some action, focus it
const toast = document.querySelector(".toast");
if (toast) {
programmaticFocus(toast);
return;
}
// Otherwise go with main
const main = document.querySelector("main");
if (main) {
programmaticFocus(main);
}
});

View File

@@ -0,0 +1,91 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
}
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;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
this.element.remove();
this.clearInert();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

@@ -1,68 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class TagModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header">
<h2>Tags</h2>
<button class="close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(tagCloud);
const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
}
}
registerBehavior("ld-tag-modal", TagModalBehavior);

View File

@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit"; import "./behaviors/bulk-edit";
import "./behaviors/clear-button"; import "./behaviors/clear-button";
import "./behaviors/confirm-button"; import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/details-modal"; import "./behaviors/details-modal";
import "./behaviors/dropdown";
import "./behaviors/filter-drawer";
import "./behaviors/form";
import "./behaviors/global-shortcuts"; import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete"; import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete"; import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte"; export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte"; export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0042_userprofile_custom_css_hash"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="collapse_side_panel",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.1.7 on 2025-03-22 12:28
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import OuterRef, Subquery
def forwards(apps, schema_editor):
# Update the latest snapshot for each bookmark
Bookmark = apps.get_model("bookmarks", "bookmark")
BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset")
latest_snapshots = (
BookmarkAsset.objects.filter(
bookmark=OuterRef("pk"), asset_type="snapshot", status="complete"
)
.order_by("-date_created")
.values("id")[:1]
)
Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0043_userprofile_collapse_side_panel"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="latest_snapshot",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="latest_snapshot",
to="bookmarks.bookmarkasset",
),
),
migrations.RunPython(forwards, reverse),
]

View File

@@ -6,7 +6,6 @@ from typing import List
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
@@ -23,7 +22,7 @@ logger = logging.getLogger(__name__)
class Tag(models.Model): class Tag(models.Model):
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
date_added = models.DateTimeField() date_added = models.DateTimeField()
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -70,8 +69,15 @@ class Bookmark(models.Model):
date_added = models.DateTimeField() date_added = models.DateTimeField()
date_modified = models.DateTimeField() date_modified = models.DateTimeField()
date_accessed = models.DateTimeField(blank=True, null=True) date_accessed = models.DateTimeField(blank=True, null=True)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag) tags = models.ManyToManyField(Tag)
latest_snapshot = models.ForeignKey(
"BookmarkAsset",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="latest_snapshot",
)
@property @property
def resolved_title(self): def resolved_title(self):
@@ -93,6 +99,19 @@ class Bookmark(models.Model):
return self.resolved_title + " (" + self.url[:30] + "...)" return self.resolved_title + " (" + self.url[:30] + "...)"
@receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs):
if instance.preview_image_file:
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
if os.path.isfile(filepath):
try:
os.remove(filepath)
except Exception as error:
logger.error(
f"Failed to delete preview image: {filepath}", exc_info=error
)
class BookmarkAsset(models.Model): class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot" TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload" TYPE_UPLOAD = "upload"
@@ -138,56 +157,6 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error) logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = [
"url",
"tag_string",
"title",
"description",
"notes",
"unread",
"shared",
"auto_close",
]
@property
def has_notes(self):
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
class BookmarkSearch: class BookmarkSearch:
SORT_ADDED_ASC = "added_asc" SORT_ADDED_ASC = "added_asc"
SORT_ADDED_DESC = "added_desc" SORT_ADDED_DESC = "added_desc"
@@ -374,9 +343,7 @@ class UserProfile(models.Model):
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"), (TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
(TAG_GROUPING_DISABLED, "Disabled"), (TAG_GROUPING_DISABLED, "Disabled"),
] ]
user = models.OneToOneField( user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
get_user_model(), related_name="profile", on_delete=models.CASCADE
)
theme = models.CharField( theme = models.CharField(
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
) )
@@ -440,6 +407,7 @@ class UserProfile(models.Model):
null=False, default=30, validators=[MinValueValidator(10)] null=False, default=30, validators=[MinValueValidator(10)]
) )
sticky_pagination = models.BooleanField(default=False, null=False) sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.custom_css: if self.custom_css:
@@ -479,16 +447,17 @@ class UserProfileForm(forms.ModelForm):
"auto_tagging_rules", "auto_tagging_rules",
"items_per_page", "items_per_page",
"sticky_pagination", "sticky_pagination",
"collapse_side_panel",
] ]
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs): def create_user_profile(sender, instance, created, **kwargs):
if created: if created:
UserProfile.objects.create(user=instance) UserProfile.objects.create(user=instance)
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs): def save_user_profile(sender, instance, **kwargs):
instance.profile.save() instance.profile.save()
@@ -497,7 +466,7 @@ class Toast(models.Model):
key = models.CharField(max_length=50) key = models.CharField(max_length=50)
message = models.TextField() message = models.TextField()
acknowledged = models.BooleanField(default=False) acknowledged = models.BooleanField(default=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE)
class FeedToken(models.Model): class FeedToken(models.Model):
@@ -507,7 +476,7 @@ class FeedToken(models.Model):
key = models.CharField(max_length=40, primary_key=True) key = models.CharField(max_length=40, primary_key=True)
user = models.OneToOneField( user = models.OneToOneField(
get_user_model(), User,
related_name="feed_token", related_name="feed_token",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@@ -541,7 +510,7 @@ class GlobalSettings(models.Model):
default=LANDING_PAGE_LOGIN, default=LANDING_PAGE_LOGIN,
) )
guest_profile_user = models.ForeignKey( guest_profile_user = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True User, on_delete=models.SET_NULL, null=True, blank=True
) )
enable_link_prefetch = models.BooleanField(default=False, null=False) enable_link_prefetch = models.BooleanField(default=False, null=False)

View File

@@ -0,0 +1,155 @@
import gzip
import logging
import os
import shutil
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone, formats
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import singlefile
MAX_ASSET_FILENAME_LENGTH = 192
logger = logging.getLogger(__name__)
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
date_created = timezone.now()
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
date_created=date_created,
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def create_snapshot(asset: BookmarkAsset):
try:
# Create snapshot into temporary file
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
# Store as gzip in asset folder
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(temp_filepath, "rb") as temp_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(temp_file, gz_file)
# Remove temporary file
os.remove(temp_filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
asset.bookmark.latest_snapshot = asset
asset.bookmark.save()
except Exception as error:
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
raise error
def upload_snapshot(bookmark: Bookmark, html: bytes):
asset = create_snapshot_asset(bookmark)
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb") as gz_file:
gz_file.write(html)
# Only save the asset if the file was written successfully
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
asset.bookmark.latest_snapshot = asset
asset.bookmark.save()
return asset
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
try:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
date_created=timezone.now(),
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_COMPLETE,
gzip=False,
)
name, extension = os.path.splitext(upload_file.name)
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save()
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
return asset
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
raise e
def remove_asset(asset: BookmarkAsset):
# If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot
bookmark = asset.bookmark
if bookmark and bookmark.latest_snapshot == asset:
latest = (
BookmarkAsset.objects.filter(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
.exclude(pk=asset.pk)
.order_by("-date_created")
.first()
)
bookmark.latest_snapshot = latest
bookmark.save()
asset.delete()
def _generate_asset_filename(
asset: BookmarkAsset, filename: str, extension: str
) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_filename = "".join(sanitize_char(char) for char in filename)
# Calculate the length of fixed parts of the final filename
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
# Calculate the maximum length for the dynamic part of the filename
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
# Truncate the filename if necessary
sanitized_filename = sanitized_filename[:max_filename_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"

View File

@@ -11,10 +11,18 @@ def get_tags(script: str, url: str):
return result return result
for line in script.lower().split("\n"): for line in script.lower().split("\n"):
if "#" in line: line = line.strip()
i = line.index("#")
line = line[:i]
# Skip empty lines or lines that start with a comment
if not line or line.startswith("#"):
continue
# Remove trailing comment - only if # is preceded by whitespace
comment_match = re.search(r"\s+#", line)
if comment_match:
line = line[: comment_match.start()]
# Ignore lines that don't contain a URL and a tag
parts = line.split() parts = line.split()
if len(parts) < 2: if len(parts) < 2:
continue continue
@@ -36,6 +44,11 @@ def get_tags(script: str, url: str):
): ):
continue continue
if parsed_pattern.fragment and not _fragment_matches(
parsed_pattern.fragment, parsed_url.fragment
):
continue
for tag in parts[1:]: for tag in parts[1:]:
result.add(tag) result.add(tag)
@@ -65,3 +78,7 @@ def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
return False return False
return True return True
def _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:
return actual_fragment.startswith(expected_fragment)

View File

@@ -1,22 +1,23 @@
import logging import logging
import os
from typing import Union from typing import Union
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string from bookmarks.models import Bookmark, User, parse_tag_string
from bookmarks.services import auto_tagging
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services import auto_tagging
from bookmarks.services.tags import get_or_create_tags from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): def create_bookmark(
bookmark: Bookmark,
tag_string: str,
current_user: User,
disable_html_snapshot: bool = False,
):
# If URL is already bookmarked, then update it # If URL is already bookmarked, then update it
existing_bookmark: Bookmark = Bookmark.objects.filter( existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url=bookmark.url owner=current_user, url=bookmark.url
@@ -42,7 +43,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
# Load preview image # Load preview image
tasks.load_preview_image(current_user, bookmark) tasks.load_preview_image(current_user, bookmark)
# Create HTML snapshot # Create HTML snapshot
if current_user.profile.enable_automatic_html_snapshots: if (
current_user.profile.enable_automatic_html_snapshots
and not disable_html_snapshot
):
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
return bookmark return bookmark
@@ -193,44 +197,15 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
) )
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str): def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: User):
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S") sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
return f"{asset.asset_type}_{formatted_datetime}_{filename}" owned_bookmarks = Bookmark.objects.filter(
owner=current_user, id__in=sanitized_bookmark_ids
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
) )
asset.save()
try: for bookmark in owned_bookmarks:
filename = _generate_upload_asset_filename(asset, upload_file.name) tasks.refresh_metadata(bookmark)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) tasks.load_preview_image(current_user, bookmark)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.file_size = upload_file.size
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
return asset
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):

View File

@@ -35,7 +35,7 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]" desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]"
tag_names = bookmark.tag_names tag_names = bookmark.tag_names
if bookmark.is_archived: if bookmark.is_archived:
tag_names.append("linkding:archived") tag_names.append("linkding:bookmarks.archived")
tags = ",".join(tag_names) tags = ",".join(tag_names)
toread = "1" if bookmark.unread else "0" toread = "1" if bookmark.unread else "0"
private = "0" if bookmark.shared else "1" private = "0" if bookmark.shared else "1"

View File

@@ -62,9 +62,9 @@ class BookmarkParser(HTMLParser):
def handle_start_a(self, attrs: Dict[str, str]): def handle_start_a(self, attrs: Dict[str, str]):
vars(self).update(attrs) vars(self).update(attrs)
tag_names = parse_tag_string(self.tags) tag_names = parse_tag_string(self.tags)
archived = "linkding:archived" in self.tags archived = "linkding:bookmarks.archived" in self.tags
try: try:
tag_names.remove("linkding:archived") tag_names.remove("linkding:bookmarks.archived")
except ValueError: except ValueError:
pass pass

View File

@@ -1,8 +1,6 @@
import gzip
import logging import logging
import os import os
import shlex import shlex
import shutil
import signal import signal
import subprocess import subprocess
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
def create_snapshot(url: str, filepath: str): def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH singlefile_path = settings.LD_SINGLEFILE_PATH
# parse options to list of arguments # parse options to list of arguments
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS) ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS) custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
temp_filepath = filepath + ".tmp"
# concat lists # concat lists
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath] args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
try: try:
# Use start_new_session=True to create a new process group # Use start_new_session=True to create a new process group
process = subprocess.Popen(args, start_new_session=True) process = subprocess.Popen(args, start_new_session=True)
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC) process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
# check if the file was created # check if the file was created
if not os.path.exists(temp_filepath): if not os.path.exists(filepath):
raise SingleFileError("Failed to create snapshot") raise SingleFileError("Failed to create snapshot")
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# First try to terminate properly # First try to terminate properly
try: try:

View File

@@ -1,22 +1,20 @@
import functools import functools
import logging import logging
import os
from typing import List from typing import List
import waybackpy import waybackpy
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.utils import timezone, formats from django.utils import timezone
from huey import crontab from huey import crontab
from huey.contrib.djhuey import HUEY as huey from huey.contrib.djhuey import HUEY as huey
from huey.exceptions import TaskLockedException from huey.exceptions import TaskLockedException
from waybackpy.exceptions import WaybackError, TooManyRequestsError from waybackpy.exceptions import WaybackError, TooManyRequestsError
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile, preview_image_loader from bookmarks.services import assets, favicon_loader, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT from bookmarks.services.website_loader import DEFAULT_USER_AGENT, load_website_metadata
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,7 +157,7 @@ def schedule_bookmarks_without_favicons(user: User):
@task() @task()
def _schedule_bookmarks_without_favicons_task(user_id: int): def _schedule_bookmarks_without_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id) user = User.objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user) bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
# TODO: Implement bulk task creation # TODO: Implement bulk task creation
@@ -175,7 +173,7 @@ def schedule_refresh_favicons(user: User):
@task() @task()
def _schedule_refresh_favicons_task(user_id: int): def _schedule_refresh_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id) user = User.objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(owner=user) bookmarks = Bookmark.objects.filter(owner=user)
# TODO: Implement bulk task creation # TODO: Implement bulk task creation
@@ -214,7 +212,7 @@ def schedule_bookmarks_without_previews(user: User):
@task() @task()
def _schedule_bookmarks_without_previews_task(user_id: int): def _schedule_bookmarks_without_previews_task(user_id: int):
user = get_user_model().objects.get(id=user_id) user = User.objects.get(id=user_id)
bookmarks = Bookmark.objects.filter( bookmarks = Bookmark.objects.filter(
Q(preview_image_file__exact=""), Q(preview_image_file__exact=""),
owner=user, owner=user,
@@ -228,6 +226,31 @@ def _schedule_bookmarks_without_previews_task(user_id: int):
logging.exception(exc) logging.exception(exc)
def refresh_metadata(bookmark: Bookmark):
if not settings.LD_DISABLE_BACKGROUND_TASKS:
_refresh_metadata_task(bookmark.id)
@task()
def _refresh_metadata_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
logger.info(f"Refresh metadata for bookmark. url={bookmark.url}")
metadata = load_website_metadata(bookmark.url)
if metadata.title:
bookmark.title = metadata.title
if metadata.description:
bookmark.description = metadata.description
bookmark.date_modified = timezone.now()
bookmark.save()
logger.info(f"Successfully refreshed metadata for bookmark. url={bookmark.url}")
def is_html_snapshot_feature_active() -> bool: def is_html_snapshot_feature_active() -> bool:
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
@@ -236,7 +259,7 @@ def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active(): if not is_html_snapshot_feature_active():
return return
asset = _create_snapshot_asset(bookmark) asset = assets.create_snapshot_asset(bookmark)
asset.save() asset.save()
@@ -246,47 +269,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
assets_to_create = [] assets_to_create = []
for bookmark in bookmark_list: for bookmark in bookmark_list:
asset = _create_snapshot_asset(bookmark) asset = assets.create_snapshot_asset(bookmark)
assets_to_create.append(asset) assets_to_create.append(asset)
BookmarkAsset.objects.bulk_create(assets_to_create) BookmarkAsset.objects.bulk_create(assets_to_create)
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
content_type="text/html",
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
# Calculate the length of the non-URL parts of the filename
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
# Calculate the maximum length for the URL part
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
# Truncate the URL if necessary
sanitized_url = sanitized_url[:max_url_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
# singe-file does not support running multiple instances in parallel, so we can # singe-file does not support running multiple instances in parallel, so we can
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic # not queue up multiple snapshot tasks at once. Instead, schedule a periodic
# task that grabs a number of pending assets and creates snapshots for them in # task that grabs a number of pending assets and creates snapshots for them in
@@ -313,13 +301,8 @@ def _create_html_snapshot_task(asset_id: int):
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}") logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
try: try:
filename = _generate_snapshot_filename(asset) assets.create_snapshot(asset)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
singlefile.create_snapshot(asset.bookmark.url, filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
logger.info( logger.info(
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}" f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
) )
@@ -328,8 +311,6 @@ def _create_html_snapshot_task(asset_id: int):
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}", f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
exc_info=error, exc_info=error,
) )
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
def create_missing_html_snapshots(user: User) -> int: def create_missing_html_snapshots(user: User) -> int:

View File

@@ -27,10 +27,20 @@ class WebsiteMetadata:
} }
def load_website_metadata(url: str, ignore_cache: bool = False):
if ignore_cache:
return _load_website_metadata(url)
return _load_website_metadata_cached(url)
# Caching metadata avoids scraping again when saving bookmarks, in case the # Caching metadata avoids scraping again when saving bookmarks, in case the
# metadata was already scraped to show preview values in the bookmark form # metadata was already scraped to show preview values in the bookmark form
@lru_cache(maxsize=10) @lru_cache(maxsize=10)
def load_website_metadata(url: str): def _load_website_metadata_cached(url: str):
return _load_website_metadata(url)
def _load_website_metadata(url: str):
title = None title = None
description = None description = None
preview_image = None preview_image = None

View File

@@ -58,7 +58,7 @@ MIDDLEWARE = [
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
] ]
ROOT_URLCONF = "siteroot.urls" ROOT_URLCONF = "bookmarks.urls"
TEMPLATES = [ TEMPLATES = [
{ {
@@ -80,7 +80,7 @@ TEMPLATES = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
WSGI_APPLICATION = "siteroot.wsgi.application" WSGI_APPLICATION = "bookmarks.wsgi.application"
# Password validation # Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
@@ -131,7 +131,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
# REST framework # REST framework
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication", "bookmarks.api.auth.LinkdingTokenAuthentication",
"rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.SessionAuthentication",
], ],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
@@ -146,6 +146,7 @@ ALLOW_REGISTRATION = False
LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in ( LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
True, True,
"True", "True",
"true",
"1", "1",
) )
@@ -153,6 +154,7 @@ LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in ( LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in (
True, True,
"True", "True",
"true",
"1", "1",
) )
@@ -179,7 +181,7 @@ HUEY = {
# Enable OICD support if configured # Enable OICD support if configured
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1") LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "true", "1")
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
@@ -194,11 +196,18 @@ if LD_ENABLE_OIDC:
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID") OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET") OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256") OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1") OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1") OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "true", "1")
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "true", "1")
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
# Enable authentication proxy support if configured # Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1") LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (
True,
"True",
"true",
"1",
)
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv( LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
"LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER" "LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER"
) )
@@ -265,6 +274,7 @@ LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")
LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in ( LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
True, True,
"True", "True",
"true",
"1", "1",
) )
@@ -286,6 +296,13 @@ LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in ( LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
True, True,
"True", "True",
"true",
"1",
)
LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
True,
"True",
"true",
"1", "1",
) )
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file") LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -36,8 +36,14 @@
} }
} }
& dl { & .sections section {
margin-bottom: 0; margin-top: var(--unit-4);
}
& .sections h3 {
margin-bottom: var(--unit-2);
font-size: var(--font-size);
font-weight: bold;
} }
& .assets { & .assets {

View File

@@ -1,5 +1,5 @@
.bookmarks-form-page { .bookmarks-form-page {
section { main {
max-width: 550px; max-width: 550px;
margin: 0 auto; margin: 0 auto;
} }
@@ -15,14 +15,23 @@
visibility: hidden; visibility: hidden;
} }
& .form-group .clear-button { & .form-group .suffix-button {
display: none;
padding: 0; padding: 0;
border: none; border: none;
height: auto; height: auto;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
& .form-group .clear-button,
& .form-group #refresh-button {
display: none;
}
& .form-group input.modified,
& .form-group textarea.modified {
background: var(--primary-color-shade);
}
& .form-input-hint.bookmark-exists { & .form-input-hint.bookmark-exists {
display: none; display: none;
color: var(--warning-color); color: var(--warning-color);

View File

@@ -10,8 +10,38 @@
} }
/* Bookmark page grid */ /* Bookmark page grid */
.bookmarks-page.grid { .bookmarks-page {
grid-gap: var(--unit-9); &.grid {
grid-gap: var(--unit-9);
}
[ld-filter-drawer-trigger] {
display: none;
}
@media (max-width: 840px) {
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
&.collapse-side-panel {
main {
grid-column: span var(--grid-columns);
}
.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
} }
/* Bookmark area header controls */ /* Bookmark area header controls */
@@ -429,7 +459,7 @@ ul.bookmark-list {
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */ /* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
&.active section:first-of-type .content-area-header { &.active .main .section-header {
border-bottom-color: transparent; border-bottom-color: transparent;
} }

View File

@@ -1,34 +1,31 @@
/* Shared components */ /* Shared components */
/* Content area component */ /* Section header component */
section.content-area { .section-header {
h2 { border-bottom: solid 1px var(--secondary-border-color);
display: flex;
flex-wrap: wrap;
column-gap: var(--unit-5);
padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4);
h1,
h2,
h3 {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
flex: 0 0 auto;
line-height: var(--unit-9);
margin: 0;
} }
.content-area-header { .header-controls {
border-bottom: solid 1px var(--secondary-border-color); flex: 1 1 0;
display: flex; display: flex;
flex-wrap: wrap;
column-gap: var(--unit-5);
padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4);
h2 {
flex: 0 0 auto;
line-height: var(--unit-9);
margin: 0;
}
.header-controls {
flex: 1 1 0;
display: flex;
}
} }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
section.content-area .content-area-header { .section-header {
flex-direction: column; flex-direction: column;
} }
} }

View File

@@ -11,18 +11,20 @@ body {
header { header {
margin-bottom: var(--unit-9); margin-bottom: var(--unit-9);
.logo { a.app-link:hover {
text-decoration: none;
}
.app-logo {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
a:hover { .app-name {
text-decoration: none; margin-left: var(--unit-3);
}
h1 {
margin: 0 0 0 var(--unit-3);
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: 500;
line-height: 1.2;
} }
} }

View File

@@ -1,8 +1,14 @@
.settings-page { .settings-page {
section.content-area { h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--unit-6);
}
section {
margin-bottom: var(--unit-10); margin-bottom: var(--unit-10);
h2 { h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--unit-3); margin-bottom: var(--unit-3);
} }
} }

View File

@@ -10,7 +10,6 @@ html {
font-size: var(--html-font-size); font-size: var(--html-font-size);
line-height: var(--html-line-height); line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
} }
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */ /* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */

View File

@@ -1,5 +1,7 @@
/* Dropdown */ /* Dropdown */
.dropdown { .dropdown {
--dropdown-focus-display: block;
display: inline-block; display: inline-block;
position: relative; position: relative;
@@ -20,9 +22,13 @@
} }
} }
&.active .menu, &:focus-within .menu {
.dropdown-toggle:focus + .menu, /* Use custom CSS property to allow disabling opening on focus when using JS */
.menu:hover { display: var(--dropdown-focus-display);
}
&.active .menu {
/* Always show menu when class is added through JS */
display: block; display: block;
} }

View File

@@ -62,13 +62,14 @@
gap: var(--unit-4); gap: var(--unit-4);
max-height: 75vh; max-height: 75vh;
max-width: var(--control-width-md); max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%; width: 100%;
& .modal-header { & .modal-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: var(--unit-2); gap: var(--unit-2);
padding: var(--unit-6);
padding-bottom: 0;
color: var(--text-color); color: var(--text-color);
& h2 { & h2 {
@@ -78,7 +79,7 @@
margin: 0; margin: 0;
} }
& button.close { & .close {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
@@ -95,10 +96,53 @@
& .modal-body { & .modal-body {
overflow-y: auto; overflow-y: auto;
position: relative; padding: 0 var(--unit-6);
}
& .modal-body:not(:has(+ .modal-footer)) {
margin-bottom: var(--unit-6);
} }
& .modal-footer { & .modal-footer {
padding: var(--unit-6);
padding-top: 0;
text-align: right; text-align: right;
} }
} }
.modal.drawer {
display: block;
& .modal-container {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-left: solid 1px var(--modal-container-border-color);
border-radius: 0;
transform: translateX(100%);
animation: fade-in 0.25s ease 1;
transition: transform 0.25s ease;
}
&.active {
& .modal-container {
transform: translateX(0);
}
}
&.active.closing {
& .modal-container {
animation: fade-out 0.25s ease 1;
transform: translateX(100%);
}
}
}
.scroll-lock {
overflow: hidden !important;
}

View File

@@ -87,6 +87,7 @@
--font-size: 0.7rem; --font-size: 0.7rem;
--font-size-sm: 0.65rem; --font-size-sm: 0.65rem;
--font-size-lg: 0.8rem; --font-size-lg: 0.8rem;
--font-size-xl: 1rem;
--line-height: 1rem; --line-height: 1rem;
/* Sizes */ /* Sizes */

View File

@@ -4,17 +4,17 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1"> <div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <main class="main col-2" aria-labelledby="main-heading">
<div class="content-area-header mb-0"> <div class="section-header mb-0">
<h2>Archived bookmarks</h2> <h1 id="main-heading">Archived bookmarks</h1>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %} {% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</button>
</div> </div>
</div> </div>
@@ -28,23 +28,27 @@
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
</section> </main>
{# Tag cloud #} {# Tag cloud #}
<section class="content-area col-1 hide-md"> <div class="side-panel col-1">
<div class="content-area-header"> <section aria-labelledby="tags-heading">
<h2>Tags</h2> <div class="section-header">
</div> <h2 id="tags-heading">Tags</h2>
<div id="tag-cloud-container"> </div>
{% include 'bookmarks/tag_cloud.html' %} <div id="tag-cloud-container">
</div> {% include 'bookmarks/tag_cloud.html' %}
</section> </div>
</section>
{# Bookmark details #} </div>
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -5,157 +5,162 @@
{% if bookmark_list.is_empty %} {% if bookmark_list.is_empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}" <section aria-label="Bookmark list">
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};" <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> role="list" tabindex="-1"
{% for bookmark_item in bookmark_list.items %} style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}> data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
<div class="content"> {% for bookmark_item in bookmark_list.items %}
<div class="title"> <li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
<label class="form-checkbox bulk-edit-checkbox"> {% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}"> <div class="content">
<i class="form-icon"></i> <div class="title">
</label> <label class="form-checkbox bulk-edit-checkbox">
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %} <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt=""> <i class="form-icon"></i>
{% endif %} </label>
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"> {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<span>{{ bookmark_item.title }}</span> <img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
</a> {% endif %}
</div> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
{% if bookmark_list.show_url %} <span>{{ bookmark_item.title }}</span>
<div class="url-path truncate">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display">
{{ bookmark_item.url }}
</a> </a>
</div> </div>
{% endif %} {% if bookmark_list.show_url %}
{% if bookmark_list.description_display == 'inline' %} <div class="url-path truncate">
<div class="description inline truncate"> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
{% if bookmark_item.tag_names %} class="url-display">
<span class="tags"> {{ bookmark_item.url }}
</a>
</div>
{% endif %}
{% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate">
{% if bookmark_item.tag_names %}
<span class="tags">
{% for tag_name in bookmark_item.tag_names %} {% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %} {% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% else %}
{% if bookmark_item.description %} {% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span> <div class="description separate">{{ bookmark_item.description }}</div>
{% endif %}
{% if bookmark_item.tag_names %}
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</div>
{% endif %} {% endif %}
</div>
{% else %}
{% if bookmark_item.description %}
<div class="description separate">{{ bookmark_item.description }}</div>
{% endif %} {% endif %}
{% if bookmark_item.tag_names %} {% if bookmark_item.notes %}
<div class="tags"> <div class="notes">
{% for tag_name in bookmark_item.tag_names %} <div class="markdown">{% markdown bookmark_item.notes %}</div>
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endif %} <div class="actions">
{% if bookmark_item.notes %} {% if bookmark_item.display_date %}
<div class="notes"> {% if bookmark_item.snapshot_url %}
<div class="markdown">{% markdown bookmark_item.notes %}</div> <a href="{{ bookmark_item.snapshot_url }}"
</div> title="{{ bookmark_item.snapshot_title }}"
{% endif %} target="{{ bookmark_list.link_target }}"
<div class="actions"> rel="noopener">
{% if bookmark_item.display_date %} {{ bookmark_item.display_date }}
{% if bookmark_item.web_archive_snapshot_url %} </a>
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{{ bookmark_item.display_date }}
</a>
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %} {% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}" <span>{{ bookmark_item.display_date }}</span>
class="btn btn-link btn-sm">Archive {% endif %}
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button> </button>
{% endif %} {% endif %}
{% endif %} {% else %}
{% if bookmark_list.show_remove_action %} {# Shared bookmark actions #}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}" <span>Shared by
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a> <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span> </span>
{% endif %} {% endif %}
{% if bookmark_item.has_extra_actions %} {% if bookmark_item.has_extra_actions %}
<div class="extra-actions"> <div class="extra-actions">
<span class="hide-sm">|</span> <span class="hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %} {% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}" <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?"> ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
Unread Unread
</button> </button>
{% endif %} {% endif %}
{% if bookmark_item.show_unshare %} {% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}" <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?"> ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>
Shared Shared
</button> </button>
{% endif %} {% endif %}
{% if bookmark_item.show_notes_button %} {% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes"> <button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use> <use xlink:href="#ld-icon-note"></use>
</svg> </svg>
Notes Notes
</button> </button>
{% endif %} {% endif %}
</div>
{% endif %}
</div>
</div>
{% if bookmark_list.show_preview_images %}
{% if bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
{% else %}
<div class="preview-image placeholder">
<div class="img"/>
</div> </div>
{% endif %} {% endif %}
</div>
</div>
{% if bookmark_list.show_preview_images %}
{% if bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
{% else %}
<div class="preview-image placeholder">
<div class="img"/>
</div>
{% endif %} {% endif %}
{% endif %} </li>
</li> {% endfor %}
{% endfor %} </ul>
</ul>
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}"> <div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
{% pagination bookmark_list.bookmarks_page %} {% pagination bookmark_list.bookmarks_page %}
</div> </div>
</section>
{% endif %} {% endif %}

View File

@@ -22,6 +22,7 @@
<option value="bulk_share">Share</option> <option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
{% endif %} {% endif %}
<option value="bulk_refresh">Refresh from website</option>
</select> </select>
<div class="tag-autocomplete d-none" ld-tag-autocomplete> <div class="tag-autocomplete d-none" ld-tag-autocomplete>
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small"> <input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">

View File

@@ -18,7 +18,7 @@
</div> </div>
<div class="asset-actions"> <div class="asset-actions">
{% if asset.file %} {% if asset.file %}
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a> <a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
{% endif %} {% endif %}
{% if details.is_editable %} {% if details.is_editable %}
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link"> <button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
@@ -33,12 +33,16 @@
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm" {% if details.snapshots_enabled %}
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot <button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
</button> {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit" </button>
class="btn btn-sm">Upload file {% endif %}
</button> {% if details.uploads_enabled %}
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
{% endif %}
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide"> <input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div> </div>
{% endif %} {% endif %}

View File

@@ -14,7 +14,7 @@
<span>{{ details.bookmark.url }}</span> <span>{{ details.bookmark.url }}</span>
</a> </a>
{% if details.latest_snapshot %} {% if details.latest_snapshot %}
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}" <a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %} {% if details.show_link_icons %}
<svg class="favicon" xmlns="http://www.w3.org/2000/svg"> <svg class="favicon" xmlns="http://www.w3.org/2000/svg">
@@ -40,14 +40,14 @@
</div> </div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %} {% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image"> <div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/> <img src="{% static details.bookmark.preview_image_file %}" alt=""/>
</div> </div>
{% endif %} {% endif %}
<dl class="grid columns-2 columns-sm-1 gap-0"> <div class="sections grid columns-2 columns-sm-1 gap-0">
{% if details.is_editable %} {% if details.is_editable %}
<div class="status col-2"> <section class="status col-2">
<dt>Status</dt> <h3>Status</h3>
<dd class="d-flex" style="gap: .8rem"> <div class="d-flex" style="gap: .8rem">
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived" <input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,42 @@
</label> </label>
</div> </div>
{% endif %} {% endif %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
{% if details.show_files %} <section class="files col-2">
<div class="files col-2"> <h3>Files</h3>
<dt>Files</dt> <div>
<dd> {% include 'bookmarks/details/assets.html' %}
{% include 'bookmarks/details/assets.html' %}
</dd>
</div> </div>
{% endif %} </section>
{% if details.bookmark.tag_names %} {% if details.bookmark.tag_names %}
<div class="tags col-1"> <section class="tags col-1">
<dt>Tags</dt> <h3 id="details-modal-tags-title">Tags</h3>
<dd> <div>
{% for tag_name in details.bookmark.tag_names %} {% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
<div class="date-added col-1"> <section class="date-added col-1">
<dt>Date added</dt> <h3>Date added</h3>
<dd> <div>
<span>{{ details.bookmark.date_added }}</span> <span>{{ details.bookmark.date_added }}</span>
</dd>
</div>
{% if details.bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ details.bookmark.resolved_description }}</dd>
</div> </div>
</section>
{% if details.bookmark.resolved_description %}
<section class="description col-2">
<h3>Description</h3>
<div>{{ details.bookmark.resolved_description }}</div>
</section>
{% endif %} {% endif %}
{% if details.bookmark.notes %} {% if details.bookmark.notes %}
<div class="notes col-2"> <section class="notes col-2">
<dt>Notes</dt> <h3>Notes</h3>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd> <div class="markdown">{% markdown details.bookmark.notes %}</div>
</div> </section>
{% endif %} {% endif %}
</dl> </div>
</form> </form>

View File

@@ -1,21 +1,17 @@
<div class="modal active bookmark-details" <div class="modal active bookmark-details" ld-details-modal
ld-details-modal> data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
<a href="{{ details.close_url }}" data-turbo-frame="details-modal"> <div class="modal-overlay"></div>
<div class="modal-overlay" aria-label="Close"></div> <div class="modal-container" role="dialog" aria-modal="true">
</a>
<div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2> <h2>{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal"> <button class="close" aria-label="Close dialog">
<button class="close"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M18 6l-12 12"></path>
<path d="M18 6l-12 12"></path> <path d="M6 6l12 12"></path>
<path d="M6 6l12 12"></path> </svg>
</svg> </button>
</button>
</a>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="content"> <div class="content">
@@ -28,7 +24,7 @@
<div class="actions"> <div class="actions">
<div class="left-actions"> <div class="left-actions">
<a class="btn btn-wide" <a class="btn btn-wide"
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a> href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
</div> </div>
<div class="right-actions"> <div class="right-actions">
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace"> <form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">

View File

@@ -1,16 +1,21 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load bookmarks %}
{% block head %}
{% with page_title="Edit bookmark - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
<section class="content-area"> <main aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Edit bookmark</h2> <h1 id="main-heading">Edit bookmark</h1>
</div> </div>
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
novalidate> novalidate>
{% bookmark_form form return_url bookmark_id %} {% include 'bookmarks/form.html' %}
</form> </form>
</section> </main>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@@ -33,9 +33,12 @@
<div class="form-group"> <div class="form-group">
<div class="d-flex justify-between align-baseline"> <div class="d-flex justify-between align-baseline">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label> <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="btn btn-link clear-button" <div class="flex">
type="button">Clear <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
</button> <button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button"
type="button">Clear
</button>
</div>
</div> </div>
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }} {{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
{{ form.title.errors }} {{ form.title.errors }}
@@ -43,7 +46,8 @@
<div class="form-group"> <div class="form-group">
<div class="d-flex justify-between align-baseline"> <div class="d-flex justify-between align-baseline">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label> <label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<button ld-clear-button data-for="{{ form.description.id_for_label }}" class="btn btn-link clear-button" <button ld-clear-button data-for="{{ form.description.id_for_label }}"
class="btn btn-link suffix-button clear-button"
type="button">Clear type="button">Clear
</button> </button>
</div> </div>
@@ -91,12 +95,12 @@
{% endif %} {% endif %}
<div class="divider"></div> <div class="divider"></div>
<div class="form-group d-flex justify-between"> <div class="form-group d-flex justify-between">
{% if auto_close %} {% if form.is_auto_close %}
<input type="submit" value="Save and close" class="btn btn-primary btn-wide"> <input type="submit" value="Save and close" class="btn btn-primary btn-wide">
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide"> <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ cancel_url }}" class="btn">Nevermind</a> <a href="{{ return_url }}" class="btn">Nevermind</a>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
/** /**
@@ -111,8 +115,9 @@
const notesInput = document.getElementById('{{ form.notes.id_for_label }}'); const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}'); const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}'); const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
const refreshButton = document.getElementById('refresh-button');
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists'); const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editedBookmarkId = {{ bookmark_id }}; const editedBookmarkId = {{ form.instance.id|default:0 }};
let isTitleModified = !!titleInput.value; let isTitleModified = !!titleInput.value;
let isDescriptionModified = !!descriptionInput.value; let isDescriptionModified = !!descriptionInput.value;
@@ -144,7 +149,7 @@
toggleLoadingIcon(urlInput, true); toggleLoadingIcon(urlInput, true);
const websiteUrl = encodeURIComponent(urlInput.value); const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`; const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`;
fetch(requestUrl) fetch(requestUrl)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -154,6 +159,7 @@
// Display hint if URL is already bookmarked // Display hint if URL is already bookmarked
const existingBookmark = data.bookmark; const existingBookmark = data.bookmark;
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none'; bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';
// Prefill form with existing bookmark data // Prefill form with existing bookmark data
if (existingBookmark) { if (existingBookmark) {
@@ -193,6 +199,36 @@
}); });
} }
function refreshMetadata() {
if (!urlInput.value) {
return;
}
toggleLoadingIcon(urlInput, true);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {
const metadata = data.metadata;
const existingBookmark = data.bookmark;
toggleLoadingIcon(urlInput, false);
if (metadata.title && metadata.title !== existingBookmark?.title) {
titleInput.value = metadata.title;
titleInput.classList.add("modified");
}
if (metadata.description && metadata.description !== existingBookmark?.description) {
descriptionInput.value = metadata.description;
descriptionInput.classList.add("modified");
}
});
}
refreshButton.addEventListener('click', refreshMetadata);
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
if (!editedBookmarkId) { if (!editedBookmarkId) {
checkUrl(); checkUrl();
@@ -203,6 +239,8 @@
descriptionInput.addEventListener('input', () => { descriptionInput.addEventListener('input', () => {
isDescriptionModified = true; isDescriptionModified = true;
}); });
} else {
refreshButton.style['display'] = 'inline-block';
} }
})(); })();
</script> </script>

View File

@@ -6,13 +6,14 @@
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml"> <link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}"> <link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0"> <link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}"> <link rel="manifest" href="{% url 'linkding:manifest' %}">
<link rel="search" type="application/opensearchdescription+xml" title="Linkding" href="{% url 'linkding:opensearch' %}"/>
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service"> <meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow"> <meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker"> <meta name="author" content="Sascha Ißbrücker">
<title>linkding</title> <title>{{ page_title|default:'Linkding' }}</title>
{# Include specific theme variant based on user profile setting #} {# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %} {% if request.user_profile.theme == 'light' %}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> <link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
@@ -30,11 +31,14 @@
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %} {% endif %}
{% if request.user_profile.custom_css %} {% if request.user_profile.custom_css %}
<link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/> <link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %} {% endif %}
<meta name="turbo-cache-control" content="no-preview"> <meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %} {% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false"> <meta name="turbo-prefetch" content="false">
{% endif %} {% endif %}
{% if rss_feed_url %}
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script> <script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head> </head>

View File

@@ -3,17 +3,20 @@
{% load shared %} {% load shared %}
{% load bookmarks %} {% load bookmarks %}
{% block title %}Bookmarks - Linkding{% endblock %}
{% block content %} {% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1"> <div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <main class="main col-2" aria-labelledby="main-heading">
<div class="content-area-header mb-0"> <div class="section-header mb-0">
<h2>Bookmarks</h2> <h1 id="main-heading">Bookmarks</h1>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search %} {% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags</button> <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div> </div>
</div> </div>
@@ -27,23 +30,27 @@
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
</section> </main>
{# Tag cloud #} {# Tag cloud #}
<section class="content-area col-1 hide-md"> <div class="side-panel col-1">
<div class="content-area-header"> <section aria-labelledby="tags-heading">
<h2>Tags</h2> <div class="section-header">
</div> <h2 id="tags-heading">Tags</h2>
<div id="tag-cloud-container"> </div>
{% include 'bookmarks/tag_cloud.html' %} <div id="tag-cloud-container">
</div> {% include 'bookmarks/tag_cloud.html' %}
</section> </div>
</section>
{# Bookmark details #} </div>
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -2,8 +2,8 @@
<!DOCTYPE html> <!DOCTYPE html>
{# Use data attributes as storage for access in static scripts #} {# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}"> <html lang="en" data-api-base-url="{% url 'linkding:api-root' %}">
{% include 'bookmarks/head.html' %} {% block head %}{% include 'bookmarks/head.html' %}{% endblock %}
<body ld-global-shortcuts> <body ld-global-shortcuts>
<div class="d-none"> <div class="d-none">
@@ -68,7 +68,7 @@
<header class="container"> <header class="container">
{% if has_toasts %} {% if has_toasts %}
<div class="toasts"> <div class="toasts">
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post"> <form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %} {% csrf_token %}
{% for toast in toast_messages %} {% for toast in toast_messages %}
<div class="toast d-flex"> <div class="toast d-flex">
@@ -80,22 +80,28 @@
</div> </div>
{% endif %} {% endif %}
<div class="d-flex justify-between"> <div class="d-flex justify-between">
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center"> <a href="{% url 'linkding:root' %}" class="app-link d-flex align-center">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo"> <img class="app-logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>LINKDING</h1> <span class="app-name">LINKDING</span>
</a> </a>
{% if request.user.is_authenticated %} <nav>
{# Only show nav items menu when logged in #} {% if request.user.is_authenticated %}
{% include 'bookmarks/nav_menu.html' %} {# Only show nav items menu when logged in #}
{% else %} {% include 'bookmarks/nav_menu.html' %}
{# Otherwise show login link #} {% else %}
<a href="{% url 'login' %}" class="btn btn-link">Login</a> {# Otherwise show login link #}
{% endif %} <a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %}
</nav>
</div> </div>
</header> </header>
<div class="content container"> <div class="content container">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
<div class="modals">
{% block overlays %}
{% endblock %}
</div>
</body> </body>
</html> </html>

View File

@@ -2,77 +2,103 @@
{% htmlmin %} {% htmlmin %}
{# Basic menu list #} {# Basic menu list #}
<div class="hide-md"> <div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a> <a href="{% url 'linkding:bookmarks.new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown"> <div ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0"> <button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks Bookmarks
</button> </button>
<ul class="menu"> <ul class="menu" role="list" tabindex="-1">
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a> <a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Active</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a> <a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived</a>
</li> </li>
{% if request.user_profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a> <a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared</a>
</li> </li>
{% endif %} {% endif %}
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a> <a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a> <a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
</li> </li>
</ul> </ul>
</div> </div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a> <div ld-dropdown class="dropdown">
<form class="d-inline" action="{% url 'logout' %}" method="post"> <button class="btn btn-link dropdown-toggle" tabindex="0">
Settings
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:settings.general' %}" class="menu-link">General</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="menu-item">
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
</li>
{% endif %}
</ul>
</div>
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-link">Logout</button> <button type="submit" class="btn btn-link">Logout</button>
</form> </form>
</div> </div>
{# Menu drop-down for smaller devices #} {# Menu drop-down for smaller devices #}
<div class="show-md"> <div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary"> <a href="{% url 'linkding:bookmarks.new' %}" aria-label="Add bookmark" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px"> style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg> </svg>
</a> </a>
<div ld-dropdown class="dropdown dropdown-right"> <div ld-dropdown class="dropdown dropdown-right">
<button class="btn btn-link dropdown-toggle" tabindex="0"> <button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px"> style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg> </svg>
</button> </button>
<!-- menu component --> <!-- menu component -->
<ul class="menu"> <ul class="menu" role="list" tabindex="-1">
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a> <a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Bookmarks</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a> <a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived bookmarks</a>
</li> </li>
{% if request.user_profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a> <a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared bookmarks</a>
</li> </li>
{% endif %} {% endif %}
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a> <a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a> <a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
</li> </li>
<div class="divider"></div> <div class="divider"></div>
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a> <a href="{% url 'linkding:settings.general' %}" class="menu-link">Settings</a>
</li> </li>
<li class="menu-item"> <li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post"> <a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="menu-item">
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
</li>
{% endif %}
<div class="divider"></div>
<li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button> <button type="submit" class="btn btn-link menu-link">Logout</button>
</form> </form>

View File

@@ -1,15 +1,20 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load bookmarks %}
{% block head %}
{% with page_title="New bookmark - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
<section class="content-area"> <main aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>New bookmark</h2> <h1 id="main-heading">New bookmark</h1>
</div> </div>
<form action="{% url 'bookmarks:new' %}" method="post" novalidate> <form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate>
{% bookmark_form form return_url auto_close=auto_close %} {% include 'bookmarks/form.html' %}
</form> </form>
</section> </main>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -15,7 +15,8 @@
{% endfor %} {% endfor %}
</form> </form>
<div ld-dropdown class="search-options dropdown dropdown-right"> <div ld-dropdown class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}"> <button type="button" aria-label="Search preferences"
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -41,8 +42,11 @@
</div> </div>
{% endif %} {% endif %}
{% if 'shared' in preferences_form.editable_fields %} {% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group"> <div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div> <label id="search-shared-label"
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
Shared filter
</label>
{% for radio in preferences_form.shared %} {% for radio in preferences_form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline"> <label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }} {{ radio.tag }}
@@ -53,8 +57,11 @@
</div> </div>
{% endif %} {% endif %}
{% if 'unread' in preferences_form.editable_fields %} {% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group"> <div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div> <label id="search-unread-label"
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
Unread filter
</label>
{% for radio in preferences_form.unread %} {% for radio in preferences_form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline"> <label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }} {{ radio.tag }}

View File

@@ -4,16 +4,16 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1"> <div
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <main class="main col-2" aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Shared bookmarks</h2> <h1 id="main-heading">Shared bookmarks</h1>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search mode='shared' %} {% bookmark_search bookmark_list.search mode='shared' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</button>
</div> </div>
</div> </div>
@@ -25,30 +25,36 @@
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
</section> </main>
{# Filters #} {# Filters #}
<section class="content-area col-1 hide-md"> <div class="side-panel col-1">
<div class="content-area-header"> <section aria-labelledby="user-heading">
<h2>User</h2> <div class="section-header">
</div> <h2 id="user-heading">User</h2>
<div> </div>
{% user_select bookmark_list.search users %} <div>
<br> {% user_select bookmark_list.search users %}
</div> <br>
<div class="content-area-header"> </div>
<h2>Tags</h2> </section>
</div> <section aria-labelledby="tags-heading">
<div id="tag-cloud-container"> <div class="section-header">
{% include 'bookmarks/tag_cloud.html' %} <h2 id="tags-heading">Tags</h2>
</div> </div>
</section> <div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
{# Bookmark details #} </div>
<turbo-frame id="details-modal" target="_top"> </section>
{% if details %} </div>
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -1,8 +1,19 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block title %}Registration complete{% endblock %} {% block head %}
{% with page_title="Registration complete - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<p>Registration complete. You can now use the application.</p> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Registration complete</h1>
</div>
<p class="text-success">
You can now use the application.
</p>
</main>
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,16 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block title %}Registration{% endblock %} {% block head %}
{% with page_title="Registration - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Register</h2> <h1 id="main-heading">Register</h1>
</div> </div>
<form method="post" action="{% url 'django_registration_register' %}" novalidate> <form method="post" action="{% url 'django_registration_register' %}" novalidate>
{% csrf_token %} {% csrf_token %}
@@ -34,5 +38,5 @@
<input type="submit" value="Register" class="btn btn-primary btn-wide"> <input type="submit" value="Register" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
</form> </form>
</section> </main>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,7 @@
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
<ShortName>Linkding</ShortName>
<Description>Linkding</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">{{base_url}}static/favicon.ico</Image>
<Url type="text/html" template="{{ bookmarks_url }}?client=opensearch&amp;q={searchTerms}"/>
</OpenSearchDescription>

View File

@@ -1,12 +1,16 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block title %}Login{% endblock %} {% block head %}
{% with page_title="Login - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Login</h2> <h1 id="main-heading">Login</h1>
</div> </div>
<form method="post" action="{% url 'login' %}"> <form method="post" action="{% url 'login' %}">
{% csrf_token %} {% csrf_token %}
@@ -29,12 +33,12 @@
<input type="submit" value="Login" class="btn btn-primary btn-wide"/> <input type="submit" value="Login" class="btn btn-primary btn-wide"/>
<input type="hidden" name="next" value="{{ next }}"/> <input type="hidden" name="next" value="{{ next }}"/>
{% if enable_oidc %} {% if enable_oidc %}
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a> <a class="btn btn-link" href="{% url 'oidc_authentication_init' %}" data-turbo="false">Login with OIDC</a>
{% endif %} {% endif %}
{% if allow_registration %} {% if allow_registration %}
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a> <a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
{% endif %} {% endif %}
</div> </div>
</form> </form>
</section> </main>
{% endblock %} {% endblock %}

View File

@@ -1,15 +1,19 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block title %}Password changed{% endblock %} {% block head %}
{% with page_title="Password changed - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Password Changed</h2> <h1 id="main-heading">Password Changed</h1>
</div> </div>
<p class="text-success"> <p class="text-success">
Your password was changed successfully. Your password was changed successfully.
</p> </p>
</section> </main>
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,16 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block title %}Change Password{% endblock %} {% block head %}
{% with page_title="Change password - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="content-area-header"> <div class="section-header">
<h2>Change Password</h2> <h1 id="main-heading">Change Password</h1>
</div> </div>
<form method="post" action="{% url 'change_password' %}"> <form method="post" action="{% url 'change_password' %}">
{% csrf_token %} {% csrf_token %}
@@ -41,5 +45,5 @@
<br/> <br/>
<input type="submit" value="Change Password" class="btn btn-primary btn-wide"> <input type="submit" value="Change Password" class="btn btn-primary btn-wide">
</form> </form>
</section> </main>
{% endblock %} {% endblock %}

View File

@@ -1,10 +1,15 @@
{% extends "bookmarks/layout.html" %} {% extends "bookmarks/layout.html" %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block content %} {% block head %}
<div class="settings-page"> {% with page_title="Settings - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% include 'settings/nav.html' %} {% block content %}
<main class="settings-page" aria-labelledby="main-heading">
<h1 id="main-heading">Settings</h1>
{# Profile section #} {# Profile section #}
{% if success_message %} {% if success_message %}
@@ -14,12 +19,12 @@
<div class="toast toast-error mb-4">{{ error_message }}</div> <div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %} {% endif %}
<section class="content-area"> <section aria-labelledby="profile-heading">
<h2>Profile</h2> <h2 id="profile-heading">Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
</p> </p>
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false"> <form action="{% url 'linkding:settings.update' %}" method="post" novalidate data-turbo="false">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label> <label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
@@ -124,6 +129,16 @@
visible without having to scroll to the end of the page first. visible without having to scroll to the end of the page first.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
{{ form.collapse_side_panel }}
<i class="form-icon"></i> Collapse side panel
</label>
<div class="form-input-hint">
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
Instead, the tags are shown in an expandable drawer.
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label> <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }} {{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
@@ -219,7 +234,7 @@ reddit.com/r/Music music reddit</pre>
<div class="form-input-hint"> <div class="form-input-hint">
Makes shared bookmarks publicly accessible, without requiring a login. 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 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>. href="{% url 'linkding:bookmarks.shared' %}">shared bookmarks page</a>.
</div> </div>
</div> </div>
{% if has_snapshot_support %} {% if has_snapshot_support %}
@@ -268,9 +283,9 @@ reddit.com/r/Music music reddit</pre>
{# Global settings section #} {# Global settings section #}
{% if global_settings_form %} {% if global_settings_form %}
<section class="content-area"> <section aria-labelledby="global-settings-heading">
<h2>Global settings</h2> <h2 id="global-settings-heading">Global settings</h2>
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false"> <form action="{% url 'linkding:settings.update' %}" method="post" novalidate data-turbo="false">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label> <label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
@@ -308,11 +323,11 @@ reddit.com/r/Music music reddit</pre>
{% endif %} {% endif %}
{# Import section #} {# Import section #}
<section class="content-area"> <section aria-labelledby="import-heading">
<h2>Import</h2> <h2 id="import-heading">Import</h2>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are <p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.</p> added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}"> <form method="post" enctype="multipart/form-data" action="{% url 'linkding:settings.import' %}">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="import_map_private_flag" class="form-checkbox"> <label for="import_map_private_flag" class="form-checkbox">
@@ -336,10 +351,10 @@ reddit.com/r/Music music reddit</pre>
</section> </section>
{# Export section #} {# Export section #}
<section class="content-area"> <section aria-labelledby="export-heading">
<h2>Export</h2> <h2 id="export-heading">Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p> <p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" target="_blank" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a> <a class="btn btn-primary" target="_blank" href="{% url 'linkding:settings.export' %}">Download (.html)</a>
{% if export_error %} {% if export_error %}
<div class="has-error"> <div class="has-error">
<p class="form-input-hint"> <p class="form-input-hint">
@@ -350,8 +365,8 @@ reddit.com/r/Music music reddit</pre>
</section> </section>
{# About section #} {# About section #}
<section class="content-area about"> <section class="about" aria-labelledby="about-heading">
<h2>About</h2> <h2 id="about-heading">About</h2>
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
@@ -374,7 +389,7 @@ reddit.com/r/Music music reddit</pre>
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </main>
<script> <script>
(function init() { (function init() {

View File

@@ -1,12 +1,17 @@
{% extends "bookmarks/layout.html" %} {% extends "bookmarks/layout.html" %}
{% block head %}
{% with page_title="Integrations - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %} {% block content %}
<div class="settings-page"> <main class="settings-page" aria-labelledby="main-heading">
<h1 id="main-heading">Integrations</h1>
{% include 'settings/nav.html' %} <section aria-labelledby="browser-extension-heading">
<h2 id="browser-extension-heading">Browser Extension</h2>
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The <p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
extension is available in the official extension stores for:</p> extension is available in the official extension stores for:</p>
<ul> <ul>
@@ -31,8 +36,8 @@
class="btn btn-primary">📎 Add bookmark</a> class="btn btn-primary">📎 Add bookmark</a>
</section> </section>
<section class="content-area"> <section aria-labelledby="rest-api-heading">
<h2>REST API</h2> <h2 id="rest-api-heading">REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p> <p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group"> <div class="form-group">
<div class="width-50 width-md-100"> <div class="width-50 width-md-100">
@@ -48,8 +53,8 @@
</p> </p>
</section> </section>
<section class="content-area"> <section aria-labelledby="rss-feeds-heading">
<h2>RSS Feeds</h2> <h2 id="rss-feeds-heading">RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p> <p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;"> <ul style="list-style-position: outside;">
<li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li> <li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li>
@@ -84,5 +89,5 @@
After deleting the feed token, new URLs will be generated when you reload this settings page. After deleting the feed token, new URLs will be generated when you reload this settings page.
</p> </p>
</section> </section>
</div> </main>
{% endblock %} {% endblock %}

View File

@@ -1,24 +0,0 @@
{% url 'bookmarks:settings.index' as index_url %}
{% url 'bookmarks:settings.general' as general_url %}
{% url 'bookmarks:settings.integrations' as integrations_url %}
<ul class="tab tab-block">
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == general_url%}active{% endif %}">
<a href="{% url 'bookmarks:settings.general' %}">General</a>
</li>
<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">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</a>
</li>
{% endif %}
</ul>
<br>

View File

@@ -3,7 +3,6 @@ from typing import List
from django import template from django import template
from bookmarks.models import ( from bookmarks.models import (
BookmarkForm,
BookmarkSearch, BookmarkSearch,
BookmarkSearchForm, BookmarkSearchForm,
User, User,
@@ -12,23 +11,6 @@ from bookmarks.models import (
register = template.Library() register = template.Library()
@register.inclusion_tag("bookmarks/form.html", name="bookmark_form", takes_context=True)
def bookmark_form(
context,
form: BookmarkForm,
cancel_url: str,
bookmark_id: int = 0,
auto_close: bool = False,
):
return {
"request": context["request"],
"form": form,
"auto_close": auto_close,
"bookmark_id": bookmark_id,
"cancel_url": cancel_url,
}
@register.inclusion_tag( @register.inclusion_tag(
"bookmarks/search.html", name="bookmark_search", takes_context=True "bookmarks/search.html", name="bookmark_search", takes_context=True
) )

View File

@@ -142,5 +142,6 @@ def render_markdown(context, markdown_text):
as_html = renderer.convert(markdown_text) as_html = renderer.convert(markdown_text)
sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs) sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)
linkified_html = bleach.linkify(sanitized_html)
return mark_safe(sanitized_html) return mark_safe(linkified_html)

View File

@@ -1,22 +1,38 @@
import random import gzip
import logging import logging
import os
import random
import shutil
import tempfile
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from unittest import TestCase from unittest import TestCase
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.contrib.auth.models import User from django.conf import settings
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
class BookmarkFactoryMixin: class BookmarkFactoryMixin:
user = None user = None
def setup_temp_assets_dir(self):
self.assets_dir = tempfile.mkdtemp()
self.settings_override = override_settings(LD_ASSET_FOLDER=self.assets_dir)
self.settings_override.enable()
self.addCleanup(self.cleanup_temp_assets_dir)
def cleanup_temp_assets_dir(self):
shutil.rmtree(self.assets_dir)
self.settings_override.disable()
def get_or_create_test_user(self): def get_or_create_test_user(self):
if self.user is None: if self.user is None:
self.user = User.objects.create_user( self.user = User.objects.create_user(
@@ -182,6 +198,24 @@ class BookmarkFactoryMixin:
asset.save() asset.save()
return asset return asset
def setup_asset_file(self, asset: BookmarkAsset, file_content: str = "test"):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if asset.gzip:
with gzip.open(filepath, "wb") as f:
f.write(file_content.encode())
else:
with open(filepath, "w") as f:
f.write(file_content)
def read_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
with open(filepath, "rb") as f:
return f.read()
def has_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
return os.path.exists(filepath)
def setup_tag(self, user: User = None, name: str = ""): def setup_tag(self, user: User = None, name: str = ""):
if user is None: if user is None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -290,6 +324,12 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin):
class LinkdingApiTestCase(APITestCase): class LinkdingApiTestCase(APITestCase):
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 get(self, url, expected_status_code=status.HTTP_200_OK): def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)

View File

@@ -7,7 +7,7 @@ from django.test import TestCase
class AppOptionsTestCase(TestCase): class AppOptionsTestCase(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.settings_module = importlib.import_module("siteroot.settings.base") self.settings_module = importlib.import_module("bookmarks.settings.base")
def test_empty_csrf_trusted_origins(self): def test_empty_csrf_trusted_origins(self):
module = importlib.reload(self.settings_module) module = importlib.reload(self.settings_module)

View File

@@ -0,0 +1,411 @@
import datetime
import gzip
import os
from datetime import timedelta
from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import BookmarkAsset
from bookmarks.services import assets
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.setup_temp_assets_dir()
self.get_or_create_test_user()
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
self.mock_singlefile_create_snapshot_patcher = mock.patch(
"bookmarks.services.singlefile.create_snapshot",
)
self.mock_singlefile_create_snapshot = (
self.mock_singlefile_create_snapshot_patcher.start()
)
self.mock_singlefile_create_snapshot.side_effect = lambda url, filepath: (
open(filepath, "w").write(self.html_content)
)
def tearDown(self) -> None:
self.mock_singlefile_create_snapshot_patcher.stop()
def get_saved_snapshot_file(self):
# look up first file in the asset folder
files = os.listdir(self.assets_dir)
if files:
return files[0]
def test_create_snapshot_asset(self):
bookmark = self.setup_bookmark()
asset = assets.create_snapshot_asset(bookmark)
self.assertIsNotNone(asset)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot from", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
# asset is not saved to the database
self.assertIsNone(asset.id)
def test_create_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
asset.save()
asset.date_created = timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
)
assets.create_snapshot(asset)
expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp"
expected_temp_filepath = os.path.join(self.assets_dir, expected_temp_filename)
expected_filename = "snapshot_2023-08-11_214511_https___example.com.html.gz"
expected_filepath = os.path.join(self.assets_dir, expected_filename)
# should call singlefile.create_snapshot with the correct arguments
self.mock_singlefile_create_snapshot.assert_called_once_with(
"https://example.com",
expected_temp_filepath,
)
# should create gzip file in asset folder
self.assertTrue(os.path.exists(expected_filepath))
# gzip file should contain the correct content
with gzip.open(expected_filepath, "rb") as gz_file:
self.assertEqual(gz_file.read().decode(), self.html_content)
# should remove temporary file
self.assertFalse(os.path.exists(expected_temp_filepath))
# should update asset status and file
asset.refresh_from_db()
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
def test_create_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
asset.save()
self.mock_singlefile_create_snapshot.side_effect = Exception
with self.assertRaises(Exception):
assets.create_snapshot(asset)
asset.refresh_from_db()
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
def test_create_snapshot_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
asset = assets.create_snapshot_asset(bookmark)
asset.save()
assets.create_snapshot(asset)
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("snapshot_"))
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
def test_upload_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
# should create gzip file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(saved_file_name)
# verify file name
self.assertTrue(saved_file_name.startswith("snapshot_"))
self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz"))
# gzip file should contain the correct content
with gzip.open(os.path.join(self.assets_dir, saved_file_name), "rb") as gz_file:
self.assertEqual(gz_file.read().decode(), self.html_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot from", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertTrue(asset.gzip)
def test_upload_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
# make gzip.open raise an exception
with mock.patch("gzip.open") as mock_gzip_open:
mock_gzip_open.side_effect = Exception
with self.assertRaises(Exception):
assets.upload_snapshot(bookmark, b"invalid content")
# asset is not saved to the database
self.assertIsNone(BookmarkAsset.objects.first())
def test_upload_snapshot_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
assets.upload_snapshot(bookmark, self.html_content.encode())
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("snapshot_"))
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
@disable_logging
def test_upload_asset(self):
bookmark = self.setup_bookmark()
file_content = b"test content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
asset = assets.upload_asset(bookmark, upload_file)
# should create file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(upload_file)
# verify file name
self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.txt"))
# file should contain the correct content
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
self.assertEqual(file.read(), file_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, upload_file.content_type)
self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
@disable_logging
def test_upload_asset_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_file_name = "a" * 300 + ".txt"
bookmark = self.setup_bookmark()
file_content = b"test content"
upload_file = SimpleUploadedFile(
long_file_name, file_content, content_type="text/plain"
)
assets.upload_asset(bookmark, upload_file)
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt"))
@disable_logging
def test_upload_asset_failure(self):
bookmark = self.setup_bookmark()
upload_file = SimpleUploadedFile("test_file.txt", b"test content")
# make open raise an exception
with mock.patch("builtins.open") as mock_open:
mock_open.side_effect = Exception
with self.assertRaises(Exception):
assets.upload_asset(bookmark, upload_file)
# asset is not saved to the database
self.assertIsNone(BookmarkAsset.objects.first())
def test_create_snapshot_updates_bookmark_latest_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
first_asset = assets.create_snapshot_asset(bookmark)
first_asset.save()
assets.create_snapshot(first_asset)
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, first_asset)
second_asset = assets.create_snapshot_asset(bookmark)
second_asset.save()
assets.create_snapshot(second_asset)
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, second_asset)
def test_upload_snapshot_updates_bookmark_latest_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
first_asset = assets.upload_snapshot(bookmark, self.html_content.encode())
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, first_asset)
second_asset = assets.upload_snapshot(bookmark, self.html_content.encode())
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, second_asset)
self.assertNotEqual(bookmark.latest_snapshot, first_asset)
def test_create_snapshot_failure_does_not_update_latest_snapshot(self):
# Create a bookmark with an existing latest_snapshot
bookmark = self.setup_bookmark(url="https://example.com")
initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
# Create a new snapshot asset that will fail
failing_asset = assets.create_snapshot_asset(bookmark)
failing_asset.save()
# Make the snapshot creation fail
self.mock_singlefile_create_snapshot.side_effect = Exception(
"Snapshot creation failed"
)
# Attempt to create a snapshot (which will fail)
with self.assertRaises(Exception):
assets.create_snapshot(failing_asset)
# Verify that the bookmark's latest_snapshot is still the initial snapshot
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
def test_upload_snapshot_failure_does_not_update_latest_snapshot(self):
# Create a bookmark with an existing latest_snapshot
bookmark = self.setup_bookmark(url="https://example.com")
initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
# Make the gzip.open function fail
with mock.patch("gzip.open") as mock_gzip_open:
mock_gzip_open.side_effect = Exception("Upload failed")
# Attempt to upload a snapshot (which will fail)
with self.assertRaises(Exception):
assets.upload_snapshot(bookmark, b"New content")
# Verify that the bookmark's latest_snapshot is still the initial snapshot
bookmark.refresh_from_db()
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
def test_remove_latest_snapshot_updates_bookmark(self):
# Create a bookmark with multiple snapshots
bookmark = self.setup_bookmark()
# Create base time (1 hour ago)
base_time = timezone.now() - timedelta(hours=1)
# Create three snapshots with explicitly different dates
old_asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
file="old_snapshot.html.gz",
date_created=base_time,
)
self.setup_asset_file(old_asset)
middle_asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
file="middle_snapshot.html.gz",
date_created=base_time + timedelta(minutes=30),
)
self.setup_asset_file(middle_asset)
latest_asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
file="latest_snapshot.html.gz",
date_created=base_time + timedelta(minutes=60),
)
self.setup_asset_file(latest_asset)
# Set the latest asset as the bookmark's latest_snapshot
bookmark.latest_snapshot = latest_asset
bookmark.save()
# Delete the latest snapshot
assets.remove_asset(latest_asset)
bookmark.refresh_from_db()
# Verify that middle_asset is now the latest_snapshot
self.assertEqual(bookmark.latest_snapshot, middle_asset)
# Delete the middle snapshot
assets.remove_asset(middle_asset)
bookmark.refresh_from_db()
# Verify that old_asset is now the latest_snapshot
self.assertEqual(bookmark.latest_snapshot, old_asset)
# Delete the last snapshot
assets.remove_asset(old_asset)
bookmark.refresh_from_db()
# Verify that latest_snapshot is now None
self.assertIsNone(bookmark.latest_snapshot)
def test_remove_non_latest_snapshot_does_not_affect_bookmark(self):
# Create a bookmark with multiple snapshots
bookmark = self.setup_bookmark()
# Create base time (1 hour ago)
base_time = timezone.now() - timedelta(hours=1)
# Create two snapshots with explicitly different dates
old_asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
file="old_snapshot.html.gz",
date_created=base_time,
)
self.setup_asset_file(old_asset)
latest_asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
file="latest_snapshot.html.gz",
date_created=base_time + timedelta(minutes=30),
)
self.setup_asset_file(latest_asset)
# Set the latest asset as the bookmark's latest_snapshot
bookmark.latest_snapshot = latest_asset
bookmark.save()
# Delete the old snapshot (not the latest)
assets.remove_asset(old_asset)
bookmark.refresh_from_db()
# Verify that latest_snapshot hasn't changed
self.assertEqual(bookmark.latest_snapshot, latest_asset)

View File

@@ -0,0 +1,32 @@
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 AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def authenticate(self, keyword):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION=f"{keyword} {self.api_token.key}")
def test_auth_with_token_keyword(self):
self.authenticate("Token")
url = reverse("linkding:user-profile")
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_auth_with_bearer_keyword(self):
self.authenticate("Bearer")
url = reverse("linkding:user-profile")
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_auth_with_unknown_keyword(self):
self.authenticate("Key")
url = reverse("linkding:user-profile")
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -21,7 +21,7 @@ class AuthProxySupportTest(TestCase):
) )
headers = {"REMOTE_USER": user.username} headers = {"REMOTE_USER": user.username}
response = self.client.get(reverse("bookmarks:index"), **headers) response = self.client.get(reverse("linkding:bookmarks.index"), **headers)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -43,7 +43,7 @@ class AuthProxySupportTest(TestCase):
) )
headers = {"Custom-User": user.username} headers = {"Custom-User": user.username}
response = self.client.get(reverse("bookmarks:index"), **headers) response = self.client.get(reverse("linkding:bookmarks.index"), **headers)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -53,6 +53,8 @@ class AuthProxySupportTest(TestCase):
) )
headers = {"REMOTE_USER": user.username} headers = {"REMOTE_USER": user.username}
response = self.client.get(reverse("bookmarks:index"), **headers, follow=True) response = self.client.get(
reverse("linkding:bookmarks.index"), **headers, follow=True
)
self.assertRedirects(response, "/login/?next=%2Fbookmarks") self.assertRedirects(response, "/login/?next=%2Fbookmarks")

View File

@@ -202,3 +202,44 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url) tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"tag1", "tag2"}) self.assertEqual(tags, {"tag1", "tag2"})
def test_auto_tag_with_url_fragment(self):
script = """
example.com/#/section/1 section1
example.com/#/section/2 section2
"""
url = "https://example.com/#/section/1"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"section1"})
def test_auto_tag_with_url_fragment_partial_match(self):
script = """
example.com/#/section section
"""
url = "https://example.com/#/section/1"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"section"})
def test_auto_tag_with_url_fragment_ignores_case(self):
script = """
example.com/#SECTION section
"""
url = "https://example.com/#section"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"section"})
def test_auto_tag_with_url_fragment_and_comment(self):
script = """
example.com/#section1 section1 #This is a comment
"""
url = "https://example.com/#section1"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"section1"})

View File

@@ -8,7 +8,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkAsset from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import tasks, bookmarks from bookmarks.services import assets, tasks
from bookmarks.tests.helpers import ( from bookmarks.tests.helpers import (
BookmarkFactoryMixin, BookmarkFactoryMixin,
BookmarkListTestMixin, BookmarkListTestMixin,
@@ -37,7 +37,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"archive": [bookmark.id], "archive": [bookmark.id],
}, },
@@ -54,7 +54,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"archive": [bookmark.id], "archive": [bookmark.id],
}, },
@@ -69,7 +69,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(is_archived=True) bookmark = self.setup_bookmark(is_archived=True)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"unarchive": [bookmark.id], "unarchive": [bookmark.id],
}, },
@@ -85,7 +85,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(is_archived=True, user=other_user) bookmark = self.setup_bookmark(is_archived=True, user=other_user)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"unarchive": [bookmark.id], "unarchive": [bookmark.id],
}, },
@@ -99,7 +99,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"remove": [bookmark.id], "remove": [bookmark.id],
}, },
@@ -114,7 +114,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"remove": [bookmark.id], "remove": [bookmark.id],
}, },
@@ -126,7 +126,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(unread=True) bookmark = self.setup_bookmark(unread=True)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"mark_as_read": [bookmark.id], "mark_as_read": [bookmark.id],
}, },
@@ -139,7 +139,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"unshare": [bookmark.id], "unshare": [bookmark.id],
}, },
@@ -156,7 +156,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"unshare": [bookmark.id], "unshare": [bookmark.id],
}, },
@@ -172,7 +172,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(tasks, "_create_html_snapshot_task"): with patch.object(tasks, "_create_html_snapshot_task"):
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"create_html_snapshot": [bookmark.id], "create_html_snapshot": [bookmark.id],
}, },
@@ -187,7 +187,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
with patch.object(tasks, "_create_html_snapshot_task"): with patch.object(tasks, "_create_html_snapshot_task"):
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"create_html_snapshot": [bookmark.id], "create_html_snapshot": [bookmark.id],
}, },
@@ -200,9 +200,9 @@ class BookmarkActionViewTestCase(
file_content = b"file content" file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content) upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset: with patch.object(assets, "upload_asset") as mock_upload_asset:
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file}, {"upload_asset": bookmark.id, "upload_asset_file": upload_file},
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -221,21 +221,42 @@ class BookmarkActionViewTestCase(
file_content = b"file content" file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content) upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset: with patch.object(assets, "upload_asset") as mock_upload_asset:
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file}, {"upload_asset": bookmark.id, "upload_asset_file": upload_file},
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
mock_upload_asset.assert_not_called() mock_upload_asset.assert_not_called()
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_upload_asset_disabled(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
response = self.client.post(
reverse("linkding:bookmarks.index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 403)
def test_upload_asset_without_file(self):
bookmark = self.setup_bookmark()
response = self.client.post(
reverse("linkding:bookmarks.index.action"),
{"upload_asset": bookmark.id},
)
self.assertEqual(response.status_code, 400)
def test_remove_asset(self): def test_remove_asset(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), {"remove_asset": asset.id} reverse("linkding:bookmarks.index.action"), {"remove_asset": asset.id}
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists()) self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
@@ -246,7 +267,7 @@ class BookmarkActionViewTestCase(
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), {"remove_asset": asset.id} reverse("linkding:bookmarks.index.action"), {"remove_asset": asset.id}
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@@ -255,7 +276,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"update_state": bookmark.id, "update_state": bookmark.id,
"is_archived": "on", "is_archived": "on",
@@ -275,7 +296,7 @@ class BookmarkActionViewTestCase(
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"update_state": bookmark.id, "update_state": bookmark.id,
"is_archived": "on", "is_archived": "on",
@@ -296,7 +317,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_archive"], "bulk_action": ["bulk_archive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -321,7 +342,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(user=other_user) bookmark3 = self.setup_bookmark(user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_archive"], "bulk_action": ["bulk_archive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -343,7 +364,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(is_archived=True) bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post( self.client.post(
reverse("bookmarks:archived.action"), reverse("linkding:bookmarks.archived.action"),
{ {
"bulk_action": ["bulk_unarchive"], "bulk_action": ["bulk_unarchive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -368,7 +389,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user) bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:archived.action"), reverse("linkding:bookmarks.archived.action"),
{ {
"bulk_action": ["bulk_unarchive"], "bulk_action": ["bulk_unarchive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -390,7 +411,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -415,7 +436,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(user=other_user) bookmark3 = self.setup_bookmark(user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -439,7 +460,7 @@ class BookmarkActionViewTestCase(
tag2 = self.setup_tag() tag2 = self.setup_tag()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_tag"], "bulk_action": ["bulk_tag"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -471,7 +492,7 @@ class BookmarkActionViewTestCase(
tag2 = self.setup_tag() tag2 = self.setup_tag()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_tag"], "bulk_action": ["bulk_tag"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -500,7 +521,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_untag"], "bulk_action": ["bulk_untag"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -532,7 +553,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user) bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_untag"], "bulk_action": ["bulk_untag"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -559,7 +580,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(unread=True) bookmark3 = self.setup_bookmark(unread=True)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_read"], "bulk_action": ["bulk_read"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -584,7 +605,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(unread=True, user=other_user) bookmark3 = self.setup_bookmark(unread=True, user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_read"], "bulk_action": ["bulk_read"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -606,7 +627,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(unread=False) bookmark3 = self.setup_bookmark(unread=False)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_unread"], "bulk_action": ["bulk_unread"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -631,7 +652,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(unread=False, user=other_user) bookmark3 = self.setup_bookmark(unread=False, user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_unread"], "bulk_action": ["bulk_unread"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -653,7 +674,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(shared=False) bookmark3 = self.setup_bookmark(shared=False)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_share"], "bulk_action": ["bulk_share"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -678,7 +699,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(shared=False, user=other_user) bookmark3 = self.setup_bookmark(shared=False, user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_share"], "bulk_action": ["bulk_share"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -700,7 +721,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(shared=True) bookmark3 = self.setup_bookmark(shared=True)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_unshare"], "bulk_action": ["bulk_unshare"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -725,7 +746,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark(shared=True, user=other_user) bookmark3 = self.setup_bookmark(shared=True, user=other_user)
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_unshare"], "bulk_action": ["bulk_unshare"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -747,7 +768,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_archive"], "bulk_action": ["bulk_archive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -763,7 +784,7 @@ class BookmarkActionViewTestCase(
self.setup_numbered_bookmarks(100) self.setup_numbered_bookmarks(100)
self.client.post( self.client.post(
reverse("bookmarks:index.action") + "?page=2", reverse("linkding:bookmarks.index.action") + "?page=2",
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -792,7 +813,7 @@ class BookmarkActionViewTestCase(
self.assertIsNotNone(Bookmark.objects.filter(title="Bookmark 3").first()) self.assertIsNotNone(Bookmark.objects.filter(title="Bookmark 3").first())
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -812,7 +833,7 @@ class BookmarkActionViewTestCase(
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count()) self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
self.client.post( self.client.post(
reverse("bookmarks:index.action") + "?q=foo", reverse("linkding:bookmarks.index.action") + "?q=foo",
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -837,7 +858,7 @@ class BookmarkActionViewTestCase(
) )
self.client.post( self.client.post(
reverse("bookmarks:archived.action"), reverse("linkding:bookmarks.archived.action"),
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -857,7 +878,7 @@ class BookmarkActionViewTestCase(
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count()) self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
self.client.post( self.client.post(
reverse("bookmarks:archived.action") + "?q=foo", reverse("linkding:bookmarks.archived.action") + "?q=foo",
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -872,7 +893,7 @@ class BookmarkActionViewTestCase(
self.setup_bulk_edit_scope_test_data() self.setup_bulk_edit_scope_test_data()
response = self.client.post( response = self.client.post(
reverse("bookmarks:shared.action"), reverse("linkding:bookmarks.shared.action"),
{ {
"bulk_action": ["bulk_delete"], "bulk_action": ["bulk_delete"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -887,7 +908,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_archive"], "bulk_action": ["bulk_archive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -896,7 +917,7 @@ class BookmarkActionViewTestCase(
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bulk_action": ["bulk_archive"], "bulk_action": ["bulk_archive"],
"bulk_execute": [""], "bulk_execute": [""],
@@ -913,7 +934,7 @@ class BookmarkActionViewTestCase(
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post( self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
{ {
"bookmark_id": [ "bookmark_id": [
str(bookmark1.id), str(bookmark1.id),
@@ -926,22 +947,22 @@ class BookmarkActionViewTestCase(
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3]) self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_index_action_redirects_to_index_with_query_params(self): def test_index_action_redirects_to_index_with_query_params(self):
url = reverse("bookmarks:index.action") + "?q=foo&page=2" url = reverse("linkding:bookmarks.index.action") + "?q=foo&page=2"
redirect_url = reverse("bookmarks:index") + "?q=foo&page=2" redirect_url = reverse("linkding:bookmarks.index") + "?q=foo&page=2"
response = self.client.post(url) response = self.client.post(url)
self.assertRedirects(response, redirect_url) self.assertRedirects(response, redirect_url)
def test_archived_action_redirects_to_archived_with_query_params(self): def test_archived_action_redirects_to_archived_with_query_params(self):
url = reverse("bookmarks:archived.action") + "?q=foo&page=2" url = reverse("linkding:bookmarks.archived.action") + "?q=foo&page=2"
redirect_url = reverse("bookmarks:archived") + "?q=foo&page=2" redirect_url = reverse("linkding:bookmarks.archived") + "?q=foo&page=2"
response = self.client.post(url) response = self.client.post(url)
self.assertRedirects(response, redirect_url) self.assertRedirects(response, redirect_url)
def test_shared_action_redirects_to_shared_with_query_params(self): def test_shared_action_redirects_to_shared_with_query_params(self):
url = reverse("bookmarks:shared.action") + "?q=foo&page=2" url = reverse("linkding:bookmarks.shared.action") + "?q=foo&page=2"
redirect_url = reverse("bookmarks:shared") + "?q=foo&page=2" redirect_url = reverse("linkding:bookmarks.shared") + "?q=foo&page=2"
response = self.client.post(url) response = self.client.post(url)
self.assertRedirects(response, redirect_url) self.assertRedirects(response, redirect_url)
@@ -991,7 +1012,7 @@ class BookmarkActionViewTestCase(
def test_index_action_with_turbo_returns_bookmark_update(self): def test_index_action_with_turbo_returns_bookmark_update(self):
fixture = self.bookmark_update_fixture() fixture = self.bookmark_update_fixture()
response = self.client.post( response = self.client.post(
reverse("bookmarks:index.action"), reverse("linkding:bookmarks.index.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html", HTTP_ACCEPT="text/vnd.turbo-stream.html",
) )
@@ -1009,7 +1030,7 @@ class BookmarkActionViewTestCase(
def test_archived_action_with_turbo_returns_bookmark_update(self): def test_archived_action_with_turbo_returns_bookmark_update(self):
fixture = self.bookmark_update_fixture() fixture = self.bookmark_update_fixture()
response = self.client.post( response = self.client.post(
reverse("bookmarks:archived.action"), reverse("linkding:bookmarks.archived.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html", HTTP_ACCEPT="text/vnd.turbo-stream.html",
) )
@@ -1027,7 +1048,7 @@ class BookmarkActionViewTestCase(
def test_shared_action_with_turbo_returns_bookmark_update(self): def test_shared_action_with_turbo_returns_bookmark_update(self):
fixture = self.bookmark_update_fixture() fixture = self.bookmark_update_fixture()
response = self.client.post( response = self.client.post(
reverse("bookmarks:shared.action"), reverse("linkding:bookmarks.shared.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html", HTTP_ACCEPT="text/vnd.turbo-stream.html",
) )

View File

@@ -46,7 +46,7 @@ class BookmarkArchivedViewTestCase(
self.setup_bookmark(is_archived=True, user=other_user), self.setup_bookmark(is_archived=True, user=other_user),
] ]
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -59,7 +59,7 @@ class BookmarkArchivedViewTestCase(
3, prefix="bar", archived=True 3, prefix="bar", archived=True
) )
response = self.client.get(reverse("bookmarks:archived") + "?q=foo") response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
html = collapse_whitespace(response.content.decode()) html = collapse_whitespace(response.content.decode())
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
@@ -84,7 +84,7 @@ class BookmarkArchivedViewTestCase(
unarchived_bookmarks + other_user_bookmarks unarchived_bookmarks + other_user_bookmarks
) )
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -100,7 +100,7 @@ class BookmarkArchivedViewTestCase(
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks) visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks) invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
response = self.client.get(reverse("bookmarks:archived") + "?q=foo") response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -132,7 +132,7 @@ class BookmarkArchivedViewTestCase(
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks) unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks) read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertVisibleBookmarks(response, unread_bookmarks) self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks) self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags) self.assertVisibleTags(response, unread_tags)
@@ -149,7 +149,8 @@ class BookmarkArchivedViewTestCase(
self.setup_bookmark(is_archived=True, tags=tags) self.setup_bookmark(is_archived=True, tags=tags)
response = self.client.get( response = self.client.get(
reverse("bookmarks:archived") + f"?q=%23{tags[0].name}+%23{tags[1].name}" reverse("linkding:bookmarks.archived")
+ f"?q=%23{tags[0].name}+%23{tags[1].name}"
) )
self.assertSelectedTags(response, [tags[0], tags[1]]) self.assertSelectedTags(response, [tags[0], tags[1]])
@@ -167,7 +168,7 @@ class BookmarkArchivedViewTestCase(
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True) self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
response = self.client.get( response = self.client.get(
reverse("bookmarks:archived") reverse("linkding:bookmarks.archived")
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}" + f"?q={tags[0].name}+%23{tags[1].name.upper()}"
) )
@@ -187,7 +188,7 @@ class BookmarkArchivedViewTestCase(
self.setup_bookmark(tags=tags, is_archived=True) self.setup_bookmark(tags=tags, is_archived=True)
response = self.client.get( response = self.client.get(
reverse("bookmarks:archived") reverse("linkding:bookmarks.archived")
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}" + f"?q={tags[0].name}+%23{tags[1].name.upper()}"
) )
@@ -196,7 +197,7 @@ class BookmarkArchivedViewTestCase(
def test_should_open_bookmarks_in_new_page_by_default(self): def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True) visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank") self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
@@ -207,14 +208,14 @@ class BookmarkArchivedViewTestCase(
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True) visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_self") self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
def test_edit_link_return_url_respects_search_options(self): def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title="foo", is_archived=True) bookmark = self.setup_bookmark(title="foo", is_archived=True)
edit_url = reverse("bookmarks:edit", args=[bookmark.id]) edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
base_url = reverse("bookmarks:archived") base_url = reverse("linkding:bookmarks.archived")
# without query params # without query params
return_url = urllib.parse.quote(base_url) return_url = urllib.parse.quote(base_url)
@@ -240,8 +241,8 @@ class BookmarkArchivedViewTestCase(
self.assertEditLink(response, url) self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self): def test_bulk_edit_respects_search_options(self):
action_url = reverse("bookmarks:archived.action") action_url = reverse("linkding:bookmarks.archived.action")
base_url = reverse("bookmarks:archived") base_url = reverse("linkding:bookmarks.archived")
# without params # without params
url = f"{action_url}" url = f"{action_url}"
@@ -264,7 +265,7 @@ class BookmarkArchivedViewTestCase(
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self): def test_allowed_bulk_actions(self):
url = reverse("bookmarks:archived") url = reverse("linkding:bookmarks.archived")
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
@@ -277,6 +278,7 @@ class BookmarkArchivedViewTestCase(
<option value="bulk_untag">Remove tags</option> <option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option> <option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option> <option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
</select> </select>
""", """,
html, html,
@@ -287,7 +289,7 @@ class BookmarkArchivedViewTestCase(
user_profile.enable_sharing = True user_profile.enable_sharing = True
user_profile.save() user_profile.save()
url = reverse("bookmarks:archived") url = reverse("linkding:bookmarks.archived")
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
@@ -302,6 +304,7 @@ class BookmarkArchivedViewTestCase(
<option value="bulk_unread">Mark as unread</option> <option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option> <option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
</select> </select>
""", """,
html, html,
@@ -309,13 +312,13 @@ class BookmarkArchivedViewTestCase(
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("bookmarks:archived")) response = self.client.post(reverse("linkding:bookmarks.archived"))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("bookmarks:archived")) self.assertEqual(response.url, reverse("linkding:bookmarks.archived"))
# some params # some params
response = self.client.post( response = self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"q": "foo", "q": "foo",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -323,12 +326,13 @@ class BookmarkArchivedViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:archived") + "?q=foo&sort=title_asc" response.url,
reverse("linkding:bookmarks.archived") + "?q=foo&sort=title_asc",
) )
# params with default value are removed # params with default value are removed
response = self.client.post( response = self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"q": "foo", "q": "foo",
"user": "", "user": "",
@@ -339,12 +343,12 @@ class BookmarkArchivedViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:archived") + "?q=foo&unread=yes" response.url, reverse("linkding:bookmarks.archived") + "?q=foo&unread=yes"
) )
# page is removed # page is removed
response = self.client.post( response = self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"q": "foo", "q": "foo",
"page": "2", "page": "2",
@@ -353,7 +357,8 @@ class BookmarkArchivedViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:archived") + "?q=foo&sort=title_asc" response.url,
reverse("linkding:bookmarks.archived") + "?q=foo&sort=title_asc",
) )
def test_save_search_preferences(self): def test_save_search_preferences(self):
@@ -361,7 +366,7 @@ class BookmarkArchivedViewTestCase(
# no params # no params
self.client.post( self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"save": "", "save": "",
}, },
@@ -378,7 +383,7 @@ class BookmarkArchivedViewTestCase(
# with param # with param
self.client.post( self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"save": "", "save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -396,7 +401,7 @@ class BookmarkArchivedViewTestCase(
# add a param # add a param
self.client.post( self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"save": "", "save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -415,7 +420,7 @@ class BookmarkArchivedViewTestCase(
# remove a param # remove a param
self.client.post( self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"save": "", "save": "",
"unread": BookmarkSearch.FILTER_UNREAD_YES, "unread": BookmarkSearch.FILTER_UNREAD_YES,
@@ -433,7 +438,7 @@ class BookmarkArchivedViewTestCase(
# ignores non-preferences # ignores non-preferences
self.client.post( self.client.post(
reverse("bookmarks:archived"), reverse("linkding:bookmarks.archived"),
{ {
"save": "", "save": "",
"q": "foo", "q": "foo",
@@ -453,7 +458,7 @@ class BookmarkArchivedViewTestCase(
) )
def test_url_encode_bookmark_actions_url(self): def test_url_encode_bookmark_actions_url(self):
url = reverse("bookmarks:archived") + "?q=%23foo" url = reverse("linkding:bookmarks.archived") + "?q=%23foo"
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
soup = self.make_soup(html) soup = self.make_soup(html)
@@ -467,34 +472,34 @@ class BookmarkArchivedViewTestCase(
def test_encode_search_params(self): def test_encode_search_params(self):
bookmark = self.setup_bookmark(description="alert('xss')", is_archived=True) bookmark = self.setup_bookmark(description="alert('xss')", is_archived=True)
url = reverse("bookmarks:archived") + "?q=alert(%27xss%27)" url = reverse("linkding:bookmarks.archived") + "?q=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url) self.assertContains(response, bookmark.url)
url = reverse("bookmarks:archived") + "?sort=alert(%27xss%27)" url = reverse("linkding:bookmarks.archived") + "?sort=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?unread=alert(%27xss%27)" url = reverse("linkding:bookmarks.archived") + "?unread=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?shared=alert(%27xss%27)" url = reverse("linkding:bookmarks.archived") + "?shared=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?user=alert(%27xss%27)" url = reverse("linkding:bookmarks.archived") + "?user=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)" url = reverse("linkding:bookmarks.archived") + "?page=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self): def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:archived") + f"?bookmark_id={bookmark.id}" url = reverse("linkding:bookmarks.archived") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"}) response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code) self.assertEqual(200, response.status_code)
@@ -503,3 +508,10 @@ class BookmarkArchivedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal")) self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container")) self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container")) self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("linkding:bookmarks.archived"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import GlobalSettings from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewPerformanceTestCase( class BookmarkArchivedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
): ):
def setUp(self) -> None: def setUp(self) -> None:
@@ -31,10 +31,11 @@ class BookmarkArchivedViewPerformanceTestCase(
# capture number of queries # capture number of queries
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertContains( html = response.content.decode("utf-8")
response, "<li ld-bookmark-item>", num_initial_bookmarks soup = self.make_soup(html)
) list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -45,9 +46,10 @@ class BookmarkArchivedViewPerformanceTestCase(
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("linkding:bookmarks.archived"))
self.assertContains( html = response.content.decode("utf-8")
response, soup = self.make_soup(html)
"<li ld-bookmark-item>", list_items = soup.select("li[ld-bookmark-item]")
num_initial_bookmarks + num_additional_bookmarks, self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
) )

View File

@@ -11,19 +11,11 @@ from bookmarks.tests.helpers import (
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.setup_temp_assets_dir()
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename): def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f: with open(filepath, "w") as f:
f.write("test") f.write("test")
@@ -125,13 +117,13 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_view_access(self): def test_view_access(self):
self.view_access_test("bookmarks:assets.view") self.view_access_test("linkding:assets.view")
def test_view_access_guest_user(self): def test_view_access_guest_user(self):
self.view_access_guest_user_test("bookmarks:assets.view") self.view_access_guest_user_test("linkding:assets.view")
def test_reader_view_access(self): def test_reader_view_access(self):
self.view_access_test("bookmarks:assets.read") self.view_access_test("linkding:assets.read")
def test_reader_view_access_guest_user(self): def test_reader_view_access_guest_user(self):
self.view_access_guest_user_test("bookmarks:assets.read") self.view_access_guest_user_test("linkding:assets.read")

View File

@@ -3,23 +3,15 @@ import os
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.services import bookmarks from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin): class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self): def setUp(self):
temp_files = [ self.setup_temp_assets_dir()
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename): def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f: with open(filepath, "w") as f:
f.write("test") f.write("test")

View File

@@ -0,0 +1,340 @@
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from bookmarks.models import BookmarkAsset
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.setup_temp_assets_dir()
def assertAsset(self, asset: BookmarkAsset, data: dict):
self.assertEqual(asset.id, data["id"])
self.assertEqual(asset.bookmark.id, data["bookmark"])
self.assertEqual(
asset.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
)
self.assertEqual(asset.file_size, data["file_size"])
self.assertEqual(asset.asset_type, data["asset_type"])
self.assertEqual(asset.content_type, data["content_type"])
self.assertEqual(asset.display_name, data["display_name"])
self.assertEqual(asset.status, data["status"])
def test_asset_list(self):
self.authenticate()
bookmark1 = self.setup_bookmark(url="https://example1.com")
bookmark1_assets = [
self.setup_asset(bookmark=bookmark1),
self.setup_asset(bookmark=bookmark1),
self.setup_asset(bookmark=bookmark1),
]
bookmark2 = self.setup_bookmark(url="https://example2.com")
bookmark2_assets = [
self.setup_asset(bookmark=bookmark2),
self.setup_asset(bookmark=bookmark2),
self.setup_asset(bookmark=bookmark2),
]
url = reverse(
"linkding:bookmark_asset-list", kwargs={"bookmark_id": bookmark1.id}
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertAsset(bookmark1_assets[0], response.data["results"][0])
self.assertAsset(bookmark1_assets[1], response.data["results"][1])
self.assertAsset(bookmark1_assets[2], response.data["results"][2])
url = reverse(
"linkding:bookmark_asset-list", kwargs={"bookmark_id": bookmark2.id}
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertAsset(bookmark2_assets[0], response.data["results"][0])
self.assertAsset(bookmark2_assets[1], response.data["results"][1])
self.assertAsset(bookmark2_assets[2], response.data["results"][2])
def test_asset_list_only_returns_assets_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_list_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse(
"linkding:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_asset_detail(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
file="cats.png",
file_size=1234,
content_type="image/png",
display_name="cats.png",
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
url = reverse(
"linkding:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertAsset(asset, response.data)
def test_asset_detail_only_returns_asset_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_detail_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_asset_download_with_snapshot_asset(self):
self.authenticate()
file_content = """
<html>
<head>
<title>Test</title>
</head>
<body>
<h1>Test</h1>
</body>
"""
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
display_name="Snapshot from today",
content_type="text/html",
gzip=True,
)
self.setup_asset_file(asset=asset, file_content=file_content)
url = reverse(
"linkding:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "text/html")
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="Snapshot from today.html"',
)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertEqual(content, file_content)
def test_asset_download_with_uploaded_asset(self):
self.authenticate()
file_content = "some file content"
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
display_name="cats.png",
content_type="image/png",
gzip=False,
)
self.setup_asset_file(asset=asset, file_content=file_content)
url = reverse(
"linkding:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "image/png")
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="cats.png"',
)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertEqual(content, file_content)
def test_asset_download_with_missing_file(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
display_name="cats.png",
content_type="image/png",
gzip=False,
)
url = reverse(
"linkding:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_download_only_returns_asset_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_download_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def create_upload_body(self):
url = "https://example.com"
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
return {"url": url, "file": file}
def test_upload_asset(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"linkding:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
file_content = b"test file content"
file_name = "test.txt"
file = SimpleUploadedFile(file_name, file_content, content_type="text/plain")
response = self.client.post(url, {"file": file}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
asset = BookmarkAsset.objects.get(id=response.data["id"])
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.display_name, file_name)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "text/plain")
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
content = self.read_asset_file(asset)
self.assertEqual(content, file_content)
def test_upload_asset_with_missing_file(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"linkding:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_upload_asset_only_works_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
url = reverse(
"linkding:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_upload_asset_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse(
"linkding:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_upload_asset_disabled(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"linkding:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_delete_asset(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
self.setup_asset_file(asset=asset)
url = reverse(
"linkding:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
self.assertFalse(self.has_asset_file(asset))
def test_delete_asset_only_works_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_delete_asset_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"linkding:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -15,32 +15,36 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.client.force_login(user) self.client.force_login(user)
def get_details_form(self, soup, bookmark): def get_details_form(self, soup, bookmark):
form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}" form_url = (
reverse("linkding:bookmarks.index.action") + f"?details={bookmark.id}"
)
return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"}) return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"})
def get_index_details_modal(self, bookmark): def get_index_details_modal(self, bookmark):
url = reverse("bookmarks:index") + f"?details={bookmark.id}" url = reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
response = self.client.get(url) response = self.client.get(url)
soup = self.make_soup(response.content) soup = self.make_soup(response.content.decode())
modal = soup.find("turbo-frame", {"id": "details-modal"}) return soup.select_one("div.modal.bookmark-details")
return modal
def get_shared_details_modal(self, bookmark): def get_shared_details_modal(self, bookmark):
url = reverse("bookmarks:shared") + f"?details={bookmark.id}" url = reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
response = self.client.get(url) response = self.client.get(url)
soup = self.make_soup(response.content) soup = self.make_soup(response.content.decode())
modal = soup.find("turbo-frame", {"id": "details-modal"}) return soup.select_one("div.modal.bookmark-details")
return modal
def find_section(self, soup, section_name): def has_details_modal(self, response):
dt = soup.find("dt", string=section_name) soup = self.make_soup(response.content.decode())
dd = dt.find_next_sibling("dd") if dt else None return soup.select_one("div.modal.bookmark-details") is not None
return dd
def get_section(self, soup, section_name): def find_section_content(self, soup, section_name):
dd = self.find_section(soup, section_name) h3 = soup.find("h3", string=section_name)
self.assertIsNotNone(dd) content = h3.find_next_sibling("div") if h3 else None
return dd return content
def get_section_content(self, soup, section_name):
content = self.find_section_content(soup, section_name)
self.assertIsNotNone(content)
return content
def find_weblink(self, soup, url): def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url}) return soup.find("a", {"class": "weblink", "href": url})
@@ -51,59 +55,38 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_asset(self, soup, asset): def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id}) return soup.find("div", {"data-asset-id": asset.id})
def details_route_access_test(self):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
# non-existent bookmark - just returns without modal in response
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
self.assertEqual(response.status_code, 200)
# guest user
self.client.logout()
response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
def test_access(self): def test_access(self):
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
response = self.client.get( response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}" reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(self.has_details_modal(response))
# other user's bookmark # other user's bookmark
other_user = self.setup_user() other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.get( response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}" reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 200)
self.assertFalse(self.has_details_modal(response))
# non-existent bookmark - just returns without modal in response # non-existent bookmark - just returns without modal in response
response = self.client.get(reverse("bookmarks:index") + "?details=9999") response = self.client.get(
reverse("linkding:bookmarks.index") + "?details=9999"
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(self.has_details_modal(response))
# guest user # guest user
self.client.logout() self.client.logout()
response = self.client.get( response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}" reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 200)
self.assertFalse(self.has_details_modal(response))
def test_access_with_sharing(self): def test_access_with_sharing(self):
# shared bookmark, sharing disabled # shared bookmark, sharing disabled
@@ -111,9 +94,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(shared=True, user=other_user) bookmark = self.setup_bookmark(shared=True, user=other_user)
response = self.client.get( response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}" reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 200)
self.assertFalse(self.has_details_modal(response))
# shared bookmark, sharing enabled # shared bookmark, sharing enabled
profile = other_user.profile profile = other_user.profile
@@ -121,25 +105,28 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save() profile.save()
response = self.client.get( response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}" reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(self.has_details_modal(response))
# shared bookmark, guest user, no public sharing # shared bookmark, guest user, no public sharing
self.client.logout() self.client.logout()
response = self.client.get( response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}" reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 200)
self.assertFalse(self.has_details_modal(response))
# shared bookmark, guest user, public sharing # shared bookmark, guest user, public sharing
profile.enable_public_sharing = True profile.enable_public_sharing = True
profile.save() profile.save()
response = self.client.get( response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}" reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(self.has_details_modal(response))
def test_displays_title(self): def test_displays_title(self):
# with title # with title
@@ -231,7 +218,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 3) self.assertEqual(self.count_weblinks(soup), 3)
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id]) reader_mode_url = reverse("linkding:assets.read", args=[asset.id])
link = self.find_weblink(soup, reader_mode_url) link = self.find_weblink(soup, reader_mode_url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
@@ -367,7 +354,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# sharing disabled # sharing disabled
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived) self.assertIsNotNone(archived)
@@ -383,7 +370,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived) self.assertIsNotNone(archived)
@@ -395,7 +382,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked # unchecked
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertFalse(archived.has_attr("checked")) self.assertFalse(archived.has_attr("checked"))
@@ -407,7 +394,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked # checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True) bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertTrue(archived.has_attr("checked")) self.assertTrue(archived.has_attr("checked"))
@@ -420,14 +407,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section_content(soup, "Status")
self.assertIsNotNone(section) self.assertIsNotNone(section)
# other user's bookmark # other user's bookmark
other_user = self.setup_user(enable_sharing=True) other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark) soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section_content(soup, "Status")
self.assertIsNone(section) self.assertIsNone(section)
# guest user # guest user
@@ -436,13 +423,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user.profile.save() other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark) soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section_content(soup, "Status")
self.assertIsNone(section) self.assertIsNone(section)
def test_date_added(self): def test_date_added(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Date added") section = self.get_section_content(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT") expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
date = section.find("span", string=expected_date) date = section.find("span", string=expected_date)
@@ -453,19 +440,19 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags") section = self.find_section_content(soup, "Tags")
self.assertIsNone(section) self.assertIsNone(section)
# with tags # with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()]) bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Tags") section = self.get_section_content(soup, "Tags")
for tag in bookmark.tags.all(): for tag in bookmark.tags.all():
tag_link = section.find("a", string=f"#{tag.name}") tag_link = section.find("a", string=f"#{tag.name}")
self.assertIsNotNone(tag_link) self.assertIsNotNone(tag_link)
expected_url = reverse("bookmarks:index") + f"?q=%23{tag.name}" expected_url = reverse("linkding:bookmarks.index") + f"?q=%23{tag.name}"
self.assertEqual(tag_link["href"], expected_url) self.assertEqual(tag_link["href"], expected_url)
def test_description(self): def test_description(self):
@@ -473,14 +460,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(description="") bookmark = self.setup_bookmark(description="")
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description") section = self.find_section_content(soup, "Description")
self.assertIsNone(section) self.assertIsNone(section)
# with description # with description
bookmark = self.setup_bookmark(description="Test description") bookmark = self.setup_bookmark(description="Test description")
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description") section = self.get_section_content(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description) self.assertEqual(section.text.strip(), bookmark.description)
def test_notes(self): def test_notes(self):
@@ -488,14 +475,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes") section = self.find_section_content(soup, "Notes")
self.assertIsNone(section) self.assertIsNone(section)
# with notes # with notes
bookmark = self.setup_bookmark(notes="Test notes") bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Notes") section = self.get_section_content(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>") self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
def test_edit_link(self): def test_edit_link(self):
@@ -519,7 +506,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
form = delete_button.find_parent("form") form = delete_button.find_parent("form")
self.assertIsNotNone(form) self.assertIsNotNone(form)
expected_url = reverse("bookmarks:index.action") expected_url = reverse("linkding:bookmarks.index.action")
self.assertEqual(expected_url, form["action"]) self.assertEqual(expected_url, form["action"])
def test_actions_visibility(self): def test_actions_visibility(self):
@@ -564,28 +551,12 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNone(edit_link) self.assertIsNone(edit_link)
self.assertIsNone(delete_button) self.assertIsNone(delete_button)
def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_visibility(self): def test_asset_list_visibility(self):
# no assets # no assets
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files") section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"}) asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list) self.assertIsNone(asset_list)
@@ -594,11 +565,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark) self.setup_asset(bookmark)
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files") section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"}) asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list) self.assertIsNotNone(asset_list)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list(self): def test_asset_list(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
assets = [ assets = [
@@ -608,7 +578,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
] ]
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files") section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"}) asset_list = section.find("div", {"class": "assets"})
for asset in assets: for asset in assets:
@@ -622,11 +592,81 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(asset_text) self.assertIsNotNone(asset_text)
self.assertIn(asset.display_name, asset_text.text) self.assertIn(asset.display_name, asset_text.text)
view_url = reverse("bookmarks:assets.view", args=[asset.id]) view_url = reverse("linkding:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url}) view_link = asset_item.find("a", {"href": view_url})
self.assertIsNotNone(view_link) self.assertIsNotNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_actions_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNotNone(create_snapshot)
self.assertIsNotNone(upload_asset)
# with sharing
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
# with public sharing
profile = other_user.profile
profile.enable_public_sharing = True
profile.save()
soup = self.get_shared_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
def test_asset_list_actions_visibility_without_snapshots_enabled(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNotNone(upload_asset)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_asset_list_actions_visibility_with_uploads_disabled(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
def test_asset_without_file(self): def test_asset_without_file(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)
@@ -635,11 +675,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset) asset_item = self.find_asset(soup, asset)
view_url = reverse("bookmarks:assets.view", args=[asset.id]) view_url = reverse("linkding:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url}) view_link = asset_item.find("a", {"href": view_url})
self.assertIsNone(view_link) self.assertIsNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_status(self): def test_asset_status(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING) pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
@@ -655,7 +694,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_text = asset_item.select_one(".asset-text span") asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(failed)", asset_text.text) self.assertIn("(failed)", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_file_size(self): def test_asset_file_size(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset1 = self.setup_asset(bookmark, file_size=None) asset1 = self.setup_asset(bookmark, file_size=None)
@@ -676,7 +714,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_text = asset_item.select_one(".asset-text") asset_text = asset_item.select_one(".asset-text")
self.assertIn("11.0\xa0MB", asset_text.text) self.assertIn("11.0\xa0MB", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_actions_visibility(self): def test_asset_actions_visibility(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -738,7 +775,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no pending asset # no pending asset
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files") files_section = self.find_section_content(soup, "Files")
create_button = files_section.find( create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
) )
@@ -749,7 +786,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.save() asset.save()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files") files_section = self.find_section_content(soup, "Files")
create_button = files_section.find( create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
) )

View File

@@ -28,14 +28,18 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_render_successfully(self): def test_should_render_successfully(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_should_edit_bookmark(self): def test_should_edit_bookmark(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id}) form_data = self.create_form_data({"id": bookmark.id})
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data) self.client.post(
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
)
bookmark.refresh_from_db() bookmark.refresh_from_db()
@@ -55,7 +59,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id, "url": ""}) form_data = self.create_form_data({"id": bookmark.id, "url": ""})
response = self.client.post( response = self.client.post(
reverse("bookmarks:edit", args=[bookmark.id]), form_data reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
) )
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
@@ -63,12 +67,16 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id, "unread": True}) form_data = self.create_form_data({"id": bookmark.id, "unread": True})
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data) self.client.post(
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(bookmark.unread) self.assertTrue(bookmark.unread)
form_data = self.create_form_data({"id": bookmark.id, "unread": False}) form_data = self.create_form_data({"id": bookmark.id, "unread": False})
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data) self.client.post(
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(bookmark.unread) self.assertFalse(bookmark.unread)
@@ -76,12 +84,16 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id, "shared": True}) form_data = self.create_form_data({"id": bookmark.id, "shared": True})
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data) self.client.post(
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(bookmark.shared) self.assertTrue(bookmark.shared)
form_data = self.create_form_data({"id": bookmark.id, "shared": False}) form_data = self.create_form_data({"id": bookmark.id, "shared": False})
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data) self.client.post(
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(bookmark.shared) self.assertFalse(bookmark.shared)
@@ -95,7 +107,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
notes="edited notes", notes="edited notes",
) )
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -151,21 +165,21 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
# if the URL isn't modified it's not a duplicate # if the URL isn't modified it's not a duplicate
form_data = self.create_form_data({"url": edited_bookmark.url}) form_data = self.create_form_data({"url": edited_bookmark.url})
response = self.client.post( response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# if the URL is already bookmarked by another user, it's not a duplicate # if the URL is already bookmarked by another user, it's not a duplicate
form_data = self.create_form_data({"url": other_user_bookmark.url}) form_data = self.create_form_data({"url": other_user_bookmark.url})
response = self.client.post( response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# if the URL is already bookmarked by the same user, it's a duplicate # if the URL is already bookmarked by the same user, it's a duplicate
form_data = self.create_form_data({"url": existing_bookmark.url}) form_data = self.create_form_data({"url": existing_bookmark.url})
response = self.client.post( response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
) )
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
self.assertInHTML( self.assertInHTML(
@@ -180,23 +194,23 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = self.create_form_data() form_data = self.create_form_data()
url = ( url = (
reverse("bookmarks:edit", args=[bookmark.id]) reverse("linkding:bookmarks.edit", args=[bookmark.id])
+ "?return_url=" + "?return_url="
+ reverse("bookmarks:close") + reverse("linkding:bookmarks.close")
) )
response = self.client.post(url, form_data) response = self.client.post(url, form_data)
self.assertRedirects(response, reverse("bookmarks:close")) self.assertRedirects(response, reverse("linkding:bookmarks.close"))
def test_should_redirect_to_bookmark_index_by_default(self): def test_should_redirect_to_bookmark_index_by_default(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
form_data = self.create_form_data() form_data = self.create_form_data()
response = self.client.post( response = self.client.post(
reverse("bookmarks:edit", args=[bookmark.id]), form_data reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
) )
self.assertRedirects(response, reverse("bookmarks:index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
def test_should_not_redirect_to_external_url(self): def test_should_not_redirect_to_external_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -204,17 +218,17 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def post_with(return_url, follow=None): def post_with(return_url, follow=None):
form_data = self.create_form_data() form_data = self.create_form_data()
url = ( url = (
reverse("bookmarks:edit", args=[bookmark.id]) reverse("linkding:bookmarks.edit", args=[bookmark.id])
+ f"?return_url={return_url}" + f"?return_url={return_url}"
) )
return self.client.post(url, form_data, follow=follow) return self.client.post(url, form_data, follow=follow)
response = post_with("https://example.com") response = post_with("https://example.com")
self.assertRedirects(response, reverse("bookmarks:index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
response = post_with("//example.com") response = post_with("//example.com")
self.assertRedirects(response, reverse("bookmarks:index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
response = post_with("://example.com") response = post_with("://example.com")
self.assertRedirects(response, reverse("bookmarks:index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
response = post_with("/foo//example.com", follow=True) response = post_with("/foo//example.com", follow=True)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@@ -227,7 +241,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = self.create_form_data({"id": bookmark.id}) form_data = self.create_form_data({"id": bookmark.id})
response = self.client.post( response = self.client.post(
reverse("bookmarks:edit", args=[bookmark.id]), form_data reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
) )
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertNotEqual(bookmark.url, form_data["url"]) self.assertNotEqual(bookmark.url, form_data["url"])
@@ -238,7 +252,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.enable_sharing = False self.user.profile.enable_sharing = False
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -255,7 +271,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.enable_sharing = True self.user.profile.enable_sharing = True
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -272,12 +290,16 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_hide_notes_if_there_are_no_notes(self): def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
self.assertContains(response, '<details class="notes">', count=1) self.assertContains(response, '<details class="notes">', count=1)
def test_should_show_notes_if_there_are_notes(self): def test_should_show_notes_if_there_are_notes(self):
bookmark = self.setup_bookmark(notes="test notes") bookmark = self.setup_bookmark(notes="test notes")
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
self.assertContains(response, '<details class="notes" open>', count=1) self.assertContains(response, '<details class="notes" open>', count=1)

View File

@@ -44,7 +44,7 @@ class BookmarkIndexViewTestCase(
self.setup_bookmark(user=other_user), self.setup_bookmark(user=other_user),
] ]
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -53,7 +53,7 @@ class BookmarkIndexViewTestCase(
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo") visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar") invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
response = self.client.get(reverse("bookmarks:index") + "?q=foo") response = self.client.get(reverse("linkding:bookmarks.index") + "?q=foo")
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -75,7 +75,7 @@ class BookmarkIndexViewTestCase(
archived_bookmarks + other_user_bookmarks archived_bookmarks + other_user_bookmarks
) )
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -91,7 +91,7 @@ class BookmarkIndexViewTestCase(
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks) visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks) invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
response = self.client.get(reverse("bookmarks:index") + "?q=foo") response = self.client.get(reverse("linkding:bookmarks.index") + "?q=foo")
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -113,7 +113,7 @@ class BookmarkIndexViewTestCase(
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks) unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks) read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, unread_bookmarks) self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks) self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags) self.assertVisibleTags(response, unread_tags)
@@ -130,7 +130,7 @@ class BookmarkIndexViewTestCase(
self.setup_bookmark(tags=tags) self.setup_bookmark(tags=tags)
response = self.client.get( response = self.client.get(
reverse("bookmarks:index") reverse("linkding:bookmarks.index")
+ f"?q=%23{tags[0].name}+%23{tags[1].name.upper()}" + f"?q=%23{tags[0].name}+%23{tags[1].name.upper()}"
) )
@@ -149,7 +149,8 @@ class BookmarkIndexViewTestCase(
self.setup_bookmark(title=tags[0].name, tags=tags) self.setup_bookmark(title=tags[0].name, tags=tags)
response = self.client.get( response = self.client.get(
reverse("bookmarks:index") + f"?q={tags[0].name}+%23{tags[1].name.upper()}" reverse("linkding:bookmarks.index")
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
) )
self.assertSelectedTags(response, [tags[1]]) self.assertSelectedTags(response, [tags[1]])
@@ -168,7 +169,8 @@ class BookmarkIndexViewTestCase(
self.setup_bookmark(tags=tags) self.setup_bookmark(tags=tags)
response = self.client.get( response = self.client.get(
reverse("bookmarks:index") + f"?q={tags[0].name}+%23{tags[1].name.upper()}" reverse("linkding:bookmarks.index")
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
) )
self.assertSelectedTags(response, [tags[0], tags[1]]) self.assertSelectedTags(response, [tags[0], tags[1]])
@@ -176,7 +178,7 @@ class BookmarkIndexViewTestCase(
def test_should_open_bookmarks_in_new_page_by_default(self): def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = self.setup_numbered_bookmarks(3) visible_bookmarks = self.setup_numbered_bookmarks(3)
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank") self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
@@ -187,14 +189,14 @@ class BookmarkIndexViewTestCase(
visible_bookmarks = self.setup_numbered_bookmarks(3) visible_bookmarks = self.setup_numbered_bookmarks(3)
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_self") self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
def test_edit_link_return_url_respects_search_options(self): def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title="foo") bookmark = self.setup_bookmark(title="foo")
edit_url = reverse("bookmarks:edit", args=[bookmark.id]) edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
base_url = reverse("bookmarks:index") base_url = reverse("linkding:bookmarks.index")
# without query params # without query params
return_url = urllib.parse.quote(base_url) return_url = urllib.parse.quote(base_url)
@@ -220,8 +222,8 @@ class BookmarkIndexViewTestCase(
self.assertEditLink(response, url) self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self): def test_bulk_edit_respects_search_options(self):
action_url = reverse("bookmarks:index.action") action_url = reverse("linkding:bookmarks.index.action")
base_url = reverse("bookmarks:index") base_url = reverse("linkding:bookmarks.index")
# without params # without params
url = f"{action_url}" url = f"{action_url}"
@@ -244,7 +246,7 @@ class BookmarkIndexViewTestCase(
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self): def test_allowed_bulk_actions(self):
url = reverse("bookmarks:index") url = reverse("linkding:bookmarks.index")
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
@@ -257,6 +259,7 @@ class BookmarkIndexViewTestCase(
<option value="bulk_untag">Remove tags</option> <option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option> <option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option> <option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
</select> </select>
""", """,
html, html,
@@ -267,7 +270,7 @@ class BookmarkIndexViewTestCase(
user_profile.enable_sharing = True user_profile.enable_sharing = True
user_profile.save() user_profile.save()
url = reverse("bookmarks:index") url = reverse("linkding:bookmarks.index")
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
@@ -282,6 +285,7 @@ class BookmarkIndexViewTestCase(
<option value="bulk_unread">Mark as unread</option> <option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option> <option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
</select> </select>
""", """,
html, html,
@@ -289,13 +293,13 @@ class BookmarkIndexViewTestCase(
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("bookmarks:index")) response = self.client.post(reverse("linkding:bookmarks.index"))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("bookmarks:index")) self.assertEqual(response.url, reverse("linkding:bookmarks.index"))
# some params # some params
response = self.client.post( response = self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"q": "foo", "q": "foo",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -303,12 +307,12 @@ class BookmarkIndexViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:index") + "?q=foo&sort=title_asc" response.url, reverse("linkding:bookmarks.index") + "?q=foo&sort=title_asc"
) )
# params with default value are removed # params with default value are removed
response = self.client.post( response = self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"q": "foo", "q": "foo",
"user": "", "user": "",
@@ -318,11 +322,13 @@ class BookmarkIndexViewTestCase(
}, },
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("bookmarks:index") + "?q=foo&unread=yes") self.assertEqual(
response.url, reverse("linkding:bookmarks.index") + "?q=foo&unread=yes"
)
# page is removed # page is removed
response = self.client.post( response = self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"q": "foo", "q": "foo",
"page": "2", "page": "2",
@@ -331,7 +337,7 @@ class BookmarkIndexViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:index") + "?q=foo&sort=title_asc" response.url, reverse("linkding:bookmarks.index") + "?q=foo&sort=title_asc"
) )
def test_save_search_preferences(self): def test_save_search_preferences(self):
@@ -339,7 +345,7 @@ class BookmarkIndexViewTestCase(
# no params # no params
self.client.post( self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"save": "", "save": "",
}, },
@@ -356,7 +362,7 @@ class BookmarkIndexViewTestCase(
# with param # with param
self.client.post( self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"save": "", "save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -374,7 +380,7 @@ class BookmarkIndexViewTestCase(
# add a param # add a param
self.client.post( self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"save": "", "save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -393,7 +399,7 @@ class BookmarkIndexViewTestCase(
# remove a param # remove a param
self.client.post( self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"save": "", "save": "",
"unread": BookmarkSearch.FILTER_UNREAD_YES, "unread": BookmarkSearch.FILTER_UNREAD_YES,
@@ -411,7 +417,7 @@ class BookmarkIndexViewTestCase(
# ignores non-preferences # ignores non-preferences
self.client.post( self.client.post(
reverse("bookmarks:index"), reverse("linkding:bookmarks.index"),
{ {
"save": "", "save": "",
"q": "foo", "q": "foo",
@@ -431,7 +437,7 @@ class BookmarkIndexViewTestCase(
) )
def test_url_encode_bookmark_actions_url(self): def test_url_encode_bookmark_actions_url(self):
url = reverse("bookmarks:index") + "?q=%23foo" url = reverse("linkding:bookmarks.index") + "?q=%23foo"
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
soup = self.make_soup(html) soup = self.make_soup(html)
@@ -445,34 +451,34 @@ class BookmarkIndexViewTestCase(
def test_encode_search_params(self): def test_encode_search_params(self):
bookmark = self.setup_bookmark(description="alert('xss')") bookmark = self.setup_bookmark(description="alert('xss')")
url = reverse("bookmarks:index") + "?q=alert(%27xss%27)" url = reverse("linkding:bookmarks.index") + "?q=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url) self.assertContains(response, bookmark.url)
url = reverse("bookmarks:index") + "?sort=alert(%27xss%27)" url = reverse("linkding:bookmarks.index") + "?sort=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?unread=alert(%27xss%27)" url = reverse("linkding:bookmarks.index") + "?unread=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?shared=alert(%27xss%27)" url = reverse("linkding:bookmarks.index") + "?shared=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?user=alert(%27xss%27)" url = reverse("linkding:bookmarks.index") + "?user=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)" url = reverse("linkding:bookmarks.index") + "?page=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self): def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:index") + f"?bookmark_id={bookmark.id}" url = reverse("linkding:bookmarks.index") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"}) response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code) self.assertEqual(200, response.status_code)
@@ -481,3 +487,10 @@ class BookmarkIndexViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal")) self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container")) self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container")) self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("linkding:bookmarks.index"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import GlobalSettings from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin): class BookmarkIndexViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -29,10 +31,11 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# capture number of queries # capture number of queries
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertContains( html = response.content.decode("utf-8")
response, "<li ld-bookmark-item>", num_initial_bookmarks soup = self.make_soup(html)
) list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -43,9 +46,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertContains( html = response.content.decode("utf-8")
response, soup = self.make_soup(html)
"<li ld-bookmark-item>", list_items = soup.select("li[ld-bookmark-item]")
num_initial_bookmarks + num_additional_bookmarks, self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
) )

View File

@@ -29,7 +29,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_create_new_bookmark(self): def test_should_create_new_bookmark(self):
form_data = self.create_form_data() form_data = self.create_form_data()
self.client.post(reverse("bookmarks:new"), form_data) self.client.post(reverse("linkding:bookmarks.new"), form_data)
self.assertEqual(Bookmark.objects.count(), 1) self.assertEqual(Bookmark.objects.count(), 1)
@@ -48,13 +48,13 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_return_422_with_invalid_form(self): def test_should_return_422_with_invalid_form(self):
form_data = self.create_form_data({"url": ""}) form_data = self.create_form_data({"url": ""})
response = self.client.post(reverse("bookmarks:new"), form_data) response = self.client.post(reverse("linkding:bookmarks.new"), form_data)
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
def test_should_create_new_unread_bookmark(self): def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({"unread": True}) form_data = self.create_form_data({"unread": True})
self.client.post(reverse("bookmarks:new"), form_data) self.client.post(reverse("linkding:bookmarks.new"), form_data)
self.assertEqual(Bookmark.objects.count(), 1) self.assertEqual(Bookmark.objects.count(), 1)
@@ -64,7 +64,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_create_new_shared_bookmark(self): def test_should_create_new_shared_bookmark(self):
form_data = self.create_form_data({"shared": True}) form_data = self.create_form_data({"shared": True})
self.client.post(reverse("bookmarks:new"), form_data) self.client.post(reverse("linkding:bookmarks.new"), form_data)
self.assertEqual(Bookmark.objects.count(), 1) self.assertEqual(Bookmark.objects.count(), 1)
@@ -72,7 +72,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(bookmark.shared) self.assertTrue(bookmark.shared)
def test_should_prefill_url_from_url_parameter(self): def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse("bookmarks:new") + "?url=http://example.com") response = self.client.get(
reverse("linkding:bookmarks.new") + "?url=http://example.com"
)
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -83,7 +85,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
) )
def test_should_prefill_title_from_url_parameter(self): def test_should_prefill_title_from_url_parameter(self):
response = self.client.get(reverse("bookmarks:new") + "?title=Example%20Title") response = self.client.get(
reverse("linkding:bookmarks.new") + "?title=Example%20Title"
)
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -95,7 +99,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_prefill_description_from_url_parameter(self): def test_should_prefill_description_from_url_parameter(self):
response = self.client.get( response = self.client.get(
reverse("bookmarks:new") + "?description=Example%20Site%20Description" reverse("linkding:bookmarks.new")
+ "?description=Example%20Site%20Description"
) )
html = response.content.decode() html = response.content.decode()
@@ -105,9 +110,22 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html, html,
) )
def test_should_prefill_tags_from_url_parameter(self):
response = self.client.get(
reverse("linkding:bookmarks.new") + "?tags=tag1%20tag2%20tag3"
)
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="tag_string" value="tag1 tag2 tag3" '
'class="form-input" autocomplete="off" autocapitalize="off" '
'id="id_tag_string">',
html,
)
def test_should_prefill_notes_from_url_parameter(self): def test_should_prefill_notes_from_url_parameter(self):
response = self.client.get( response = self.client.get(
reverse("bookmarks:new") reverse("linkding:bookmarks.new")
+ "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29" + "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29"
) )
html = response.content.decode() html = response.content.decode()
@@ -129,50 +147,51 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
) )
def test_should_enable_auto_close_when_specified_in_url_parameter(self): def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(reverse("bookmarks:new") + "?auto_close") response = self.client.get(reverse("linkding:bookmarks.new") + "?auto_close")
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="hidden" name="auto_close" value="true" ' '<input type="hidden" name="auto_close" value="True" id="id_auto_close">',
'id="id_auto_close">',
html, html,
) )
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(self): def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(self):
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="hidden" name="auto_close" id="id_auto_close">', html '<input type="hidden" name="auto_close" value="False" id="id_auto_close">',
html,
) )
def test_should_redirect_to_index_view(self): def test_should_redirect_to_index_view(self):
form_data = self.create_form_data() form_data = self.create_form_data()
response = self.client.post(reverse("bookmarks:new"), form_data) response = self.client.post(reverse("linkding:bookmarks.new"), form_data)
self.assertRedirects(response, reverse("bookmarks:index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
def test_should_not_redirect_to_external_url(self): def test_should_not_redirect_to_external_url(self):
form_data = self.create_form_data() form_data = self.create_form_data()
response = self.client.post( response = self.client.post(
reverse("bookmarks:new") + "?return_url=https://example.com", form_data reverse("linkding:bookmarks.new") + "?return_url=https://example.com",
form_data,
) )
self.assertRedirects(response, reverse("bookmarks:index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
def test_auto_close_should_redirect_to_close_view(self): def test_auto_close_should_redirect_to_close_view(self):
form_data = self.create_form_data({"auto_close": "true"}) form_data = self.create_form_data({"auto_close": "True"})
response = self.client.post(reverse("bookmarks:new"), form_data) response = self.client.post(reverse("linkding:bookmarks.new"), form_data)
self.assertRedirects(response, reverse("bookmarks:close")) self.assertRedirects(response, reverse("linkding:bookmarks.close"))
def test_should_respect_share_profile_setting(self): def test_should_respect_share_profile_setting(self):
self.user.profile.enable_sharing = False self.user.profile.enable_sharing = False
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -189,7 +208,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.enable_sharing = True self.user.profile.enable_sharing = True
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -208,7 +227,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.enable_sharing = True self.user.profile.enable_sharing = True
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
""" """
@@ -222,7 +241,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.enable_public_sharing = True self.user.profile.enable_public_sharing = True
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
""" """
@@ -235,12 +254,14 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_hide_notes_if_there_are_no_notes(self): def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(
reverse("linkding:bookmarks.edit", args=[bookmark.id])
)
self.assertContains(response, '<details class="notes">', count=1) self.assertContains(response, '<details class="notes">', count=1)
def test_should_not_check_unread_by_default(self): def test_should_not_check_unread_by_default(self):
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@@ -252,11 +273,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.default_mark_unread = True self.user.profile.default_mark_unread = True
self.user.profile.save() self.user.profile.save()
response = self.client.get(reverse("bookmarks:new")) response = self.client.get(reverse("linkding:bookmarks.new"))
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="checkbox" name="unread" value="true" ' '<input type="checkbox" name="unread" id="id_unread" checked="">',
'id="id_unread" checked="">',
html, html,
) )

View File

@@ -0,0 +1,70 @@
import os
import shutil
import tempfile
from django.conf import settings
from django.test import TestCase, override_settings
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)
self.override.enable()
def tearDown(self):
self.override.disable()
shutil.rmtree(self.temp_dir)
def setup_preview_file(self, filename):
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_bookmark_with_preview(self):
bookmark = self.setup_bookmark()
bookmark.preview_image_file = f"preview_{bookmark.id}.jpg"
bookmark.save()
self.setup_preview_file(bookmark.preview_image_file)
return bookmark
def assertPreviewImageExists(self, bookmark):
self.assertTrue(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def assertPreviewImageDoesNotExist(self, bookmark):
self.assertFalse(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def test_delete_bookmark_deletes_preview_image(self):
bookmark = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark)
bookmark.delete()
self.assertPreviewImageDoesNotExist(bookmark)
def test_bulk_delete_bookmarks_deletes_preview_images(self):
bookmark1 = self.setup_bookmark_with_preview()
bookmark2 = self.setup_bookmark_with_preview()
bookmark3 = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark1)
self.assertPreviewImageExists(bookmark2)
self.assertPreviewImageExists(bookmark3)
bookmarks.delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertPreviewImageDoesNotExist(bookmark1)
self.assertPreviewImageDoesNotExist(bookmark2)
self.assertPreviewImageDoesNotExist(bookmark3)

View File

@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
radios = form.select(f'input[name="{name}"][type="radio"]') radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) == 0) self.assertTrue(len(radios) == 0)
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""): def assertUnmodifiedLabel(self, html: str, text: str):
id_attr = f'for="{id}"' if id else "" soup = self.make_soup(html)
tag = "label" if id else "div" label = soup.find("label", string=lambda s: s and s.strip() == text)
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>' self.assertEqual(label["class"], ["form-label"])
self.assertInHTML(needle, html) def assertModifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
def assertModifiedLabel(self, html: str, text: str, id: str = ""): label = soup.find("label", string=lambda s: s and s.strip() == text)
id_attr = f'for="{id}"' if id else "" self.assertEqual(label["class"], ["form-label", "text-bold"])
tag = "label" if id else "div"
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def test_search_form_inputs(self): def test_search_form_inputs(self):
# Without params # Without params
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
# Without modifications # Without modifications
url = "/test" url = "/test"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn( self.assertNotIn("badge", button["class"])
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
# With modifications # With modifications
url = "/test?sort=title_asc" url = "/test?sort=title_asc"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn( self.assertIn("badge", button["class"])
'<button type="button" class="btn dropdown-toggle badge">',
rendered_template,
)
# Ignores non-preferences modifications # Ignores non-preferences modifications
url = "/test?q=foo&user=john" url = "/test?q=foo&user=john"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn( self.assertNotIn("badge", button["class"])
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
def test_modified_labels(self): def test_modified_labels(self):
# Without modifications # Without modifications
url = "/test" url = "/test"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort") self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter") self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter") self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified sort # Modified sort
url = "/test?sort=title_asc" url = "/test?sort=title_asc"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort") self.assertModifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter") self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter") self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified shared # Modified shared
url = "/test?shared=yes" url = "/test?shared=yes"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort") self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertModifiedLabel(rendered_template, "Shared filter") self.assertModifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter") self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified unread # Modified unread
url = "/test?unread=yes" url = "/test?unread=yes"
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort") self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter") self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertModifiedLabel(rendered_template, "Unread filter") self.assertModifiedLabel(rendered_template, "Unread filter")

View File

@@ -75,7 +75,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True, user=user4), self.setup_bookmark(shared=True, user=user4),
] ]
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -94,7 +94,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True, user=user3), self.setup_bookmark(shared=True, user=user3),
] ]
url = reverse("bookmarks:shared") + "?user=" + user1.username url = reverse("linkding:bookmarks.shared") + "?user=" + user1.username
response = self.client.get(url) response = self.client.get(url)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
@@ -109,7 +109,7 @@ class BookmarkSharedViewTestCase(
) )
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user) invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
response = self.client.get(reverse("bookmarks:shared") + "?q=foo") response = self.client.get(reverse("linkding:bookmarks.shared") + "?q=foo")
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -125,7 +125,7 @@ class BookmarkSharedViewTestCase(
3, shared=True, user=user2, prefix="user2" 3, shared=True, user=user2, prefix="user2"
) )
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -159,7 +159,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]]) self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]])
self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]]) self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]])
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -181,7 +181,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]]) self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]]) self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]])
url = reverse("bookmarks:shared") + "?user=" + user1.username url = reverse("linkding:bookmarks.shared") + "?user=" + user1.username
response = self.client.get(url) response = self.client.get(url)
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
@@ -217,7 +217,9 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]]) self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]]) self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
response = self.client.get(reverse("bookmarks:shared") + "?q=searchvalue") response = self.client.get(
reverse("linkding:bookmarks.shared") + "?q=searchvalue"
)
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -241,7 +243,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]]) self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]]) self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
@@ -258,7 +260,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True)) self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True))
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False)) self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False))
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleUserOptions(response, expected_visible_users) self.assertVisibleUserOptions(response, expected_visible_users)
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self): def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
@@ -278,7 +280,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True)) self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True)) self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleUserOptions(response, expected_visible_users) self.assertVisibleUserOptions(response, expected_visible_users)
def test_should_list_bookmarks_and_tags_for_search_preferences(self): def test_should_list_bookmarks_and_tags_for_search_preferences(self):
@@ -313,7 +315,7 @@ class BookmarkSharedViewTestCase(
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks) unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks) read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleBookmarks(response, unread_bookmarks) self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks) self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags) self.assertVisibleTags(response, unread_tags)
@@ -330,7 +332,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True), self.setup_bookmark(shared=True),
] ]
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank") self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
@@ -347,7 +349,7 @@ class BookmarkSharedViewTestCase(
self.setup_bookmark(shared=True), self.setup_bookmark(shared=True),
] ]
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_self") self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
@@ -358,8 +360,8 @@ class BookmarkSharedViewTestCase(
user.profile.save() user.profile.save()
bookmark = self.setup_bookmark(title="foo", shared=True, user=user) bookmark = self.setup_bookmark(title="foo", shared=True, user=user)
edit_url = reverse("bookmarks:edit", args=[bookmark.id]) edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
base_url = reverse("bookmarks:shared") base_url = reverse("linkding:bookmarks.shared")
# without query params # without query params
return_url = urllib.parse.quote(base_url) return_url = urllib.parse.quote(base_url)
@@ -394,13 +396,13 @@ class BookmarkSharedViewTestCase(
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("bookmarks:shared")) response = self.client.post(reverse("linkding:bookmarks.shared"))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("bookmarks:shared")) self.assertEqual(response.url, reverse("linkding:bookmarks.shared"))
# some params # some params
response = self.client.post( response = self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"q": "foo", "q": "foo",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -408,12 +410,12 @@ class BookmarkSharedViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:shared") + "?q=foo&sort=title_asc" response.url, reverse("linkding:bookmarks.shared") + "?q=foo&sort=title_asc"
) )
# params with default value are removed # params with default value are removed
response = self.client.post( response = self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"q": "foo", "q": "foo",
"user": "", "user": "",
@@ -424,12 +426,12 @@ class BookmarkSharedViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:shared") + "?q=foo&unread=yes" response.url, reverse("linkding:bookmarks.shared") + "?q=foo&unread=yes"
) )
# page is removed # page is removed
response = self.client.post( response = self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"q": "foo", "q": "foo",
"page": "2", "page": "2",
@@ -438,7 +440,7 @@ class BookmarkSharedViewTestCase(
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
response.url, reverse("bookmarks:shared") + "?q=foo&sort=title_asc" response.url, reverse("linkding:bookmarks.shared") + "?q=foo&sort=title_asc"
) )
def test_save_search_preferences(self): def test_save_search_preferences(self):
@@ -447,7 +449,7 @@ class BookmarkSharedViewTestCase(
# no params # no params
self.client.post( self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"save": "", "save": "",
}, },
@@ -464,7 +466,7 @@ class BookmarkSharedViewTestCase(
# with param # with param
self.client.post( self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"save": "", "save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -482,7 +484,7 @@ class BookmarkSharedViewTestCase(
# add a param # add a param
self.client.post( self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"save": "", "save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC, "sort": BookmarkSearch.SORT_TITLE_ASC,
@@ -501,7 +503,7 @@ class BookmarkSharedViewTestCase(
# remove a param # remove a param
self.client.post( self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"save": "", "save": "",
"unread": BookmarkSearch.FILTER_UNREAD_YES, "unread": BookmarkSearch.FILTER_UNREAD_YES,
@@ -519,7 +521,7 @@ class BookmarkSharedViewTestCase(
# ignores non-preferences # ignores non-preferences
self.client.post( self.client.post(
reverse("bookmarks:shared"), reverse("linkding:bookmarks.shared"),
{ {
"save": "", "save": "",
"q": "foo", "q": "foo",
@@ -539,7 +541,7 @@ class BookmarkSharedViewTestCase(
) )
def test_url_encode_bookmark_actions_url(self): def test_url_encode_bookmark_actions_url(self):
url = reverse("bookmarks:shared") + "?q=%23foo" url = reverse("linkding:bookmarks.shared") + "?q=%23foo"
response = self.client.get(url) response = self.client.get(url)
html = response.content.decode() html = response.content.decode()
soup = self.make_soup(html) soup = self.make_soup(html)
@@ -557,34 +559,34 @@ class BookmarkSharedViewTestCase(
user.profile.save() user.profile.save()
bookmark = self.setup_bookmark(description="alert('xss')", shared=True) bookmark = self.setup_bookmark(description="alert('xss')", shared=True)
url = reverse("bookmarks:shared") + "?q=alert(%27xss%27)" url = reverse("linkding:bookmarks.shared") + "?q=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url) self.assertContains(response, bookmark.url)
url = reverse("bookmarks:shared") + "?sort=alert(%27xss%27)" url = reverse("linkding:bookmarks.shared") + "?sort=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?unread=alert(%27xss%27)" url = reverse("linkding:bookmarks.shared") + "?unread=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?shared=alert(%27xss%27)" url = reverse("linkding:bookmarks.shared") + "?shared=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?user=alert(%27xss%27)" url = reverse("linkding:bookmarks.shared") + "?user=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)" url = reverse("linkding:bookmarks.shared") + "?page=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self): def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:shared") + f"?bookmark_id={bookmark.id}" url = reverse("linkding:bookmarks.shared") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"}) response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code) self.assertEqual(200, response.status_code)
@@ -593,3 +595,11 @@ class BookmarkSharedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal")) self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container")) self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container")) self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_includes_public_shared_rss_feed(self):
response = self.client.get(reverse("linkding:bookmarks.shared"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNotNone(feed)
self.assertEqual(feed.attrs["href"], reverse("linkding:feeds.public_shared"))

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import GlobalSettings from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin): class BookmarkSharedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -30,10 +32,11 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# capture number of queries # capture number of queries
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertContains( html = response.content.decode("utf-8")
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks soup = self.make_soup(html)
) list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -45,9 +48,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("linkding:bookmarks.shared"))
self.assertContains( html = response.content.decode("utf-8")
response, soup = self.make_soup(html)
'<li ld-bookmark-item class="shared">', list_items = soup.select("li[ld-bookmark-item]")
num_initial_bookmarks + num_additional_bookmarks, self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
) )

View File

@@ -1,12 +1,12 @@
import datetime import datetime
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.client import RequestFactory
from bookmarks.models import BookmarkForm, Bookmark from bookmarks.forms import BookmarkForm
from bookmarks.models import Bookmark
User = get_user_model() from bookmarks.tests.helpers import BookmarkFactoryMixin
ENABLED_URL_VALIDATION_TEST_CASES = [ ENABLED_URL_VALIDATION_TEST_CASES = [
("thisisnotavalidurl", False), ("thisisnotavalidurl", False),
@@ -29,12 +29,10 @@ DISABLED_URL_VALIDATION_TEST_CASES = [
] ]
class BookmarkValidationTestCase(TestCase): class BookmarkValidationTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.user = User.objects.create_user( self.get_or_create_test_user()
"testuser", "test@example.com", "password123"
)
def test_bookmark_model_should_not_allow_missing_url(self): def test_bookmark_model_should_not_allow_missing_url(self):
bookmark = Bookmark( bookmark = Bookmark(
@@ -66,12 +64,15 @@ class BookmarkValidationTestCase(TestCase):
self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES) self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
def test_bookmark_form_should_validate_required_fields(self): def test_bookmark_form_should_validate_required_fields(self):
form = BookmarkForm(data={"url": ""}) rf = RequestFactory()
request = rf.post("/", data={"url": ""})
form = BookmarkForm(request)
self.assertEqual(len(form.errors), 1) self.assertEqual(len(form.errors), 1)
self.assertIn("required", str(form.errors)) self.assertIn("required", str(form.errors))
form = BookmarkForm(data={"url": None}) request = rf.post("/", data={})
form = BookmarkForm(request)
self.assertEqual(len(form.errors), 1) self.assertEqual(len(form.errors), 1)
self.assertIn("required", str(form.errors)) self.assertIn("required", str(form.errors))
@@ -106,7 +107,9 @@ class BookmarkValidationTestCase(TestCase):
def _run_bookmark_form_url_validity_checks(self, cases): def _run_bookmark_form_url_validity_checks(self, cases):
for case in cases: for case in cases:
url, expectation = case url, expectation = case
form = BookmarkForm(data={"url": url}) rf = RequestFactory()
request = rf.post("/", data={"url": url})
form = BookmarkForm(request)
if expectation: if expectation:
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)

View File

@@ -1,21 +1,39 @@
import datetime
import io
import urllib.parse import urllib.parse
from collections import OrderedDict from collections import OrderedDict
from unittest.mock import patch from unittest.mock import patch, ANY
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.response import Response from rest_framework.response import Response
import bookmarks.services.bookmarks
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
from bookmarks.utils import app_version
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.mock_assets_upload_snapshot_patcher = patch(
"bookmarks.services.assets.upload_snapshot",
)
self.mock_assets_upload_snapshot = (
self.mock_assets_upload_snapshot_patcher.start()
)
def tearDown(self):
self.mock_assets_upload_snapshot_patcher.stop()
def authenticate(self): def authenticate(self):
self.api_token = Token.objects.get_or_create( self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user() user=self.get_or_create_test_user()
@@ -33,7 +51,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["title"] = bookmark.title expectation["title"] = bookmark.title
expectation["description"] = bookmark.description expectation["description"] = bookmark.description
expectation["notes"] = bookmark.notes expectation["notes"] = bookmark.notes
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url expectation["web_archive_snapshot_url"] = (
bookmark.web_archive_snapshot_url
or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)
)
expectation["favicon_url"] = ( expectation["favicon_url"] = (
f"http://testserver/static/{bookmark.favicon_file}" f"http://testserver/static/{bookmark.favicon_file}"
if bookmark.favicon_file if bookmark.favicon_file
@@ -68,7 +89,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmarks = self.setup_numbered_bookmarks(5) bookmarks = self.setup_numbered_bookmarks(5)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -83,7 +104,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -95,7 +116,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark.save() bookmark.save()
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
self.assertIsNone(response.data["results"][0]["website_title"]) self.assertIsNone(response.data["results"][0]["website_title"])
self.assertIsNone(response.data["results"][0]["website_description"]) self.assertIsNone(response.data["results"][0]["website_description"])
@@ -106,7 +127,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_numbered_bookmarks(5, archived=True) self.setup_numbered_bookmarks(5, archived=True)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -117,7 +138,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_numbered_bookmarks(5) self.setup_numbered_bookmarks(5)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list") + "?q=" + search_value, reverse("linkding:bookmark-list") + "?q=" + search_value,
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -129,7 +150,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# Filter off # Filter off
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
self.assertBookmarkListEqual( self.assertBookmarkListEqual(
response.data["results"], unread_bookmarks + read_bookmarks response.data["results"], unread_bookmarks + read_bookmarks
@@ -137,14 +158,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# Filter shared # Filter shared
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list") + "?unread=yes", reverse("linkding:bookmark-list") + "?unread=yes",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], unread_bookmarks) self.assertBookmarkListEqual(response.data["results"], unread_bookmarks)
# Filter unshared # Filter unshared
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list") + "?unread=no", reverse("linkding:bookmark-list") + "?unread=no",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], read_bookmarks) self.assertBookmarkListEqual(response.data["results"], read_bookmarks)
@@ -156,7 +177,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# Filter off # Filter off
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
self.assertBookmarkListEqual( self.assertBookmarkListEqual(
response.data["results"], unshared_bookmarks + shared_bookmarks response.data["results"], unshared_bookmarks + shared_bookmarks
@@ -164,14 +185,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# Filter shared # Filter shared
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list") + "?shared=yes", reverse("linkding:bookmark-list") + "?shared=yes",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks) self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
# Filter unshared # Filter unshared
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list") + "?shared=no", reverse("linkding:bookmark-list") + "?shared=no",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], unshared_bookmarks) self.assertBookmarkListEqual(response.data["results"], unshared_bookmarks)
@@ -182,7 +203,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmarks.reverse() bookmarks.reverse()
response = self.get( response = self.get(
reverse("bookmarks:bookmark-list") + "?sort=title_desc", reverse("linkding:bookmark-list") + "?sort=title_desc",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -193,7 +214,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True) archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-archived"), reverse("linkding:bookmark-archived"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks) self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
@@ -210,7 +231,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
response = self.get( response = self.get(
reverse("bookmarks:bookmark-archived"), reverse("linkding:bookmark-archived"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks) self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
@@ -224,7 +245,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_numbered_bookmarks(5, archived=True) self.setup_numbered_bookmarks(5, archived=True)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-archived") + "?q=" + search_value, reverse("linkding:bookmark-archived") + "?q=" + search_value,
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks) self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
@@ -235,7 +256,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmarks.reverse() bookmarks.reverse()
response = self.get( response = self.get(
reverse("bookmarks:bookmark-archived") + "?sort=title_desc", reverse("linkding:bookmark-archived") + "?sort=title_desc",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -259,7 +280,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_bookmark(shared=True, user=user4) self.setup_bookmark(shared=True, user=user4)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared"), reverse("linkding:bookmark-shared"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks) self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
@@ -279,7 +300,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared"), reverse("linkding:bookmark-shared"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks) self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
@@ -296,7 +317,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_bookmark(shared=True, user=user2) self.setup_bookmark(shared=True, user=user2)
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared"), reverse("linkding:bookmark-shared"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks) self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
@@ -318,7 +339,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_bookmark(shared=True, user=user3), self.setup_bookmark(shared=True, user=user3),
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared") + "?q=searchvalue", reverse("linkding:bookmark-shared") + "?q=searchvalue",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], expected_bookmarks) self.assertBookmarkListEqual(response.data["results"], expected_bookmarks)
@@ -331,7 +352,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.setup_bookmark(shared=True, user=user_search_user), self.setup_bookmark(shared=True, user=user_search_user),
] ]
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared") + "?user=" + user_search_user.username, reverse("linkding:bookmark-shared") + "?user=" + user_search_user.username,
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], expected_bookmarks) self.assertBookmarkListEqual(response.data["results"], expected_bookmarks)
@@ -350,7 +371,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
), ),
] ]
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared") reverse("linkding:bookmark-shared")
+ "?q=searchvalue&user=" + "?q=searchvalue&user="
+ combined_search_user.username, + combined_search_user.username,
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
@@ -364,7 +385,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmarks.reverse() bookmarks.reverse()
response = self.get( response = self.get(
reverse("bookmarks:bookmark-shared") + "?sort=title_desc", reverse("linkding:bookmark-shared") + "?sort=title_desc",
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.assertBookmarkListEqual(response.data["results"], bookmarks) self.assertBookmarkListEqual(response.data["results"], bookmarks)
@@ -382,7 +403,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"shared": False, "shared": False,
"tag_names": ["tag1", "tag2"], "tag_names": ["tag1", "tag2"],
} }
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(bookmark.url, data["url"]) self.assertEqual(bookmark.url, data["url"])
self.assertEqual(bookmark.title, data["title"]) self.assertEqual(bookmark.title, data["title"])
@@ -406,7 +427,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
description="Website description", description="Website description",
preview_image=None, preview_image=None,
) )
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(bookmark.title, "Website title") self.assertEqual(bookmark.title, "Website title")
self.assertEqual(bookmark.description, "Website description") self.assertEqual(bookmark.description, "Website description")
@@ -425,7 +446,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
preview_image=None, preview_image=None,
) )
self.post( self.post(
reverse("bookmarks:bookmark-list") + "?disable_scraping", reverse("linkding:bookmark-list") + "?disable_scraping",
data, data,
status.HTTP_201_CREATED, status.HTTP_201_CREATED,
) )
@@ -433,6 +454,40 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, "") self.assertEqual(bookmark.title, "")
self.assertEqual(bookmark.description, "") self.assertEqual(bookmark.description, "")
def test_create_bookmark_creates_html_snapshot_by_default(self):
self.authenticate()
with patch.object(
bookmarks.services.bookmarks,
"create_bookmark",
wraps=bookmarks.services.bookmarks.create_bookmark,
) as mock_create_bookmark:
data = {"url": "https://example.com/"}
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=False
)
def test_create_bookmark_does_not_create_html_snapshot_if_disabled(self):
self.authenticate()
with patch.object(
bookmarks.services.bookmarks,
"create_bookmark",
wraps=bookmarks.services.bookmarks.create_bookmark,
) as mock_create_bookmark:
data = {"url": "https://example.com/"}
self.post(
reverse("linkding:bookmark-list") + "?disable_html_snapshot",
data,
status.HTTP_201_CREATED,
)
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self): def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.authenticate() self.authenticate()
@@ -447,7 +502,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"is_archived": True, "is_archived": True,
"tag_names": ["tag1", "tag2"], "tag_names": ["tag1", "tag2"],
} }
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(bookmark.id, original_bookmark.id) self.assertEqual(bookmark.id, original_bookmark.id)
self.assertEqual(bookmark.url, data["url"]) self.assertEqual(bookmark.url, data["url"])
@@ -471,7 +526,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"description": "Test description", "description": "Test description",
"tag_names": ["tag 1", "tag 2"], "tag_names": ["tag 1", "tag 2"],
} }
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
tag_names = [tag.name for tag in bookmark.tags.all()] tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ["tag-1", "tag-2"]) self.assertListEqual(tag_names, ["tag-1", "tag-2"])
@@ -481,7 +536,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
self.post( self.post(
reverse("bookmarks:bookmark-list") + "?disable_scraping", reverse("linkding:bookmark-list") + "?disable_scraping",
data, data,
status.HTTP_201_CREATED, status.HTTP_201_CREATED,
) )
@@ -506,7 +561,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"is_archived": True, "is_archived": True,
"tag_names": ["tag1", "tag2"], "tag_names": ["tag1", "tag2"],
} }
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(bookmark.url, data["url"]) self.assertEqual(bookmark.url, data["url"])
self.assertEqual(bookmark.title, data["title"]) self.assertEqual(bookmark.title, data["title"])
@@ -520,7 +575,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
@@ -528,7 +583,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
data = {"url": "https://example.com/", "unread": True} data = {"url": "https://example.com/", "unread": True}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertTrue(bookmark.unread) self.assertTrue(bookmark.unread)
@@ -536,7 +591,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertFalse(bookmark.unread) self.assertFalse(bookmark.unread)
@@ -544,7 +599,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
data = {"url": "https://example.com/", "shared": True} data = {"url": "https://example.com/", "shared": True}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertTrue(bookmark.shared) self.assertTrue(bookmark.shared)
@@ -552,7 +607,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertFalse(bookmark.shared) self.assertFalse(bookmark.shared)
@@ -566,7 +621,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
profile.save() profile.save()
data = {"url": "https://example.com/", "tag_names": [tag1.name]} data = {"url": "https://example.com/", "tag_names": [tag1.name]}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
@@ -574,7 +629,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark]) self.assertBookmarkListEqual([response.data], [bookmark])
@@ -586,16 +641,33 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
tags=[tag1], tags=[tag1],
) )
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark]) self.assertBookmarkListEqual([response.data], [bookmark])
def test_get_bookmark_returns_fallback_webarchive_url(self):
self.authenticate()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="",
url="https://example.com/",
added=timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
),
)
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(
response.data["web_archive_snapshot_url"],
"https://web.archive.org/web/20230811214511/https://example.com/",
)
def test_update_bookmark(self): def test_update_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"url": "https://example.com/updated"} data = {"url": "https://example.com/updated"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"]) self.assertEqual(updated_bookmark.url, data["url"])
@@ -610,7 +682,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"website_title": "test", "website_title": "test",
"website_description": "test", "website_description": "test",
} }
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(data["url"], updated_bookmark.url) self.assertEqual(data["url"], updated_bookmark.url)
@@ -627,7 +699,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"title": "https://example.com/"} data = {"title": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST) self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self): def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
@@ -637,7 +709,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"]) self.assertEqual(updated_bookmark.url, data["url"])
@@ -654,7 +726,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"url": "https://example.com/", "unread": True} data = {"url": "https://example.com/", "unread": True}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.unread, True) self.assertEqual(updated_bookmark.unread, True)
@@ -664,7 +736,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"url": "https://example.com/", "shared": True} data = {"url": "https://example.com/", "shared": True}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.shared, True) self.assertEqual(updated_bookmark.shared, True)
@@ -680,7 +752,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
profile.save() profile.save()
data = {"url": "https://example.com/", "tag_names": [tag1.name]} data = {"url": "https://example.com/", "tag_names": [tag1.name]}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2]) self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
@@ -695,17 +767,17 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# if the URL isn't modified it's not a duplicate # if the URL isn't modified it's not a duplicate
data = {"url": edited_bookmark.url} data = {"url": edited_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id]) url = reverse("linkding:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
# if the URL is already bookmarked by another user, it's not a duplicate # if the URL is already bookmarked by another user, it's not a duplicate
data = {"url": other_user_bookmark.url} data = {"url": other_user_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id]) url = reverse("linkding:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
# if the URL is already bookmarked by the same user, it's a duplicate # if the URL is already bookmarked by the same user, it's a duplicate
data = {"url": existing_bookmark.url} data = {"url": existing_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id]) url = reverse("linkding:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST) self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_patch_bookmark(self): def test_patch_bookmark(self):
@@ -713,55 +785,55 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"url": "https://example.com"} data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(bookmark.url, data["url"]) self.assertEqual(bookmark.url, data["url"])
data = {"title": "Updated title"} data = {"title": "Updated title"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(bookmark.title, data["title"]) self.assertEqual(bookmark.title, data["title"])
data = {"description": "Updated description"} data = {"description": "Updated description"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(bookmark.description, data["description"]) self.assertEqual(bookmark.description, data["description"])
data = {"notes": "Updated notes"} data = {"notes": "Updated notes"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(bookmark.notes, data["notes"]) self.assertEqual(bookmark.notes, data["notes"])
data = {"unread": True} data = {"unread": True}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(bookmark.unread) self.assertTrue(bookmark.unread)
data = {"unread": False} data = {"unread": False}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(bookmark.unread) self.assertFalse(bookmark.unread)
data = {"shared": True} data = {"shared": True}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(bookmark.shared) self.assertTrue(bookmark.shared)
data = {"shared": False} data = {"shared": False}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(bookmark.shared) self.assertFalse(bookmark.shared)
data = {"tag_names": ["updated-tag-1", "updated-tag-2"]} data = {"tag_names": ["updated-tag-1", "updated-tag-2"]}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
bookmark.refresh_from_db() bookmark.refresh_from_db()
tag_names = [tag.name for tag in bookmark.tags.all()] tag_names = [tag.name for tag in bookmark.tags.all()]
@@ -776,7 +848,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"website_title": "test", "website_title": "test",
"website_description": "test", "website_description": "test",
} }
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertNotEqual( self.assertNotEqual(
@@ -793,7 +865,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()] is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
) )
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK) self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, bookmark.url) self.assertEqual(updated_bookmark.url, bookmark.url)
@@ -816,7 +888,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
profile.save() profile.save()
data = {"tag_names": [tag1.name]} data = {"tag_names": [tag1.name]}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2]) self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
@@ -825,7 +897,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertEqual(len(Bookmark.objects.filter(id=bookmark.id)), 0) self.assertEqual(len(Bookmark.objects.filter(id=bookmark.id)), 0)
@@ -833,7 +905,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-archive", args=[bookmark.id]) url = reverse("linkding:bookmark-archive", args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=bookmark.id) bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertTrue(bookmark.is_archived) self.assertTrue(bookmark.is_archived)
@@ -842,7 +914,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark(is_archived=True) bookmark = self.setup_bookmark(is_archived=True)
url = reverse("bookmarks:bookmark-unarchive", args=[bookmark.id]) url = reverse("linkding:bookmark-unarchive", args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=bookmark.id) bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
@@ -850,7 +922,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self): def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
self.authenticate() self.authenticate()
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -873,7 +945,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
mock_load_website_metadata.return_value = expected_metadata mock_load_website_metadata.return_value = expected_metadata
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -897,7 +969,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
preview_image_file="preview.png", preview_image_file="preview.png",
) )
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -934,7 +1006,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
mock_load_website_metadata.return_value = expected_metadata mock_load_website_metadata.return_value = expected_metadata
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -950,7 +1022,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_check_returns_no_auto_tags_if_none_configured(self): def test_check_returns_no_auto_tags_if_none_configured(self):
self.authenticate() self.authenticate()
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -966,7 +1038,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
profile.auto_tagging_rules = "example.com tag1 tag2" profile.auto_tagging_rules = "example.com tag1 tag2"
profile.save() profile.save()
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -975,6 +1047,43 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertCountEqual(auto_tags, ["tag1", "tag2"]) self.assertCountEqual(auto_tags, ["tag1", "tag2"])
def test_check_ignore_cache(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",
"https://example.com/preview.png",
)
mock_load_website_metadata.return_value = expected_metadata
# Does not ignore cache by default
url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com")
self.get(
f"{url}?url={check_url}",
expected_status_code=status.HTTP_200_OK,
)
mock_load_website_metadata.assert_called_once_with(
"https://example.com", ignore_cache=False
)
mock_load_website_metadata.reset_mock()
# Ignores cache based on query param
self.get(
f"{url}?url={check_url}&ignore_cache=true",
expected_status_code=status.HTTP_200_OK,
)
mock_load_website_metadata.assert_called_once_with(
"https://example.com", ignore_cache=True
)
def test_can_only_access_own_bookmarks(self): def test_can_only_access_own_bookmarks(self):
self.authenticate() self.authenticate()
self.setup_bookmark() self.setup_bookmark()
@@ -987,23 +1096,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True) inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
self.setup_bookmark(user=other_user, is_archived=True) self.setup_bookmark(user=other_user, is_archived=True)
url = reverse("bookmarks:bookmark-list") url = reverse("linkding:bookmark-list")
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 1) self.assertEqual(len(response.data["results"]), 1)
url = reverse("bookmarks:bookmark-archived") url = reverse("linkding:bookmark-archived")
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 1) self.assertEqual(len(response.data["results"]), 1)
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id]) url = reverse("linkding:bookmark-detail", args=[inaccessible_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse( url = reverse(
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id] "linkding:bookmark-detail", args=[inaccessible_shared_bookmark.id]
) )
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id]) url = reverse("linkding:bookmark-detail", args=[inaccessible_bookmark.id])
self.put( self.put(
url, url,
{url: "https://example.com/"}, {url: "https://example.com/"},
@@ -1012,7 +1121,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse( url = reverse(
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id] "linkding:bookmark-detail", args=[inaccessible_shared_bookmark.id]
) )
self.put( self.put(
url, url,
@@ -1021,31 +1130,31 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
) )
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id]) url = reverse("linkding:bookmark-detail", args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse( url = reverse(
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id] "linkding:bookmark-detail", args=[inaccessible_shared_bookmark.id]
) )
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-archive", args=[inaccessible_bookmark.id]) url = reverse("linkding:bookmark-archive", args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse( url = reverse(
"bookmarks:bookmark-archive", args=[inaccessible_shared_bookmark.id] "linkding:bookmark-archive", args=[inaccessible_shared_bookmark.id]
) )
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-unarchive", args=[inaccessible_bookmark.id]) url = reverse("linkding:bookmark-unarchive", args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse( url = reverse(
"bookmarks:bookmark-unarchive", args=[inaccessible_shared_bookmark.id] "linkding:bookmark-unarchive", args=[inaccessible_shared_bookmark.id]
) )
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url) check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
response = self.get( response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
@@ -1074,13 +1183,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
response.data["search_preferences"], profile.search_preferences response.data["search_preferences"], profile.search_preferences
) )
self.assertEqual(response.data["version"], app_version)
def test_user_profile(self): def test_user_profile(self):
self.authenticate() self.authenticate()
# default profile # default profile
profile = self.user.profile profile = self.user.profile
url = reverse("bookmarks:user-profile") url = reverse("linkding:user-profile")
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertUserProfile(response, profile) self.assertUserProfile(response, profile)
@@ -1103,7 +1213,123 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
} }
profile.save() profile.save()
url = reverse("bookmarks:user-profile") url = reverse("linkding:user-profile")
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertUserProfile(response, profile) self.assertUserProfile(response, profile)
def create_singlefile_upload_body(self):
url = "https://example.com"
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
return {"url": url, "file": file}
def test_singlefile_upload(self):
bookmark = self.setup_bookmark(url="https://example.com")
self.authenticate()
response = self.client.post(
reverse("linkding:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(response.data["message"], "Snapshot uploaded successfully.")
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_creates_bookmark_if_not_exists(self):
other_user = self.setup_user()
self.setup_bookmark(url="https://example.com", user=other_user)
self.authenticate()
self.client.post(
reverse("linkding:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(Bookmark.objects.count(), 2)
bookmark = Bookmark.objects.get(
url="https://example.com", owner=self.get_or_create_test_user()
)
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_updates_own_bookmark_if_exists(self):
bookmark = self.setup_bookmark(url="https://example.com")
other_user = self.setup_user()
self.setup_bookmark(url="https://example.com", user=other_user)
self.authenticate()
self.client.post(
reverse("linkding:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(Bookmark.objects.count(), 2)
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_creates_bookmark_without_creating_snapshot(self):
with patch(
"bookmarks.services.bookmarks.create_bookmark"
) as mock_create_bookmark:
self.authenticate()
self.client.post(
reverse("linkding:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
mock_create_bookmark.assert_called_once()
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
)
def test_singlefile_upload_missing_parameters(self):
self.authenticate()
# Missing 'url'
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
response = self.client.post(
reverse("linkding:bookmark-singlefile"),
{"file": file},
format="multipart",
expected_status_code=status.HTTP_400_BAD_REQUEST,
)
self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required."
)
# Missing 'file'
response = self.client.post(
reverse("linkding:bookmark-singlefile"),
{"url": "https://example.com"},
format="multipart",
expected_status_code=status.HTTP_400_BAD_REQUEST,
)
self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required."
)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_singlefile_upload_disabled(self):
self.authenticate()
self.client.post(
reverse("linkding:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_403_FORBIDDEN,
)

View File

@@ -33,7 +33,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
self.get( self.get(
reverse("bookmarks:bookmark-list"), reverse("linkding:bookmark-list"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
@@ -51,7 +51,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
self.get( self.get(
reverse("bookmarks:bookmark-archived"), reverse("linkding:bookmark-archived"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
@@ -70,7 +70,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
self.get( self.get(
reverse("bookmarks:bookmark-shared"), reverse("linkding:bookmark-shared"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )

View File

@@ -16,36 +16,36 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_list_bookmarks_requires_authentication(self): def test_list_bookmarks_requires_authentication(self):
self.get( self.get(
reverse("bookmarks:bookmark-list"), reverse("linkding:bookmark-list"),
expected_status_code=status.HTTP_401_UNAUTHORIZED, expected_status_code=status.HTTP_401_UNAUTHORIZED,
) )
self.authenticate() self.authenticate()
self.get( self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
) )
def test_list_archived_bookmarks_requires_authentication(self): def test_list_archived_bookmarks_requires_authentication(self):
self.get( self.get(
reverse("bookmarks:bookmark-archived"), reverse("linkding:bookmark-archived"),
expected_status_code=status.HTTP_401_UNAUTHORIZED, expected_status_code=status.HTTP_401_UNAUTHORIZED,
) )
self.authenticate() self.authenticate()
self.get( self.get(
reverse("bookmarks:bookmark-archived"), reverse("linkding:bookmark-archived"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
def test_list_shared_bookmarks_does_not_require_authentication(self): def test_list_shared_bookmarks_does_not_require_authentication(self):
self.get( self.get(
reverse("bookmarks:bookmark-shared"), reverse("linkding:bookmark-shared"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
self.authenticate() self.authenticate()
self.get( self.get(
reverse("bookmarks:bookmark-shared"), reverse("linkding:bookmark-shared"),
expected_status_code=status.HTTP_200_OK, expected_status_code=status.HTTP_200_OK,
) )
@@ -61,16 +61,14 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
"tag_names": ["tag1", "tag2"], "tag_names": ["tag1", "tag2"],
} }
self.post( self.post(reverse("linkding:bookmark-list"), data, status.HTTP_401_UNAUTHORIZED)
reverse("bookmarks:bookmark-list"), data, status.HTTP_401_UNAUTHORIZED
)
self.authenticate() self.authenticate()
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED) self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
def test_get_bookmark_requires_authentication(self): def test_get_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -80,7 +78,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_update_bookmark_requires_authentication(self): def test_update_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -93,14 +91,14 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
other_user = self.setup_user() other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com/"} data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND) self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_patch_bookmark_requires_authentication(self): def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
data = {"url": "https://example.com"} data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -113,13 +111,13 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
other_user = self.setup_user() other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com"} data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND) self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_delete_bookmark_requires_authentication(self): def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id]) url = reverse("linkding:bookmark-detail", args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -128,7 +126,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_archive_requires_authentication(self): def test_archive_requires_authentication(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-archive", args=[bookmark.id]) url = reverse("linkding:bookmark-archive", args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -137,7 +135,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_unarchive_requires_authentication(self): def test_unarchive_requires_authentication(self):
bookmark = self.setup_bookmark(is_archived=True) bookmark = self.setup_bookmark(is_archived=True)
url = reverse("bookmarks:bookmark-unarchive", args=[bookmark.id]) url = reverse("linkding:bookmark-unarchive", args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -145,7 +143,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_check_requires_authentication(self): def test_check_requires_authentication(self):
url = reverse("bookmarks:bookmark-check") url = reverse("linkding:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com") check_url = urllib.parse.quote_plus("https://example.com")
self.get( self.get(
@@ -156,9 +154,14 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.get(f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK) self.get(f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK)
def test_user_profile_requires_authentication(self): def test_user_profile_requires_authentication(self):
url = reverse("bookmarks:user-profile") url = reverse("linkding:user-profile")
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED) self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.authenticate() self.authenticate()
self.get(url, expected_status_code=status.HTTP_200_OK) self.get(url, expected_status_code=status.HTTP_200_OK)
def test_singlefile_upload_requires_authentication(self):
url = reverse("linkding:bookmark-singlefile")
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -43,7 +43,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<a href="{url}" <a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener"> title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content} {label_content}
</a> </a>
<span>|</span> <span>|</span>
@@ -65,18 +65,18 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
count=1, count=1,
): ):
if base_url is None: if base_url is None:
base_url = reverse("bookmarks:index") base_url = reverse("linkding:bookmarks.index")
details_url = base_url + f"?details={bookmark.id}" details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML( self.assertInHTML(
f""" f"""
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a> <a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
""", """,
html, html,
count=count, count=count,
) )
def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1): def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):
edit_url = reverse("bookmarks:edit", args=[bookmark.id]) edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
self.assertInHTML( self.assertInHTML(
f""" f"""
<a href="{edit_url}?return_url=/bookmarks">Edit</a> <a href="{edit_url}?return_url=/bookmarks">Edit</a>
@@ -559,11 +559,39 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_self" html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_self"
) )
def test_should_render_latest_snapshot_link_if_one_exists(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
)
bookmark.latest_snapshot = self.setup_asset(bookmark)
bookmark.save()
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
snapshot_url = reverse(
"linkding:assets.view", args=[bookmark.latest_snapshot.id]
)
# Check that the snapshot link is rendered with the correct URL and title
self.assertInHTML(
f"""
<a href="{snapshot_url}"
title="View latest snapshot" target="_blank" rel="noopener">
{formatted_date}
</a>
<span>|</span>
""",
html,
)
def test_should_reflect_unread_state_as_css_class(self): def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True) self.setup_bookmark(unread=True)
html = self.render_template() html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread">', html) list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread"], list_item["class"])
def test_should_reflect_shared_state_as_css_class(self): def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -572,8 +600,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True) self.setup_bookmark(shared=True)
html = self.render_template() html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="shared">', html) list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["shared"], list_item["class"])
def test_should_reflect_both_unread_and_shared_state_as_css_class(self): def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -582,8 +613,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(unread=True, shared=True) self.setup_bookmark(unread=True, shared=True)
html = self.render_template() html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread shared">', html) list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread", "shared"], list_item["class"])
def test_show_bookmark_actions_for_owned_bookmarks(self): def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -651,7 +685,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext) html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared")) self.assertViewLink(
html, bookmark, base_url=reverse("linkding:bookmarks.shared")
)
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
@@ -848,6 +884,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
) )
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
def test_note_renders_markdown_with_linkify(self):
# Should linkify plain URL
self.setup_bookmark(notes="Example: https://example.com")
html = self.render_template()
note_html = '<p>Example: <a href="https://example.com" rel="nofollow">https://example.com</a></p>'
self.assertNotes(html, note_html, 1)
# Should not linkify URL in markdown link
self.setup_bookmark(notes="[https://example.com](https://example.com)")
html = self.render_template()
note_html = '<p><a href="https://example.com" rel="nofollow">https://example.com</a></p>'
self.assertNotes(html, note_html, 1)
def test_note_cleans_html(self): def test_note_cleans_html(self):
self.setup_bookmark(notes='<script>alert("test")</script>') self.setup_bookmark(notes='<script>alert("test")</script>')
self.setup_bookmark( self.setup_bookmark(
@@ -943,7 +994,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertWebArchiveLink( self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank" html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
) )
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared")) self.assertViewLink(
html, bookmark, base_url=reverse("linkding:bookmarks.shared")
)
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0) self.assertMarkAsReadButton(html, bookmark, count=0)

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