Compare commits

...

154 Commits

Author SHA1 Message Date
Sascha Ißbrücker
992dc69a36 Bump version 2022-08-14 13:41:53 +02:00
Sascha Ißbrücker
c9c6b097d0 Add support for authentication proxies (#321)
* add support for auth proxies

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

* Fix focus outline for title link

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

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

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

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

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

add an optional environment variable: LD_CONTEXT_PATH

* Fix for pull request code review comments

Co-authored-by: s2marine <s2marine@gmail.com>
2022-08-07 12:41:11 +02:00
Aaron Bach
eadae32eb3 Add simple docs of the new shared API parameter (#312) 2022-08-05 09:34:03 +02:00
Sascha Ißbrücker
2f0dd0db0d Update README.md 2022-08-04 21:18:56 +02:00
Sascha Ißbrücker
da4ed5b7c1 Update CHANGELOG.md 2022-08-04 21:05:30 +02:00
Sascha Ißbrücker
fd2770efd8 Bump version 2022-08-04 20:58:02 +02:00
Sascha Ißbrücker
dd5e65ecd7 Display selected tags in tag cloud (#307)
* Add links to remove tags from current query

* Display selected tags in tag cloud

* Add tag cloud tests

* Fix tag cloud in archive

* Add tests for bookmark views

* Expose parse query string

* Improve tag cloud tests

* Cleanup

* Fix rebase issues

* Ignore casing when removing tags from query

Co-authored-by: Jon Hauris <jonp@hauris.org>
2022-08-04 20:31:24 +02:00
Sascha Ißbrücker
fec966f687 Add bookmark sharing (#311)
* Allow marking bookmarks as shared

* Add basic share view

* Ensure tag names in tag cloud are unique

* Filter shared bookmarks by user

* Add link for filtering by user

* Prevent n+1 queries when rendering bookmark list

* Prevent empty query params in return URL

* Fix user select template tag name

* Create shared bookmarks through API

* List shared bookmarks through API

* Show bookmark suggestions for shared view

* Show unique tags in search suggestions

* Sort user options

* Add bookmark sharing feature flag

* Add test for share setting default

* Simplify settings view
2022-08-04 19:37:16 +02:00
Sascha Ißbrücker
e6718be53b Update unread flag when saving duplicate URL (#306)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-26 00:13:41 +02:00
Sascha Ißbrücker
3ac35677d8 Update CHANGELOG.md 2022-07-24 01:06:36 +02:00
Sascha Ißbrücker
013ea16578 Bump version 2022-07-24 01:00:43 +02:00
dependabot[bot]
cf1085c781 Bump terser from 5.5.1 to 5.14.2 (#302)
Bumps [terser](https://github.com/terser/terser) from 5.5.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-23 23:43:27 +02:00
dependabot[bot]
5d1dc38d1d Bump svelte from 3.46.4 to 3.49.0 (#299)
Bumps [svelte](https://github.com/sveltejs/svelte) from 3.46.4 to 3.49.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/compare/v3.46.4...v3.49.0)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-23 23:39:10 +02:00
Kian-Meng Ang
de6e91fd75 Fix typo (#295) 2022-07-23 23:36:00 +02:00
Function
506d3cad25 Shorten and simplify example bookmarklet in documentation (#297)
* Shorten and simplify example bookmarklet

* Restore server placeholder in bookmarklet documentation
2022-07-23 23:35:40 +02:00
dependabot[bot]
fdfafbbb0b Bump django from 3.2.13 to 3.2.14 (#294)
Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.14.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.13...3.2.14)

---
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>
2022-07-23 23:33:37 +02:00
Sascha Ißbrücker
54ce6d5fe6 Add RSS feeds (#305)
* Add basic unread bookmarks feed

* Generate user-specific feed

* Add feed tests

* Add all bookmarks feed

* Add feed token admin

* Add note about renewing URLs

* Add support for query parameter

* Fix rebase issues

* Improve docs on feeds integration

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-23 23:20:27 +02:00
Sascha Ißbrücker
13ff9ac4f8 Add read it later functionality (#304)
* Allow marking bookmarks as unread

* Restructure navigation to include preset filters

* Add mark as read action

* Improve description

* Highlight unread bookmarks visually

* Mark bookmarks as read by default

* Add tests

* Implement toread flag in importer

* Implement admin actions

* Add query tests

* Remove untagged link

* Update api docs

* Reduce height of description textarea

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-23 22:17:20 +02:00
ukcuddlyguy
48e4958218 Add bookmarklet to community (#293)
* Add bookmarklet to community

https://github.com/sissbruecker/linkding/issues/290

* Update README.md

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-07-04 22:34:12 +02:00
Sascha Ißbrücker
b618a8b10b Do not associate tags if bookmark was not imported 2022-07-03 14:44:16 +02:00
Sascha Ißbrücker
90a46c1fb9 Update CHANGELOG.md 2022-07-03 07:53:33 +02:00
Sascha Ißbrücker
3086926146 Bump version 2022-07-03 06:51:38 +02:00
Dustin Blackman
b53bd9f112 Bump waybackpy to 3.0.6 (#281)
* fix wayback

* fix tests

* Reuse user agent from website loader

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-03 06:26:16 +02:00
Dave Onkels
75c0429973 Add apple-touch-icon reference in header (#282)
* Add apple-touch-icon reference in header

Recommend adding this reference to support an icon when adding a web app to an iOS homescreen.

* Add dedicated apple touch icon

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-03 06:05:20 +02:00
wahlm
0829d00e5f no duplication of imported tags (#289)
* no duplication of imported tags (#287)

* Add importer test

* Revert settings test

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-03 05:34:40 +02:00
Sascha Ißbrücker
88fcb42292 Update CHANGELOG.md 2022-05-26 04:25:14 +02:00
Sascha Ißbrücker
aac8bf39b8 Bump version 2022-05-26 04:19:22 +02:00
Aaron Bach
49f648a908 Add community reference to linkding-cli (#270) 2022-05-26 04:16:06 +02:00
Sascha Ißbrücker
68c3c27b38 Add PATCH support to bookmarks endpoint (#269)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-26 04:15:13 +02:00
kenc
792a19d15e Allow creating archived bookmark through REST API (#268)
* Add POST archived API endpoint

* Update API docs

* Expose is_archived in existing POST endpoint

* Add test to verify bookmark not archived by default

* Fix JSON payload in API docs

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-05-26 04:10:36 +02:00
Sascha Ißbrücker
2de6d8151b Improve about section (#265)
* Improve about section

* Add changelog link

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 21:33:08 +02:00
Sascha Ißbrücker
9e9d7ae7d2 Add background tasks to admin (#264)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 18:21:10 +02:00
Sascha Ißbrücker
4e8a183082 Update CHANGELOG.md 2022-05-21 13:42:33 +02:00
Sascha Ißbrücker
7719c5b1ba Bump version 2022-05-21 13:26:28 +02:00
Sascha Ißbrücker
e08bf9fd03 Fake request headers to reduce bot detection (#263)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 13:25:32 +02:00
Sascha Ißbrücker
a9bf111ff1 Update CHANGELOG.md 2022-05-21 10:49:40 +02:00
Sascha Ißbrücker
54b0b32b80 Update SECURITY.md 2022-05-21 10:46:27 +02:00
Sascha Ißbrücker
bd7a937430 Bump version 2022-05-21 10:43:16 +02:00
Sascha Ißbrücker
138dfe392c Reduce resource usage 2022-05-21 10:42:30 +02:00
Sascha Ißbrücker
d7f257b3c6 Allow searching for untagged bookmarks (#226)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 09:50:51 +02:00
Chris Patti
ebbf0022bc Update how-to.md (#260)
Change the paraphrasing to the exact name of the correct "Show Web Page At" selection under the Safari category in IOS Shortcuts.

Wow did this confuse me until I figured it out :) Hopefully this helps the next new user! Love this app to bits!
2022-05-21 09:29:14 +02:00
Sascha Ißbrücker
f4e3d724f0 Improve import performance (#261)
* Run import in batches, cache tags

* Use bulk operations for bookmarks and assigning tags

* Improve naming

* Restore bookmark validation

* Add logging

* Bulk create tags

* Use HTMLParser for parsing bookmarks

* add parser tests

* Add more importer tests

* Add more importer tests

* Remove pyparsing dependency

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 09:27:30 +02:00
Sascha Ißbrücker
117160ea87 Enforce CSRF check for acknowledging toasts 2022-05-20 16:51:50 +02:00
Aaron Bach
e14458f5cd Add community reference to aiolinkding (#259) 2022-05-19 08:07:44 +02:00
Manuel Riel
179d0c26ca Add to managed hosting options (#253)
* Add to managed hosting options

* Fix capitalization
2022-05-19 08:05:25 +02:00
Sascha Ißbrücker
5f5f470f52 Add documentation section 2022-05-14 23:59:56 +02:00
Sascha Ißbrücker
3e521493b9 Update SECURITY.md 2022-05-14 19:46:22 +02:00
Sascha Ißbrücker
bbaa1669cd Improve README.md 2022-05-14 19:23:52 +02:00
Sascha Ißbrücker
fb779cf6d6 Update CHANGELOG.md 2022-05-14 11:09:17 +02:00
Sascha Ißbrücker
14e4950fec Bump version 2022-05-14 10:13:58 +02:00
Sascha Ißbrücker
f92c3dd403 Make Internet Archive integration opt-in (#250)
* Make web archive integration opt-in

* Add toast message about web archive integration opt-in

* Improve wording for web archive setting

* Add toast admin

* Fix toast clear button visited styles

* Add test for redirect

* Improve wording

* Ensure redirects to same domain

* Improve wording

* Fix snapshot test

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 09:46:51 +02:00
clach04
56173aea3f Issue #187 clarify archive.org feature (#229)
* Issue #187 clarify archive.org feature

Use the name in the readme that archive.org use.

* Simplify

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-05-14 08:49:21 +02:00
Rithas K
c97d5c3dc5 Feature: Shortcut key for new bookmark (#241)
* Add shortcut key for new bookmark

* Use location.assign to keep history

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 08:44:03 +02:00
Pascal Iske
6dd07edf90 docs(readme): add community helm chart link to readme (#242) 2022-05-14 08:34:47 +02:00
dependabot[bot]
1274d6dd4f Bump django from 3.2.12 to 3.2.13 (#244)
Bumps [django](https://github.com/django/django) from 3.2.12 to 3.2.13.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.12...3.2.13)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-14 08:31:41 +02:00
Sascha Ißbrücker
6cf35ecca6 Add whitespace after auto-completed tag (#249)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 01:18:41 +02:00
Sascha Ißbrücker
c5c527400c Remove IntelliJ project files 2022-05-14 01:13:17 +02:00
Sascha Ißbrücker
dc0a4e33bd Scroll menu items into view when using keyboard (#248)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 01:02:48 +02:00
Sascha Ißbrücker
ca5dcb882c Update CHANGELOG.md 2022-03-27 12:03:24 +02:00
Sascha Ißbrücker
04649e0901 Update CHANGELOG.md 2022-03-27 11:59:36 +02:00
Sascha Ißbrücker
a85f1cfe83 Bump version 2022-03-27 11:54:28 +02:00
Sascha Ißbrücker
3906d9e5b8 Prevent external redirects 2022-03-27 11:47:45 +02:00
Sascha Ißbrücker
eca98a13f5 Prevent bookmark actions through get requests 2022-03-27 10:56:09 +02:00
Sascha Ißbrücker
10e5861f01 Update CHANGELOG.md 2022-03-26 11:31:46 +01:00
Sascha Ißbrücker
f68c67e272 Update CHANGELOG.md 2022-03-26 11:07:11 +01:00
Sascha Ißbrücker
e2a52b9cba Bump version 2022-03-26 11:02:06 +01:00
Sascha Ißbrücker
4ad2d2111a Bump npm packages (#224)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-03-26 10:58:47 +01:00
Christoph Schmatzler
c16e87f9c7 Allow specifying port through LD_SERVER_PORT environment variable (#156)
* Allow specifying port through LD_SERVER_PORT environment variable

Co-authored-by: Christoph Schmatzler <christoph@medium.place>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-03-26 10:24:38 +01:00
elmodor
673466ab28 Increase buffer size (#189)
Handles requests with a larger block size than the default 4096
2022-03-26 10:02:36 +01:00
Sascha Ißbrücker
13f27f5412 Update CHANGELOG.md 2022-03-25 19:57:59 +01:00
Sascha Ißbrücker
530c4b74c4 Update CHANGELOG.md 2022-03-25 19:49:43 +01:00
Sascha Ißbrücker
3eb8cfe45e Bump version 2022-03-25 18:55:58 +01:00
dependabot[bot]
f5b07eebba Bump sqlparse from 0.4.1 to 0.4.2 (#159)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.1 to 0.4.2.
- [Release notes](https://github.com/andialbrecht/sqlparse/releases)
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.1...0.4.2)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-25 18:54:07 +01:00
dependabot[bot]
3ba8f7e30b Bump django from 3.2.6 to 3.2.12 (#197)
Bumps [django](https://github.com/django/django) from 3.2.6 to 3.2.12.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.6...3.2.12)

---
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>
2022-03-25 18:51:59 +01:00
Sascha Ißbrücker
9a63c367a8 Move shortcut to docs 2022-03-25 18:35:05 +01:00
Sascha Ißbrücker
edb71286e7 Prevent external redirects 2022-03-25 18:29:54 +01:00
Sascha Ißbrücker
1ffc3e0266 Fix bookmark access restrictions 2022-03-22 02:24:21 +01:00
Sascha Ißbrücker
66995cfab2 Create SECURITY.md 2022-03-20 09:11:58 +01:00
Kazi
68143de992 How-to guide for HTTP Shortcuts app on Android (#201)
* How-to guide for HTTP Shortcuts app on Android

* refinements

* link fix
2022-02-28 08:12:37 +01:00
Fivefold
b93a9fadb6 Add linkding-injector community extension to README (#190) 2022-01-17 13:39:45 +01:00
Sascha Ißbrücker
77fea02f77 Update CHANGELOG.md 2021-12-13 00:12:51 +01:00
Sascha Ißbrücker
fcc0b6f591 Update CHANGELOG.md 2021-12-13 00:09:14 +01:00
Sascha Ißbrücker
e1c9a7add6 Bump version 2021-12-12 22:57:22 +01:00
Sascha Ißbrücker
82b4268a26 Ensure tag names don't contain spaces (#184) 2021-12-12 22:54:22 +01:00
Fivefold
5287eb3f8b Make bookmarks count column in admin sortable (#183)
The Tag view in django admin has a calculated bookmark count column that is unsortable. This fixes it
2021-12-12 22:52:22 +01:00
QiaoHao
d298260122 exclude .git in docker container (#176) 2021-12-05 21:11:49 +01:00
Sascha Ißbrücker
12e5810aee Fix docker-compose.yaml to import variables from env file 2021-11-10 12:19:56 +01:00
Sascha Ißbrücker
1dabd0266b Update CHANGELOG.md 2021-10-16 05:49:56 +02:00
Sascha Ißbrücker
7390fc3f4f Bump version 2021-10-16 05:44:05 +02:00
Sascha Ißbrücker
5e003ede92 Change api token field to readonly 2021-10-16 05:43:35 +02:00
Sascha Ißbrücker
984eef92e2 Add password change view (#168) 2021-10-16 05:42:04 +02:00
Sascha Ißbrücker
eae6ca6e07 Merge API view with integrations view (#165) 2021-10-03 15:13:45 +02:00
Sascha Ißbrücker
a6bfaa7c78 Update CHANGELOG.md 2021-10-03 09:54:10 +02:00
Sascha Ißbrücker
7aa1630be2 Bump version 2021-10-03 09:49:50 +02:00
Sascha Ißbrücker
4f9fcb41bd Add bookmark link target setting (#164) 2021-10-03 09:35:59 +02:00
Sascha Ißbrücker
da4a81305a Bump version 2021-10-02 23:57:21 +02:00
Sascha Ißbrücker
df33144dd0 Update CHANGELOG.md 2021-10-02 23:55:16 +02:00
Sascha Ißbrücker
123fa54d5a Fix jumping search box (#163) 2021-10-02 23:49:59 +02:00
Sascha Ißbrücker
2ab4aa5566 Update CHANGELOG.md 2021-10-01 18:10:58 +02:00
Sascha Ißbrücker
d4cba7d5fa Update CHANGELOG.md 2021-10-01 18:08:24 +02:00
Sascha Ißbrücker
3d8fd66e50 Bump version 2021-10-01 18:03:28 +02:00
Sascha Ißbrücker
3ff7a5ba91 Add global search shortcut (#161) 2021-10-01 18:02:34 +02:00
Sascha Ißbrücker
88c109c9a4 Update CHANGELOG.md 2021-09-04 22:44:42 +02:00
Sascha Ißbrücker
a1d5ff6532 Update CHANGELOG.md 2021-09-04 22:39:32 +02:00
Sascha Ißbrücker
e7c55cd318 Bump version 2021-09-04 22:31:55 +02:00
Sascha Ißbrücker
d87dde6bae Create snapshots on web.archive.org for bookmarks (#150)
* Implement initial background tasks concept

* fix property reference

* update requirements.txt

* simplify bookmark null check

* improve web archive url display

* add background tasks test

* add basic supervisor setup

* schedule missing snapshot creation on login

* remove task locks and clear task history before starting background task processor

* batch create snapshots after import

* fix script reference in supervisord.conf

* add option to disable background tasks

* restructure feature overview
2021-09-04 22:31:04 +02:00
Sascha Ißbrücker
8d214649b7 Change Docker base image to slim-buster 2021-08-27 10:14:48 +02:00
Sascha Ißbrücker
dfb040bbb1 Update CHANGELOG.md 2021-08-26 12:44:06 +02:00
Sascha Ißbrücker
076c5d7658 Bump version 2021-08-26 12:40:03 +02:00
Sascha Ißbrücker
e47c00bd07 Add support for micro-, nanosecond timestamps in importer (#151) 2021-08-26 12:33:54 +02:00
Sascha Ißbrücker
55a0d189dd Update CHANGELOG.md 2021-08-25 12:36:38 +02:00
Sascha Ißbrücker
d39ce076ec Bump version 2021-08-25 10:25:22 +02:00
Chris Cesare
aa0258d3b6 remove duplicate word in README (#136) 2021-08-25 10:20:35 +02:00
Taku Izumi
937858cf58 Fix website scraper decoding content incorrectly (#126)
* Avoid stall on web scraping

This patch fixes stall on web scraping.
I encountered a stall (scraping never ends) when adding
a bookmark of some site.
To avoid this case, adding a timeout parameter at requests.get()
function is a solution.

Signed-off-by: Taku Izumi <admin@orz-style.com>

* Avoid character corruption of scraping some Japanese sites

This patch fixes character corruption of scraping some Japanese
sites. To avoid character corruption, I use r.content instead
of r.text in load_page function.

The reason of character corruption is encoding problem, I think.
r.text handles data as unicode encoded text, so if scraping
web site's charset is not unicode encoded, character corruption
occurs. r.content handles data as str[], we can avoid encoding
problem.

Signed-off-by: Taku Izumi <admin@orz-style.com>

* use charset_normalizer to determine response encoding

Co-authored-by: Taku Izumi <admin@orz-style.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-25 10:16:23 +02:00
Sascha Ißbrücker
8047ba6c63 Fix importer not validating bookmark models (#149) 2021-08-25 09:20:01 +02:00
Damanpreet Singh
de903bc341 Add about section in settings (#134)
* About section in settings

* Added about section in settings tab

* fix code style

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-24 19:47:58 +02:00
Sascha Ißbrücker
c8fcc426b0 Update CHANGELOG.md 2021-08-17 06:07:40 +02:00
Sascha Ißbrücker
eb915210d3 Update CHANGELOG.md 2021-08-17 06:02:07 +02:00
Sascha Ißbrücker
ad9a0f84f2 Bump version 2021-08-17 05:53:25 +02:00
Sascha Ißbrücker
cc04a17e2f Upgrade Django major (#144)
* Bump dependency versions

* Configure default auto field implementation

* fix admin to use token proxy model

* update django docs link
2021-08-17 05:48:45 +02:00
Thomas Bouve
69105d3d3c Support running container as an arbitrary user of the root group (#138)
* Support OpenShift containers.

* Improve comment wording

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-16 09:16:24 +02:00
Sascha Ißbrücker
c269d16855 Update CHANGELOG.md 2021-08-15 10:47:46 +02:00
Sascha Ißbrücker
90ee3cdb94 Update CHANGELOG.md 2021-08-15 10:46:23 +02:00
Sascha Ißbrücker
2c19266ef8 bump version 2021-08-15 09:52:25 +02:00
ulixxe
048a8b1162 improve tag query performance (#142)
* changed query on tag search for speedup related to issues #112 and #141

* fix tests and only conditionally append tag filter

* add bookmark tags query tests

* reuse bookmark queries for tag queries

* fix tag query test setup

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-15 09:28:40 +02:00
Sascha Ißbrücker
2fb0bb1224 Exclude tests from coverage 2021-05-14 23:45:32 +02:00
Sascha Ißbrücker
3e48b22095 Add settings view tests 2021-05-14 23:34:53 +02:00
Sascha Ißbrücker
9aa17d0528 Add code coverage script 2021-05-14 12:23:11 +02:00
Sascha Ißbrücker
d643fca98f Add query tests 2021-05-14 10:26:33 +02:00
Sascha Ißbrücker
f293fa15bc Update CI config to install Node dependencies 2021-05-14 02:38:44 +02:00
Sascha Ißbrücker
f58434077b Add bookmark view tests 2021-05-14 02:32:19 +02:00
Sascha Ißbrücker
59641e787c Update CHANGELOG.md 2021-05-13 20:09:53 +02:00
Sascha Ißbrücker
0d36a3bb86 Update CHANGELOG.md 2021-05-13 19:21:21 +02:00
Sascha Ißbrücker
b25f3d5529 Bump version 2021-05-13 18:29:46 +02:00
mattofr
24746deaae Admin documentation (#91)
* Started admin documentation

* Small correction

* Polish admin docs and reference from README.md

Co-authored-by: emacs <emacs@hp2530p.tradesystem.nl>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-05-13 18:28:25 +02:00
André Kelpe
e4a082231f Add how-to document (#102)
* adds how-to for using the bookmarklet on Android/Chrome

* Polish how-to doc and reference from README.md

* Add how-to for creating share action in Safari

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-05-13 17:52:13 +02:00
dependabot[bot]
5a380212d9 Bump django from 2.2.18 to 2.2.20 (#110)
Bumps [django](https://github.com/django/django) from 2.2.18 to 2.2.20.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/2.2.18...2.2.20)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:23:37 +02:00
dependabot[bot]
96068719cd Bump urllib3 from 1.25.3 to 1.25.8 (#119)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.25.3 to 1.25.8.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.25.3...1.25.8)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:21:27 +02:00
dependabot[bot]
e42d562750 Bump django-debug-toolbar from 3.2 to 3.2.1 (#115)
Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 3.2 to 3.2.1.
- [Release notes](https://github.com/jazzband/django-debug-toolbar/releases)
- [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst)
- [Commits](https://github.com/jazzband/django-debug-toolbar/compare/3.2...3.2.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:17:51 +02:00
dependabot[bot]
ff456b10ee Bump django-registration from 3.0.1 to 3.1.2 (#106)
Bumps [django-registration](https://github.com/ubernostrum/django-registration) from 3.0.1 to 3.1.2.
- [Release notes](https://github.com/ubernostrum/django-registration/releases)
- [Commits](https://github.com/ubernostrum/django-registration/compare/3.0.1...3.1.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:13:36 +02:00
Sascha Ißbrücker
3a05666680 Update CHANGELOG.md 2021-04-07 00:43:39 +02:00
Sascha Ißbrücker
dbe92b4b84 Bump version 2021-04-06 23:39:02 +02:00
Sascha Ißbrücker
90f62d3482 Fix relative date formatting (#107) 2021-04-06 23:38:15 +02:00
Sascha Ißbrücker
847f9644f4 Update CHANGELOG.md 2021-04-04 10:31:48 +02:00
Sascha Ißbrücker
bf84b3ddfd Bump version 2021-04-04 10:17:12 +02:00
Sascha Ißbrücker
2d19e97212 Allow editing of scraped values (#80)
* Allow editing scraped title + description (#80)

* Fix edit button hijacking form submit
2021-04-04 10:16:40 +02:00
Sascha Ißbrücker
c083997399 Update CHANGELOG.md 2021-03-31 09:23:06 +02:00
Sascha Ißbrücker
36f134db9a Update CHANGELOG.md 2021-03-31 09:11:59 +02:00
145 changed files with 8011 additions and 961 deletions

3
.coveragerc Normal file
View File

@@ -0,0 +1,3 @@
[run]
source = bookmarks
omit = bookmarks/tests/*

View File

@@ -7,6 +7,8 @@
/docs /docs
/static /static
/build /build
/out
/.git
/.dockerignore /.dockerignore
/.gitignore /.gitignore
@@ -17,10 +19,13 @@
/*.patch /*.patch
/*.md /*.md
/*.js /*.js
/*.log
/*.pid
# Whitelist files needed in build or prod image # Whitelist files needed in build or prod image
!/rollup.config.js !/rollup.config.js
!/bootstrap.sh !/bootstrap.sh
!/background-tasks-wrapper.sh
# Remove development settings # Remove development settings
/siteroot/settings/dev.py /siteroot/settings/dev.py

View File

@@ -4,6 +4,11 @@ LD_CONTAINER_NAME=linkding
LD_HOST_PORT=9090 LD_HOST_PORT=9090
# Directory on the host system that should be mounted as data dir into the Docker container # Directory on the host system that should be mounted as data dir into the Docker container
LD_HOST_DATA_DIR=./data LD_HOST_DATA_DIR=./data
# Can be used to run linkding under a context path, for example: linkding/
# Must end with a slash `/`
LD_CONTEXT_PATH=
# Option to disable background tasks
LD_DISABLE_BACKGROUND_TASKS=False
# Option to disable URL validation for bookmarks completely # Option to disable URL validation for bookmarks completely
LD_DISABLE_URL_VALIDATION=False LD_DISABLE_URL_VALIDATION=False

View File

@@ -12,7 +12,13 @@ jobs:
uses: actions/setup-python@v1 uses: actions/setup-python@v1
with: with:
python-version: 3.7 python-version: 3.7
- name: Install dependencies - name: Set up Node
uses: actions/setup-node@v2
with:
node-version: 14
- name: Install Python dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install Node dependencies
run: npm install
- name: Run tests - name: Run tests
run: python manage.py test run: python manage.py test

45
.gitignore vendored
View File

@@ -3,55 +3,14 @@
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/dictionaries
.idea/**/shelf
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
cmake-build-release/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format # File-based project format
*.iws *.iws
# IntelliJ # IntelliJ
.idea
*.iml
out/ out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
### Python template ### Python template
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

15
.idea/compiler.xml generated
View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<wildcardResourcePatterns>
<entry name="!?*.java" />
<entry name="!?*.form" />
<entry name="!?*.class" />
<entry name="!?*.groovy" />
<entry name="!?*.scala" />
<entry name="!?*.flex" />
<entry name="!?*.kt" />
<entry name="!?*.clj" />
</wildcardResourcePatterns>
</component>
</project>

11
.idea/dataSources.xml generated
View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="SQLite - db.sqlite3" uuid="c880bd6d-554c-484d-a5be-45581d9a9377">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/db.sqlite3</jdbc-url>
</data-source>
</component>
</project>

4
.idea/encodings.xml generated
View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

View File

@@ -1,12 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E402" />
</list>
</option>
</inspection_tool>
</profile>
</component>

13
.idea/misc.xml generated
View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="HaskellBuildOptions">
<ghcPath>/usr/local/bin/ghc</ghcPath>
<stackPath>/usr/local/bin/stack</stackPath>
</component>
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" default="false" project-jdk-name="Python 3.7 (linkding)" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/linkdings.iml" filepath="$PROJECT_DIR$/linkdings.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

View File

@@ -1,41 +1,282 @@
# Changelog # Changelog
## v1.6.0 (29/03/2021) ## v1.13.0 (04/08/2022)
### What's Changed
* Add bookmark sharing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/311
* Display selected tags in tag cloud by @sissbruecker and @jhauris in https://github.com/sissbruecker/linkding/pull/307
* Update unread flag when saving duplicate URL by @sissbruecker in https://github.com/sissbruecker/linkding/pull/306
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.12.0...v1.13.0
---
## v1.12.0 (23/07/2022)
### What's Changed
* Add read it later functionality by @sissbruecker in https://github.com/sissbruecker/linkding/pull/304
* Add RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/305
* Add bookmarklet to community by @ukcuddlyguy in https://github.com/sissbruecker/linkding/pull/293
* Shorten and simplify example bookmarklet in documentation by @FunctionDJ in https://github.com/sissbruecker/linkding/pull/297
* Fix typo by @kianmeng in https://github.com/sissbruecker/linkding/pull/295
* Bump django from 3.2.13 to 3.2.14 by @dependabot in https://github.com/sissbruecker/linkding/pull/294
* Bump svelte from 3.46.4 to 3.49.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/299
* Bump terser from 5.5.1 to 5.14.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/302
### New Contributors
* @ukcuddlyguy made their first contribution in https://github.com/sissbruecker/linkding/pull/293
* @FunctionDJ made their first contribution in https://github.com/sissbruecker/linkding/pull/297
* @kianmeng made their first contribution in https://github.com/sissbruecker/linkding/pull/295
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.1...v1.12.0
---
## v1.11.1 (03/07/2022)
### What's Changed
* Fix duplicate tags on import by @wahlm in https://github.com/sissbruecker/linkding/pull/289
* Add apple-touch-icon by @daveonkels in https://github.com/sissbruecker/linkding/pull/282
* Bump waybackpy to 3.0.6 by @dustinblackman in https://github.com/sissbruecker/linkding/pull/281
### New Contributors
* @wahlm made their first contribution in https://github.com/sissbruecker/linkding/pull/289
* @daveonkels made their first contribution in https://github.com/sissbruecker/linkding/pull/282
* @dustinblackman made their first contribution in https://github.com/sissbruecker/linkding/pull/281
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.0...v1.11.1
---
## v1.11.0 (26/05/2022)
### What's Changed
* Add background tasks to admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/264
* Improve about section by @sissbruecker in https://github.com/sissbruecker/linkding/pull/265
* Allow creating archived bookmark through REST API by @kencx in https://github.com/sissbruecker/linkding/pull/268
* Add PATCH support to bookmarks endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/269
* Add community reference to linkding-cli by @bachya in https://github.com/sissbruecker/linkding/pull/270
### New Contributors
* @kencx made their first contribution in https://github.com/sissbruecker/linkding/pull/268
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.1...v1.11.0
---
## v1.10.1 (21/05/2022)
### What's Changed
* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1
---
## v1.10.0 (21/05/2022)
### What's Changed
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259
* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261
* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260
* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226
### New Contributors
* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253
* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259
* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0
---
## v1.9.0 (14/05/2022)
### What's Changed
* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248
* Add whitespace after auto-completed tag by @sissbruecker in https://github.com/sissbruecker/linkding/pull/249
* Bump django from 3.2.12 to 3.2.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/244
* Add community helm chart reference to readme by @pascaliske in https://github.com/sissbruecker/linkding/pull/242
* Feature: Shortcut key for new bookmark by @rithask in https://github.com/sissbruecker/linkding/pull/241
* Clarify archive.org feature by @clach04 in https://github.com/sissbruecker/linkding/pull/229
* Make Internet Archive integration opt-in by @sissbruecker in https://github.com/sissbruecker/linkding/pull/250
### New Contributors
* @pascaliske made their first contribution in https://github.com/sissbruecker/linkding/pull/242
* @rithask made their first contribution in https://github.com/sissbruecker/linkding/pull/241
* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
---
## v1.8.8 (27/03/2022)
- [**bug**] Prevent bookmark actions through get requests
- [**bug**] Prevent external redirects
---
## v1.8.7 (26/03/2022)
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
---
## v1.8.6 (25/03/2022)
- [bug] fix bookmark access restrictions
- [bug] prevent external redirects
- [chore] bump dependencies
---
## v1.8.5 (12/12/2021)
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
---
## v1.8.4 (16/10/2021)
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
---
## v1.8.3 (03/10/2021)
- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)
---
## v1.8.2 (02/10/2021)
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
---
## v1.8.1 (01/10/2021)
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
- allows to press `s` to focus the search input
---
## v1.8.0 (04/09/2021)
- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
- This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option
---
## v1.7.2 (26/08/2021)
- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)
---
## v1.7.1 (25/08/2021)
- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)
- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)
- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)
---
## v1.7.0 (17/08/2021)
- Upgrade to Django 3
- Bump other dependencies
---
## v1.6.5 (15/08/2021)
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
---
## v1.6.4 (13/05/2021)
- Update dependencies for security fixes
---
## v1.6.3 (06/04/2021)
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
---
## v1.6.2 (04/04/2021)
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
---
## v1.6.1 (31/03/2021)
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
---
## v1.6.0 (28/03/2021)
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101) - Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
--- ---
## v1.5.0 (28/03/2021) ## v1.5.0 (28/03/2021)
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49) - [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
--- ---
## v1.4.1 (20/03/2021) ## v1.4.1 (20/03/2021)
- Security patches
- Security patches
- Documentation improvements - Documentation improvements
--- ---
## v1.4.0 (24/02/2021) ## v1.4.0 (24/02/2021)
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76) - [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
--- ---
## v1.3.3 (18/02/2021) ## v1.3.3 (18/02/2021)
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78) - [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
--- ---
## v1.3.2 (18/02/2021) ## v1.3.2 (18/02/2021)
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77) - [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55) - [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
--- ---
## v1.3.1 (15/02/2021) ## v1.3.1 (15/02/2021)
[enhancement] Enhance delete links with inline confirmation [enhancement] Enhance delete links with inline confirmation
--- ---
## v1.3.0 (14/02/2021) ## v1.3.0 (14/02/2021)
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71) - [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70) - [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64) - [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
@@ -47,27 +288,34 @@
--- ---
## v1.2.1 (12/01/2021) ## v1.2.1 (12/01/2021)
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65) - [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11) - [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
--- ---
## v1.2.0 (09/01/2021) ## v1.2.0 (09/01/2021)
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58) - [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45) - [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
--- ---
## v1.1.1 (01/01/2021) ## v1.1.1 (01/01/2021)
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54) - [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
--- ---
## v1.1.0 (31/12/2020) ## v1.1.0 (31/12/2020)
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50) - [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
--- ---
## v1.0.0 (31/12/2020) ## v1.0.0 (31/12/2020)
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47) - [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26) - [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25) - [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
@@ -76,4 +324,4 @@
- [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18) - [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18)
- [**enhancement**] Enhancement: better administration page [#4](https://github.com/sissbruecker/linkding/issues/4) - [**enhancement**] Enhancement: better administration page [#4](https://github.com/sissbruecker/linkding/issues/4)
- [**enhancement**] Bug: Navigation bar active link stays on add bookmark [#3](https://github.com/sissbruecker/linkding/issues/3) - [**enhancement**] Bug: Navigation bar active link stays on add bookmark [#3](https://github.com/sissbruecker/linkding/issues/3)
- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2) - [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)

View File

@@ -9,7 +9,7 @@ COPY . .
RUN npm run build RUN npm run build
FROM python:3.9-slim AS python-base FROM python:3.9.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential RUN apt-get update && apt-get -y install build-essential
WORKDIR /etc/linkding WORKDIR /etc/linkding
@@ -33,7 +33,7 @@ RUN mkdir /opt/venv && \
/opt/venv/bin/pip install -Ur requirements.txt /opt/venv/bin/pip install -Ur requirements.txt
FROM python:3.9-slim as final FROM python:3.9.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support RUN apt-get update && apt-get -y install mime-support
WORKDIR /etc/linkding WORKDIR /etc/linkding
# copy prod dependencies # copy prod dependencies
@@ -47,6 +47,8 @@ EXPOSE 9090
# Activate virtual env # Activate virtual env
ENV VIRTUAL_ENV /opt/venv ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH ENV PATH /opt/venv/bin:$PATH
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
RUN ["chmod", "g+w", "."]
# Run bootstrap logic # Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"] RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"] CMD ["./bootstrap.sh"]

136
README.md
View File

@@ -1,29 +1,47 @@
# linkding <div align="center">
<br>
<a href="https://github.com/sissbruecker/linkding">
<img src="docs/header.svg" height="50">
</a>
<br>
</div>
*linkding* is a simple bookmark service that you can host yourself. ## Overview
It's designed be to be minimal, fast and easy to set up using Docker. - [Introduction](#introduction)
- [Installation](#installation)
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
- [Community](#community)
- [Development](#development)
## Introduction
linkding is a simple bookmark service that you can host yourself.
It's designed be to be minimal, fast, and easy to set up using Docker.
The name comes from: The name comes from:
- *link* which is often used as a synonym for URLs and bookmarks in common language - *link* which is often used as a synonym for URLs and bookmarks in common language
- *Ding* which is german for *thing* - *Ding* which is German for thing
- ...so basically some thing for managing your links - ...so basically something for managing your links
**Feature Overview:** **Feature Overview:**
- Tags for organizing bookmarks - Organize bookmarks with tags
- Search by text or tags - Read it later functionality
- Share bookmarks with other users
- Bulk editing - Bulk editing
- Bookmark archive - Bookmark archive
- Automatically provides titles and descriptions from linked websites - Automatically provides titles and descriptions of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format - Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe) - Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- Bookmarklet that should work in most browsers - Light and dark themes
- Dark mode
- Easy to set up using Docker
- Uses SQLite as database
- Works without Javascript
- ...but has several UI enhancements when Javascript is enabled
- REST API for developing 3rd party apps - REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access - Admin panel for user self-service and raw data access
- Easy setup using Docker, uses SQLite as database
**Demo:** https://demo.linkding.link/ (configured with open registration) **Demo:** https://demo.linkding.link/ (configured with open registration)
@@ -34,91 +52,91 @@ The name comes from:
## Installation ## Installation
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi. linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
There is also the option to set up the installation manually which I do not support, but can give some pointers on below. ### Using Docker
### Docker setup To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
```shell
To install linkding using Docker you can just run the image from the Docker registry:
```
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
``` ```
By default the application runs on port `9090`, but you can map it to a different host port by modifying the command above. By default, the application runs on port `9090`, you can map it to a different host port by modifying the port mapping in the command above. If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
However for **production use** you also want to mount a data folder on your system, so that the applications database is not stored in the container, but on your hosts file system. This is safer in case something happens to the container and makes it easier to update the container later on, or to run backups. To do so you can use the following extended command, where you replace `{host-data-folder}` with the absolute path to a folder on your system where you want to store the data: Note that the command above will store the linkding SQLite database in the container, which means that deleting the container, for example when upgrading the installation, will also remove the database. For hosting an actual installation you usually want to store the database on the host system, rather than in the container. To do so, run the following command, and replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database:
```shell ```shell
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
``` ```
If everything completed successfully the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping. To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
### Automated Docker setup To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
If you are using a Linux system you can use the following [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) for an automated setup. The script does basically everything described above, but also handles updating an existing container to a new application version (technically replaces the existing container with a new container built from a newer image, while mounting the same data folder). ### Using Docker Compose
The script can be configured using shell variables - for more details have a look at the script itself. To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
### Docker-compose setup
To install linkding using docker-compose you can use the `docker-compose.yml` file. Copy the `.env.sample` file to `.env` and set your parameters, then run:
```shell ```shell
docker-compose up -d docker-compose up -d
``` ```
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
### User setup ### User setup
Finally you need to create a user so that you can access the application. Replace the credentials in the following command and run it: For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
**Docker** **Docker**
```shell ```shell
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
``` ```
**Docker-compose** **Docker Compose**
```shell ```shell
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
``` ```
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials. The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
### Manual setup ### Managed Hosting Options
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready. Self-hosting web applications on your own hardware (unfortunately) still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting linkding, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a (usually monthly) fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
### Hosting - [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding)
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for: ## Documentation
- first get the app running (described in this document)
- open the port that the application is running on in your servers firewall
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
## Options | Document | Description |
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
Check the [options document](docs/Options.md) on how to configure your linkding installation. ## Browser Extension
## Backups linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
- [Mozilla Addon Store](https://addons.mozilla.org/de/firefox/addon/linkding-extension/)
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
Check the [backups document](docs/backup.md) for options on how to create backups. The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
## API ## Community
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information. This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
## Troubleshooting - [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
**Import fails with `502 Bad Gateway`** - [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error. - [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds. - [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.
## Development ## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂. The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.2/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites ### Prerequisites
- Python 3 - Python 3
@@ -160,7 +178,3 @@ Start the Django development server with:
python3 manage.py runserver python3 manage.py runserver
``` ```
The frontend is now available under http://localhost:8000 The frontend is now available under http://localhost:8000
## Community
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)

13
SECURITY.md Normal file
View File

@@ -0,0 +1,13 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.10.x | :white_check_mark: |
## Reporting a Vulnerability
To report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com
I'll try to get back to you as soon as possible.

Binary file not shown.

5
background-tasks-wrapper.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
python manage.py clean_tasks
exec python manage.py process_tasks

View File

@@ -1,3 +1,5 @@
from background_task.admin import TaskAdmin, CompletedTaskAdmin
from background_task.models import Task, CompletedTask
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
@@ -5,9 +7,9 @@ from django.contrib.auth.models import User
from django.db.models import Count, QuerySet from django.db.models import Count, QuerySet
from django.utils.translation import ngettext, gettext from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile, Toast, FeedToken
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -19,9 +21,9 @@ class LinkdingAdminSite(AdminSite):
class AdminBookmark(admin.ModelAdmin): class AdminBookmark(admin.ModelAdmin):
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added') list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name') search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
list_filter = ('owner__username', 'is_archived', 'tags',) list_filter = ('owner__username', 'is_archived', 'unread', 'tags',)
ordering = ('-date_added',) ordering = ('-date_added',)
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks'] actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks', 'mark_as_read', 'mark_as_unread']
def archive_selected_bookmarks(self, request, queryset: QuerySet): def archive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset: for bookmark in queryset:
@@ -43,6 +45,24 @@ class AdminBookmark(admin.ModelAdmin):
bookmarks_count, bookmarks_count,
) % bookmarks_count, messages.SUCCESS) ) % bookmarks_count, messages.SUCCESS)
def mark_as_read(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
queryset.update(unread=False)
self.message_user(request, ngettext(
'%d bookmark marked as read.',
'%d bookmarks marked as read.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
def mark_as_unread(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
queryset.update(unread=True)
self.message_user(request, ngettext(
'%d bookmark marked as unread.',
'%d bookmarks marked as unread.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
class AdminTag(admin.ModelAdmin): class AdminTag(admin.ModelAdmin):
list_display = ('name', 'bookmarks_count', 'owner', 'date_added') list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
@@ -59,6 +79,8 @@ class AdminTag(admin.ModelAdmin):
def bookmarks_count(self, obj): def bookmarks_count(self, obj):
return obj.bookmarks_count return obj.bookmarks_count
bookmarks_count.admin_order_field = 'bookmarks_count'
def delete_unused_tags(self, request, queryset: QuerySet): def delete_unused_tags(self, request, queryset: QuerySet):
unused_tags = queryset.filter(bookmark__isnull=True) unused_tags = queryset.filter(bookmark__isnull=True)
unused_tags_count = unused_tags.count() unused_tags_count = unused_tags.count()
@@ -93,8 +115,24 @@ class AdminCustomUser(UserAdmin):
return super(AdminCustomUser, self).get_inline_instances(request, obj) return super(AdminCustomUser, self).get_inline_instances(request, obj)
class AdminToast(admin.ModelAdmin):
list_display = ('key', 'message', 'owner', 'acknowledged')
search_fields = ('key', 'message')
list_filter = ('owner__username',)
class AdminFeedToken(admin.ModelAdmin):
list_display = ('key', 'user')
search_fields = ['key']
list_filter = ('user__username',)
linkding_admin_site = LinkdingAdminSite() linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(Token, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast)
linkding_admin_site.register(FeedToken, AdminFeedToken)
linkding_admin_site.register(Task, TaskAdmin)
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)

View File

@@ -6,7 +6,7 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata from bookmarks.services.website_loader import load_website_metadata
@@ -42,6 +42,16 @@ class BookmarkViewSet(viewsets.GenericViewSet,
data = serializer(page, many=True).data data = serializer(page, many=True).data
return self.get_paginated_response(data) return self.get_paginated_response(data)
@action(methods=['get'], detail=False)
def shared(self, request):
filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first()
query_set = queries.query_shared_bookmarks(user, filters.query)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).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, pk):
bookmark = self.get_object() bookmark = self.get_object()

View File

@@ -19,6 +19,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
'description', 'description',
'website_title', 'website_title',
'website_description', 'website_description',
'is_archived',
'unread',
'shared',
'tag_names', 'tag_names',
'date_added', 'date_added',
'date_modified' 'date_modified'
@@ -33,6 +36,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Override optional char fields to provide default value # Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='') title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='') description = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
shared = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update # Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[]) tag_names = TagListField(required=False, default=[])
@@ -41,14 +47,23 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url'] bookmark.url = validated_data['url']
bookmark.title = validated_data['title'] bookmark.title = validated_data['title']
bookmark.description = validated_data['description'] bookmark.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'], ' ') bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread']
bookmark.shared = validated_data['shared']
tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user']) return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data): def update(self, instance: Bookmark, validated_data):
instance.url = validated_data['url'] # Update fields if they were provided in the payload
instance.title = validated_data['title'] for key in ['url', 'title', 'description', 'unread', 'shared']:
instance.description = validated_data['description'] if key in validated_data:
tag_string = build_tag_string(validated_data['tag_names'], ' ') setattr(instance, key, validated_data[key])
# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if 'tag_names' in validated_data:
tag_string = build_tag_string(validated_data['tag_names'])
return update_bookmark(instance, tag_string, self.context['user']) return update_bookmark(instance, tag_string, self.context['user'])

View File

@@ -3,3 +3,7 @@ from django.apps import AppConfig
class BookmarksConfig(AppConfig): class BookmarksConfig(AppConfig):
name = 'bookmarks' name = 'bookmarks'
def ready(self):
# Register signal handlers
import bookmarks.signals

View File

@@ -8,8 +8,9 @@
export let placeholder; export let placeholder;
export let value; export let value;
export let tags; export let tags;
export let mode = 'default'; export let mode = '';
export let apiClient; export let apiClient;
export let filters;
let isFocus = false; let isFocus = false;
let isOpen = false; let isOpen = false;
@@ -112,9 +113,12 @@
let bookmarks = [] let bookmarks = []
if (value && value.length >= 3) { if (value && value.length >= 3) {
const fetchedBookmarks = mode === 'archive' const path = mode ? `/${mode}` : ''
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0}) const suggestionFilters = {
: await apiClient.getBookmarks(value, {limit: 5, offset: 0}) ...filters,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => { bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60) const label = clampText(fullLabel, 60)

View File

@@ -11,6 +11,7 @@
let isFocus = false; let isFocus = false;
let isOpen = false; let isOpen = false;
let input = null; let input = null;
let suggestionList = null;
let suggestions = []; let suggestions = [];
let selectedIndex = 0; let selectedIndex = 0;
@@ -86,7 +87,7 @@
function complete(suggestion) { function complete(suggestion) {
const bounds = getCurrentWordBounds(input); const bounds = getCurrentWordBounds(input);
const value = input.value; const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end); input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close(); close();
} }
@@ -100,6 +101,16 @@
if (newIndex >= length) newIndex = 0; if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex; selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
} }
</script> </script>
@@ -114,7 +125,8 @@
</div> </div>
<!-- autocomplete suggestion list --> <!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}> <ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items --> <!-- menu list items -->
{#each suggestions as tag,i} {#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}> <li class="menu-item" class:selected={selectedIndex === i}>

View File

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

View File

@@ -0,0 +1,12 @@
from bookmarks.models import Toast
def toasts(request):
user = request.user if hasattr(request, 'user') else None
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
has_toasts = len(toast_messages) > 0
return {
'has_toasts': has_toasts,
'toast_messages': toast_messages,
}

56
bookmarks/feeds.py Normal file
View File

@@ -0,0 +1,56 @@
from dataclasses import dataclass
from django.contrib.syndication.views import Feed
from django.db.models import QuerySet
from django.urls import reverse
from bookmarks.models import Bookmark, FeedToken
from bookmarks import queries
@dataclass
class FeedContext:
feed_token: FeedToken
query_set: QuerySet[Bookmark]
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
query_string = request.GET.get('q')
query_set = queries.query_bookmarks(feed_token.user, query_string)
return FeedContext(feed_token, query_set)
def item_title(self, item: Bookmark):
return item.resolved_title
def item_description(self, item: Bookmark):
return item.resolved_description
def item_link(self, item: Bookmark):
return item.url
def item_pubdate(self, item: Bookmark):
return item.date_added
class AllBookmarksFeed(BaseBookmarksFeed):
title = 'All bookmarks'
description = 'All bookmarks'
def link(self, context: FeedContext):
return reverse('bookmarks:feeds.all', args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class UnreadBookmarksFeed(BaseBookmarksFeed):
title = 'Unread bookmarks'
description = 'All unread bookmarks'
def link(self, context: FeedContext):
return reverse('bookmarks:feeds.unread', args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)

View File

@@ -0,0 +1,15 @@
from background_task.models import Task, CompletedTask
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Remove task locks and clear completed task history"
def handle(self, *args, **options):
# Remove task locks
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
# even though no process is working on them, and would prevent the task processor from picking the next task in
# the queue
Task.objects.all().update(locked_by=None, locked_at=None)
# Clear task history to prevent them from bloating the DB
CompletedTask.objects.all().delete()

6
bookmarks/middlewares.py Normal file
View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.20 on 2021-05-16 14:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0008_userprofile_bookmark_date_display'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='web_archive_snapshot_url',
field=models.CharField(blank=True, max_length=2048),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.6 on 2021-10-03 06:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0009_bookmark_web_archive_snapshot_url'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='bookmark_link_target',
field=models.CharField(choices=[('_blank', 'New page'), ('_self', 'Same page')], default='_blank', max_length=10),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.6 on 2022-01-08 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0010_userprofile_bookmark_link_target'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='web_archive_integration',
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.2.6 on 2022-01-08 19:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('bookmarks', '0011_userprofile_web_archive_integration'),
]
operations = [
migrations.CreateModel(
name='Toast',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=50)),
('message', models.TextField()),
('acknowledged', models.BooleanField(default=False)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.2.6 on 2022-01-08 19:27
from django.db import migrations
from django.contrib.auth import get_user_model
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(key='web_archive_opt_in_hint',
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
owner=user)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key='web_archive_opt_in_hint').delete()
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0012_toast'),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.2.13 on 2022-07-23 12:30
from django.db import migrations, models
def forwards(apps, schema_editor):
Bookmark = apps.get_model('bookmarks', 'Bookmark')
Bookmark.objects.update(unread=False)
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0013_web_archive_optin_toast'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='unread',
field=models.BooleanField(default=False),
),
migrations.RunPython(forwards, reverse),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.2.13 on 2022-07-23 20:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('bookmarks', '0014_alter_bookmark_unread'),
]
operations = [
migrations.CreateModel(
name='FeedToken',
fields=[
('key', models.CharField(max_length=40, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_token', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-08-02 18:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0015_feedtoken'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='shared',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-08-04 09:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0016_bookmark_shared'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='enable_sharing',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,8 +1,11 @@
import binascii
import os
from typing import List from typing import List
from django import forms from django import forms
from django.contrib.auth import get_user_model 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.handlers.wsgi import WSGIRequest
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
@@ -20,11 +23,19 @@ class Tag(models.Model):
return self.name return self.name
def sanitize_tag_name(tag_name: str):
# strip leading/trailing spaces
# replace inner spaces with replacement char
return tag_name.strip().replace(' ', '-')
def parse_tag_string(tag_string: str, delimiter: str = ','): def parse_tag_string(tag_string: str, delimiter: str = ','):
if not tag_string: if not tag_string:
return [] return []
names = tag_string.strip().split(delimiter) names = tag_string.strip().split(delimiter)
names = [name.strip() for name in names if name] # remove empty names, sanitize remaining names
names = [sanitize_tag_name(name) for name in names if name]
# remove duplicates
names = unique(names, str.lower) names = unique(names, str.lower)
names.sort(key=str.lower) names.sort(key=str.lower)
@@ -41,8 +52,10 @@ class Bookmark(models.Model):
description = models.TextField(blank=True) description = models.TextField(blank=True)
website_title = models.CharField(max_length=512, blank=True, null=True) website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True) website_description = models.TextField(blank=True, null=True)
unread = models.BooleanField(default=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
shared = models.BooleanField(default=False)
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)
@@ -88,14 +101,35 @@ class BookmarkForm(forms.ModelForm):
required=False) required=False)
description = forms.CharField(required=False, description = forms.CharField(required=False,
widget=forms.Textarea()) widget=forms.Textarea())
# Include website title and description as hidden field as they only provide info when editing bookmarks
website_title = forms.CharField(max_length=512,
required=False, widget=forms.HiddenInput())
website_description = forms.CharField(required=False,
widget=forms.HiddenInput())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark # Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False) auto_close = forms.CharField(required=False)
# Hidden field that determines where to redirect after saving the form
return_url = forms.CharField(required=False)
class Meta: class Meta:
model = Bookmark model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url'] fields = [
'url',
'tag_string',
'title',
'description',
'website_title',
'website_description',
'unread',
'shared',
'auto_close',
]
class BookmarkFilters:
def __init__(self, request: WSGIRequest):
self.query = request.GET.get('q') or ''
self.user = request.GET.get('user') or ''
class UserProfile(models.Model): class UserProfile(models.Model):
@@ -115,16 +149,33 @@ class UserProfile(models.Model):
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'), (BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'), (BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
] ]
BOOKMARK_LINK_TARGET_BLANK = '_blank'
BOOKMARK_LINK_TARGET_SELF = '_self'
BOOKMARK_LINK_TARGET_CHOICES = [
(BOOKMARK_LINK_TARGET_BLANK, 'New page'),
(BOOKMARK_LINK_TARGET_SELF, 'Same page'),
]
WEB_ARCHIVE_INTEGRATION_DISABLED = 'disabled'
WEB_ARCHIVE_INTEGRATION_ENABLED = 'enabled'
WEB_ARCHIVE_INTEGRATION_CHOICES = [
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
]
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE) user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO) theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False, bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
default=BOOKMARK_DATE_DISPLAY_RELATIVE) default=BOOKMARK_DATE_DISPLAY_RELATIVE)
bookmark_link_target = models.CharField(max_length=10, choices=BOOKMARK_LINK_TARGET_CHOICES, blank=False,
default=BOOKMARK_LINK_TARGET_BLANK)
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = ['theme', 'bookmark_date_display'] fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing']
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=get_user_model())
@@ -136,3 +187,34 @@ def create_user_profile(sender, instance, created, **kwargs):
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=get_user_model())
def save_user_profile(sender, instance, **kwargs): def save_user_profile(sender, instance, **kwargs):
instance.profile.save() instance.profile.save()
class Toast(models.Model):
key = models.CharField(max_length=50)
message = models.TextField()
acknowledged = models.BooleanField(default=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
class FeedToken(models.Model):
"""
Adapted from authtoken.models.Token
"""
key = models.CharField(max_length=40, primary_key=True)
user = models.OneToOneField(get_user_model(),
related_name='feed_token',
on_delete=models.CASCADE,
)
created = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
return super().save(*args, **kwargs)
@classmethod
def generate_key(cls):
return binascii.hexlify(os.urandom(20)).decode()
def __str__(self):
return self.key

View File

@@ -1,3 +1,5 @@
from typing import Optional
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet
@@ -27,7 +29,13 @@ def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
.filter(is_archived=True) .filter(is_archived=True)
def _base_bookmarks_query(user: User, query_string: str) -> QuerySet: def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(shared=True) \
.filter(owner__profile__enable_sharing=True)
def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
# Add aggregated tag info to bookmark instances # Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \ query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'), .annotate(tag_count=Count('tags'),
@@ -35,10 +43,11 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
tag_projection=Value(True, BooleanField())) tag_projection=Value(True, BooleanField()))
# Filter for user # Filter for user
query_set = query_set.filter(owner=user) if user:
query_set = query_set.filter(owner=user)
# Split query into search terms and tags # Split query into search terms and tags
query = _parse_query_string(query_string) query = parse_query_string(query_string)
# Filter for search terms and tags # Filter for search terms and tags
for term in query['search_terms']: for term in query['search_terms']:
@@ -55,6 +64,17 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
tags__name__iexact=tag_name tags__name__iexact=tag_name
) )
# Untagged bookmarks
if query['untagged']:
query_set = query_set.filter(
tags=None
)
# Unread bookmarks
if query['unread']:
query_set = query_set.filter(
unread=True
)
# Sort by date added # Sort by date added
query_set = query_set.order_by('-date_added') query_set = query_set.order_by('-date_added')
@@ -62,43 +82,33 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
def query_bookmark_tags(user: User, query_string: str) -> QuerySet: def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \ bookmarks_query = query_bookmarks(user, query_string)
.filter(bookmark__is_archived=False) \
.distinct() query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet: def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \ bookmarks_query = query_archived_bookmarks(user, query_string)
.filter(bookmark__is_archived=True) \
.distinct() query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet: def query_shared_bookmark_tags(user: Optional[User], query_string: str) -> QuerySet:
query_set = Tag.objects bookmarks_query = query_shared_bookmarks(user, query_string)
# Filter for user query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
query_set = query_set.filter(owner=user)
# Only show tags which have bookmarks return query_set.distinct()
query_set = query_set.filter(bookmark__isnull=False)
# Split query into search terms and tags
query = _parse_query_string(query_string)
# Filter for search terms and tags def query_shared_bookmark_users(query_string: str) -> QuerySet:
for term in query['search_terms']: bookmarks_query = query_shared_bookmarks(None, query_string)
query_set = query_set.filter(
Q(bookmark__title__contains=term)
| Q(bookmark__description__contains=term)
| Q(bookmark__website_title__contains=term)
| Q(bookmark__website_description__contains=term)
| Q(bookmark__url__contains=term)
)
for tag_name in query['tag_names']: query_set = User.objects.filter(bookmark__in=bookmarks_query)
query_set = query_set.filter(
bookmark__tags__name__iexact=tag_name
)
return query_set.distinct() return query_set.distinct()
@@ -107,7 +117,7 @@ def get_user_tags(user: User):
return Tag.objects.filter(owner=user).all() return Tag.objects.filter(owner=user).all()
def _parse_query_string(query_string): def parse_query_string(query_string):
# Sanitize query params # Sanitize query params
if not query_string: if not query_string:
query_string = '' query_string = ''
@@ -116,11 +126,17 @@ def _parse_query_string(query_string):
keywords = query_string.strip().split(' ') keywords = query_string.strip().split(' ')
keywords = [word for word in keywords if word] keywords = [word for word in keywords if word]
search_terms = [word for word in keywords if word[0] != '#'] search_terms = [word for word in keywords if word[0] != '#' and word[0] != '!']
tag_names = [word[1:] for word in keywords if word[0] == '#'] tag_names = [word[1:] for word in keywords if word[0] == '#']
tag_names = unique(tag_names, str.lower) tag_names = unique(tag_names, str.lower)
# Special search commands
untagged = '!untagged' in keywords
unread = '!unread' in keywords
return { return {
'search_terms': search_terms, 'search_terms': search_terms,
'tag_names': tag_names, 'tag_names': tag_names,
'untagged': untagged,
'unread': unread,
} }

View File

@@ -5,7 +5,8 @@ from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services.tags import get_or_create_tags from bookmarks.services.tags import get_or_create_tags
from bookmarks.services.website_loader import load_website_metadata from bookmarks.services import website_loader
from bookmarks.services import tasks
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
@@ -27,17 +28,27 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
# Update tag list # Update tag list
_update_bookmark_tags(bookmark, tag_string, current_user) _update_bookmark_tags(bookmark, tag_string, current_user)
bookmark.save() bookmark.save()
# Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False)
return bookmark return bookmark
def update_bookmark(bookmark: Bookmark, tag_string, current_user: User): def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update website info # Detect URL change
_update_website_metadata(bookmark) original_bookmark = Bookmark.objects.get(id=bookmark.id)
has_url_changed = original_bookmark.url != bookmark.url
# Update tag list # Update tag list
_update_bookmark_tags(bookmark, tag_string, current_user) _update_bookmark_tags(bookmark, tag_string, current_user)
# Update dates # Update dates
bookmark.date_modified = timezone.now() bookmark.date_modified = timezone.now()
bookmark.save() bookmark.save()
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed
_update_website_metadata(bookmark)
return bookmark return bookmark
@@ -79,7 +90,7 @@ def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User): def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
tag_names = parse_tag_string(tag_string, ' ') tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, current_user) tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks: for bookmark in bookmarks:
@@ -92,7 +103,7 @@ def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User): def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
tag_names = parse_tag_string(tag_string, ' ') tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, current_user) tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks: for bookmark in bookmarks:
@@ -105,16 +116,18 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description
to_bookmark.unread = from_bookmark.unread
to_bookmark.shared = from_bookmark.shared
def _update_website_metadata(bookmark: Bookmark): def _update_website_metadata(bookmark: Bookmark):
metadata = load_website_metadata(bookmark.url) metadata = website_loader.load_website_metadata(bookmark.url)
bookmark.website_title = metadata.title bookmark.website_title = metadata.title
bookmark.website_description = metadata.description bookmark.website_description = metadata.description
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User): def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string, ' ') tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, user) tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags) bookmark.tags.set(tags)

View File

@@ -1,12 +1,14 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string from bookmarks.models import Bookmark, Tag, parse_tag_string
from bookmarks.services import tasks
from bookmarks.services.parser import parse, NetscapeBookmark from bookmarks.services.parser import parse, NetscapeBookmark
from bookmarks.services.tags import get_or_create_tags from bookmarks.utils import parse_timestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,8 +20,39 @@ class ImportResult:
failed: int = 0 failed: int = 0
class TagCache:
def __init__(self, user: User):
self.user = user
self.cache = dict()
# Init cache with all existing tags for that user
tags = Tag.objects.filter(owner=user)
for tag in tags:
self.put(tag)
def get(self, tag_name: str):
tag_name_lowercase = tag_name.lower()
if tag_name_lowercase in self.cache:
return self.cache[tag_name_lowercase]
else:
return None
def get_all(self, tag_names: List[str]):
result = []
for tag_name in tag_names:
tag = self.get(tag_name)
# Prevent returning duplicates
if not (tag in result):
result.append(tag)
return result
def put(self, tag: Tag):
self.cache[tag.name.lower()] = tag
def import_netscape_html(html: str, user: User): def import_netscape_html(html: str, user: User):
result = ImportResult() result = ImportResult()
import_start = timezone.now()
try: try:
netscape_bookmarks = parse(html) netscape_bookmarks = parse(html)
@@ -27,44 +60,140 @@ def import_netscape_html(html: str, user: User):
logging.exception('Could not read bookmarks file.') logging.exception('Could not read bookmarks file.')
raise raise
parse_end = timezone.now()
logger.debug(f'Parse duration: {parse_end - import_start}')
# Create and cache all tags beforehand
_create_missing_tags(netscape_bookmarks, user)
tag_cache = TagCache(user)
# Split bookmarks to import into batches, to keep memory usage for bulk operations manageable
batches = _get_batches(netscape_bookmarks, 200)
for batch in batches:
_import_batch(batch, user, tag_cache, result)
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user)
end = timezone.now()
logger.debug(f'Import duration: {end - import_start}')
return result
def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User):
tag_cache = TagCache(user)
tags_to_create = []
for netscape_bookmark in netscape_bookmarks:
tag_names = parse_tag_string(netscape_bookmark.tag_string)
for tag_name in tag_names:
tag = tag_cache.get(tag_name)
if not tag:
tag = Tag(name=tag_name, owner=user)
tag.date_added = timezone.now()
tags_to_create.append(tag)
tag_cache.put(tag)
Tag.objects.bulk_create(tags_to_create)
def _get_batches(items: List, batch_size: int):
batches = []
offset = 0
num_items = len(items)
while offset < num_items:
batch = items[offset:min(offset + batch_size, num_items)]
if len(batch) > 0:
batches.append(batch)
offset = offset + batch_size
return batches
def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_cache: TagCache, result: ImportResult):
# Query existing bookmarks
batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
# Create or update bookmarks from parsed Netscape bookmarks
bookmarks_to_create = []
bookmarks_to_update = []
for netscape_bookmark in netscape_bookmarks: for netscape_bookmark in netscape_bookmarks:
result.total = result.total + 1 result.total = result.total + 1
try: try:
_import_bookmark_tag(netscape_bookmark, user) # Lookup existing bookmark by URL, or create new bookmark if there is no bookmark for that URL yet
bookmark = next(
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
if not bookmark:
bookmark = Bookmark(owner=user)
is_update = False
else:
is_update = True
# Copy data from parsed bookmark
_copy_bookmark_data(netscape_bookmark, bookmark)
# Validate bookmark fields, exclude owner to prevent n+1 database query,
# also there is no specific validation on owner
bookmark.clean_fields(exclude=['owner'])
# Schedule for update or insert
if is_update:
bookmarks_to_update.append(bookmark)
else:
bookmarks_to_create.append(bookmark)
result.success = result.success + 1 result.success = result.success + 1
except: except:
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...' shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str) logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
result.failed = result.failed + 1 result.failed = result.failed + 1
return result # Bulk update bookmarks in DB
Bookmark.objects.bulk_update(bookmarks_to_update,
['url', 'date_added', 'date_modified', 'unread', 'title', 'description', 'owner'])
# Bulk insert new bookmarks into DB
Bookmark.objects.bulk_create(bookmarks_to_create)
# Bulk assign tags
# In Django 3, bulk_create does not return the auto-generated IDs when bulk inserting,
# so we have to reload the inserted bookmarks, and match them to the parsed bookmarks by URL
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
BookmarkToTagRelationShip = Bookmark.tags.through
relationships = []
for netscape_bookmark in netscape_bookmarks:
# Lookup bookmark by URL again
bookmark = next(
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
if not bookmark:
# Something is wrong, we should have just created this bookmark
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
logging.warning(
f'Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL.')
continue
# Get tag models by string, schedule inserts for bookmark -> tag associations
tag_names = parse_tag_string(netscape_bookmark.tag_string)
tags = tag_cache.get_all(tag_names)
for tag in tags:
relationships.append(BookmarkToTagRelationShip(bookmark=bookmark, tag=tag))
# Insert all bookmark -> tag associations at once, should ignore errors if association already exists
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User): def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark):
# Either modify existing bookmark for the URL or create new one
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
bookmark.url = netscape_bookmark.href bookmark.url = netscape_bookmark.href
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone() if netscape_bookmark.date_added:
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added bookmark.date_modified = bookmark.date_added
bookmark.unread = False bookmark.unread = netscape_bookmark.to_read
bookmark.title = netscape_bookmark.title if netscape_bookmark.title:
bookmark.title = netscape_bookmark.title
if netscape_bookmark.description: if netscape_bookmark.description:
bookmark.description = netscape_bookmark.description bookmark.description = netscape_bookmark.description
bookmark.owner = user
bookmark.save()
# Set tags
tag_names = parse_tag_string(netscape_bookmark.tag_string)
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)
bookmark.save()
def _get_or_create_bookmark(url: str, user: User):
try:
return Bookmark.objects.get(url=url, owner=user)
except Bookmark.DoesNotExist:
return Bookmark()

View File

@@ -1,7 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from html.parser import HTMLParser
from typing import Dict, List
import pyparsing as pp
@dataclass @dataclass
@@ -9,65 +8,80 @@ class NetscapeBookmark:
href: str href: str
title: str title: str
description: str description: str
date_added: int date_added: str
tag_string: str tag_string: str
to_read: bool
def extract_bookmark_link(tag): class BookmarkParser(HTMLParser):
href = tag[0].href def __init__(self):
title = tag[0].text super().__init__()
tag_string = tag[0].tags self.bookmarks = []
date_added_string = tag[0].add_date if tag[0].add_date else datetime.now().timestamp()
date_added = int(date_added_string)
return { self.current_tag = None
'href': href, self.bookmark = None
'title': title, self.href = ''
'tag_string': tag_string, self.add_date = ''
'date_added': date_added self.tags = ''
} self.title = ''
self.description = ''
self.toread = ''
def handle_starttag(self, tag: str, attrs: list):
name = 'handle_start_' + tag.lower()
if name in dir(self):
getattr(self, name)({k.lower(): v for k, v in attrs})
self.current_tag = tag
def extract_bookmark(tag): def handle_endtag(self, tag: str):
link = tag[0].link name = 'handle_end_' + tag.lower()
description = tag[0].description if name in dir(self):
description = description[0] if description else '' getattr(self, name)()
self.current_tag = None
return { def handle_data(self, data):
'link': link, name = f'handle_{self.current_tag}_data'
'description': description, if name in dir(self):
} getattr(self, name)(data)
def handle_end_dl(self):
self.add_bookmark()
def extract_description(tag): def handle_start_dt(self, attrs: Dict[str, str]):
return tag[0].strip() self.add_bookmark()
def handle_start_a(self, attrs: Dict[str, str]):
# define grammar vars(self).update(attrs)
dt_start, _ = pp.makeHTMLTags("DT") self.bookmark = NetscapeBookmark(
dd_start, _ = pp.makeHTMLTags("DD") href=self.href,
a_start, a_end = pp.makeHTMLTags("A") title='',
bookmark_link_tag = pp.Group(a_start + a_start.tag_body("text") + a_end.suppress()) description='',
bookmark_link_tag.addParseAction(extract_bookmark_link) date_added=self.add_date,
bookmark_description_tag = dd_start.suppress() + pp.SkipTo(pp.anyOpenTag | pp.anyCloseTag)("description") tag_string=self.tags,
bookmark_description_tag.addParseAction(extract_description) to_read=self.toread == '1'
bookmark_tag = pp.Group(dt_start + bookmark_link_tag("link") + pp.ZeroOrMore(bookmark_description_tag)("description"))
bookmark_tag.addParseAction(extract_bookmark)
def parse(html: str) -> [NetscapeBookmark]:
matches = bookmark_tag.searchString(html)
bookmarks = []
for match in matches:
bookmark_match = match[0]
bookmark = NetscapeBookmark(
href=bookmark_match['link']['href'],
title=bookmark_match['link']['title'],
description=bookmark_match['description'],
tag_string=bookmark_match['link']['tag_string'],
date_added=bookmark_match['link']['date_added'],
) )
bookmarks.append(bookmark)
return bookmarks def handle_a_data(self, data):
self.title = data.strip()
def handle_dd_data(self, data):
self.description = data.strip()
def add_bookmark(self):
if self.bookmark:
self.bookmark.title = self.title
self.bookmark.description = self.description
self.bookmarks.append(self.bookmark)
self.bookmark = None
self.href = ''
self.add_date = ''
self.tags = ''
self.title = ''
self.description = ''
self.toread = ''
def parse(html: str) -> List[NetscapeBookmark]:
parser = BookmarkParser()
parser.feed(html)
return parser.bookmarks

View File

@@ -0,0 +1,66 @@
import logging
import waybackpy
from background_task import background
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from waybackpy.exceptions import WaybackError
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__)
def is_web_archive_integration_active(user: User) -> bool:
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
web_archive_integration_enabled = \
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
return background_tasks_enabled and web_archive_integration_enabled
def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):
if is_web_archive_integration_active(user):
_create_web_archive_snapshot_task(bookmark.id, force_update)
@background()
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
# Skip if snapshot exists and update is not explicitly requested
if bookmark.web_archive_snapshot_url and not force_update:
return
logger.debug(f'Create web archive link for bookmark: {bookmark}...')
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT)
try:
archive.save()
except WaybackError as error:
logger.exception(f'Error creating web archive link for bookmark: {bookmark}...', exc_info=error)
raise
bookmark.web_archive_snapshot_url = archive.archive_url
bookmark.save()
logger.debug(f'Successfully created web archive link for bookmark: {bookmark}...')
def schedule_bookmarks_without_snapshots(user: User):
if is_web_archive_integration_active(user):
_schedule_bookmarks_without_snapshots_task(user.id)
@background()
def _schedule_bookmarks_without_snapshots_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
for bookmark in bookmarks_without_snapshots:
_create_web_archive_snapshot_task(bookmark.id, False)

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from charset_normalizer import from_bytes
@dataclass @dataclass
@@ -33,5 +34,25 @@ def load_website_metadata(url: str):
def load_page(url: str): def load_page(url: str):
r = requests.get(url) headers = fake_request_headers()
return r.text r = requests.get(url, timeout=10, headers=headers)
# Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
# This is different from Response.text which does respect the encoding specified in the response first,
# before trying to determine one
results = from_bytes(r.content)
return str(results.best())
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36'
def fake_request_headers():
return {
"Accept": "text/html,application/xhtml+xml,application/xml",
"Accept-Encoding": "gzip, deflate",
"Dnt": "1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": DEFAULT_USER_AGENT,
}

8
bookmarks/signals.py Normal file
View File

@@ -0,0 +1,8 @@
from django.contrib.auth import user_logged_in
from django.dispatch import receiver
from bookmarks.services import tasks
@receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs):
tasks.schedule_bookmarks_without_snapshots(user)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

@@ -1,86 +0,0 @@
(function () {
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
function isAllSelected() {
let result = true
singleToggles.forEach(function (toggle) {
result = result && toggle.checked
})
return result
}
function selectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = true
})
}
function deselectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = false
})
}
// Toggle all
allToggle.addEventListener('change', function (e) {
if (e.target.checked) {
selectAll()
} else {
deselectAll()
}
})
// Toggle single
singleToggles.forEach(function (toggle) {
toggle.addEventListener('change', function () {
allToggle.checked = isAllSelected()
})
})
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
let bulkEditToggleTimeout
if (bulkEditToggle.checked) {
bulkEditBar.style.overflow = 'visible';
}
bulkEditToggle.addEventListener('change', function (e) {
if (bulkEditToggleTimeout) {
clearTimeout(bulkEditToggleTimeout);
bulkEditToggleTimeout = null;
}
if (e.target.checked) {
bulkEditToggleTimeout = setTimeout(function () {
bulkEditBar.style.overflow = 'visible';
}, 500);
} else {
bulkEditBar.style.overflow = 'hidden';
}
});
// Init tag auto-complete
function initTagAutoComplete() {
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
const apiClient = new linkding.ApiClient(apiBaseUrl)
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: 'bulk-edit-tags-input',
name: tagInput.name,
value: tagInput.value,
apiClient: apiClient,
variant: 'small'
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
}
initTagAutoComplete();
})()

View File

@@ -19,6 +19,7 @@
if (buttonEl.nodeName === 'BUTTON') { if (buttonEl.nodeName === 'BUTTON') {
confirmEl.type = buttonEl.type; confirmEl.type = buttonEl.type;
confirmEl.name = buttonEl.name; confirmEl.name = buttonEl.name;
confirmEl.value = buttonEl.value;
} }
if (buttonEl.nodeName === 'A') { if (buttonEl.nodeName === 'A') {
confirmEl.href = buttonEl.href; confirmEl.href = buttonEl.href;
@@ -40,6 +41,43 @@
}); });
} }
function initGlobalShortcuts() {
// Focus search button
document.addEventListener('keydown', function (event) {
// Filter for shortcut key
if (event.key !== 's') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
initConfirmationButtons() if (isInputTarget) return;
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
});
// Add new bookmark
document.addEventListener('keydown', function(event) {
// Filter for new entry shortcut key
if (event.key !== 'n') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
window.location.assign("/bookmarks/new");
});
}
initConfirmationButtons();
initGlobalShortcuts();
})() })()

View File

@@ -11,6 +11,18 @@ header {
margin-bottom: 40px; margin-bottom: 40px;
} }
header .toasts {
margin-bottom: 20px;
.toast {
margin-bottom: 0.4rem;
}
.toast a.btn-clear:visited {
color: currentColor;
}
}
.navbar { .navbar {
.navbar-brand { .navbar-brand {

View File

@@ -1,14 +1,16 @@
.bookmarks-page .search { .bookmarks-page .search {
$searchbox-width: 180px;
$searchbox-width-md: 300px;
$searchbox-height: 1.8rem; $searchbox-height: 1.8rem;
// Regular input // Regular input
input[type='search'] { input[type='search'] {
width: 180px; width: $searchbox-width;
height: $searchbox-height; height: $searchbox-height;
-webkit-appearance: none; -webkit-appearance: none;
@media (min-width: $control-width-md) { @media (min-width: $control-width-md) {
width: 300px; width: $searchbox-width-md;
} }
} }
@@ -18,14 +20,19 @@
height: $searchbox-height; height: $searchbox-height;
.form-autocomplete-input { .form-autocomplete-input {
width: $searchbox-width;
height: $searchbox-height; height: $searchbox-height;
width: 100%;
input[type='search'] { input[type='search'] {
width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
border: none; border: none;
} }
@media (min-width: $control-width-md) {
width: $searchbox-width-md;
}
} }
} }
} }
@@ -42,6 +49,15 @@ ul.bookmark-list {
margin: 0; margin: 0;
padding: 0; padding: 0;
.title a {
display: inline-block;
vertical-align: top;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.description { .description {
color: $gray-color-dark; color: $gray-color-dark;
@@ -54,6 +70,10 @@ ul.bookmark-list {
margin-right: 0.1rem; margin-right: 0.1rem;
} }
.actions .date-label a {
color: $gray-color;
}
.actions .btn-link { .actions .btn-link {
color: $gray-color; color: $gray-color;
padding: 0; padding: 0;
@@ -80,8 +100,18 @@ ul.bookmark-list {
.tag-cloud { .tag-cloud {
a, a:visited:hover { .selected-tags {
color: $alternative-color; margin-bottom: 0.8rem;
a, a:visited:hover {
color: $error-color;
}
}
.unselected-tags {
a, a:visited:hover {
color: $alternative-color;
}
} }
.group { .group {
@@ -97,12 +127,41 @@ ul.bookmark-list {
.bookmarks-form { .bookmarks-form {
.btn.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
color: $gray-color;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
> svg {
width: 20px;
height: 20px;
}
}
.has-icon-right > input, .has-icon-right > textarea {
padding-right: 30px;
}
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
.form-icon.loading { .form-icon.loading {
visibility: hidden; visibility: hidden;
} }
.form-input-hint.bookmark-exists { .form-input-hint.bookmark-exists {
visibility: hidden; display: none;
color: $warning-color; color: $warning-color;
a { a {
@@ -113,13 +172,13 @@ ul.bookmark-list {
} }
} }
/* Bulk edit */ /* Bookmark actions / bulk edit */
$bulk-edit-toggle-width: 16px; $bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px; $bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset); $bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms; $bulk-edit-transition-duration: 400ms;
.bulk-edit-form { .bookmarks-page form.bookmark-actions {
.bulk-edit-bar { .bulk-edit-bar {
margin-top: -17px; margin-top: -17px;
@@ -163,6 +222,7 @@ $bulk-edit-transition-duration: 400ms;
span.confirmation { span.confirmation {
display: flex; display: flex;
} }
span.confirmation button { span.confirmation button {
padding: 0; padding: 0;
} }

View File

@@ -11,4 +11,8 @@
.input-group > input[type=submit] { .input-group > input[type=submit] {
height: auto; height: auto;
} }
section.about table {
max-width: 500px;
}
} }

View File

@@ -15,3 +15,7 @@
.text-gray-dark { .text-gray-dark {
color: $gray-color-dark; color: $gray-color-dark;
} }
.align-baseline {
align-items: baseline;
}

View File

@@ -14,11 +14,11 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search query tags mode='archive' %} {% bookmark_search filters tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %} {% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
@@ -26,7 +26,7 @@
{% if empty %} {% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
{% bookmark_list bookmarks return_url %} {% bookmark_list bookmarks return_url link_target %}
{% endif %} {% endif %}
</form> </form>
</section> </section>
@@ -36,11 +36,11 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags %} {% tag_cloud tags selected_tags %}
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script> <script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script> <script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View File

@@ -3,19 +3,22 @@
<ul class="bookmark-list"> <ul class="bookmark-list">
{% for bookmark in bookmarks %} {% for bookmark in bookmarks %}
<li> <li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle"> <label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}"> <input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
<div class="title truncate"> <div class="title">
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a> <a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{{ bookmark.resolved_title }}
</a>
</div> </div>
<div class="description truncate"> <div class="description truncate">
{% if bookmark.tag_names %} {% if bookmark.tag_names %}
<span> <span>
{% for tag_name in bookmark.tag_names %} {% for tag_name in bookmark.tag_names %}
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a> <a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
@@ -27,24 +30,57 @@
</div> </div>
<div class="actions"> <div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %} {% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_relative_date }}</span> <span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span> <span class="text-gray text-sm">|</span>
{% endif %} {% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %} {% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_absolute_date }}</span> <span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span> <span class="text-gray text-sm">|</span>
{% endif %} {% endif %}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}" {% if bookmark.owner == request.user %}
class="btn btn-link btn-sm">Edit</a> {# Bookmark owner actions #}
{% if bookmark.is_archived %} <a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}" class="btn btn-link btn-sm">Edit</a>
class="btn btn-link btn-sm">Unarchive</a> {% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive</button>
{% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
{% if bookmark.unread %}
<span class="text-gray text-sm">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read</button>
{% endif %}
{% else %} {% else %}
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}" {# Shared bookmark actions #}
class="btn btn-link btn-sm">Archive</a> <span class="text-gray text-sm">Shared by
<a class="text-gray" href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
{% endif %} {% endif %}
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm btn-confirmation">Remove</a>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}

View File

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

View File

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

View File

@@ -14,11 +14,11 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search query tags %} {% bookmark_search filters tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %} {% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
@@ -26,7 +26,7 @@
{% if empty %} {% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
{% bookmark_list bookmarks return_url %} {% bookmark_list bookmarks return_url link_target %}
{% endif %} {% endif %}
</form> </form>
</section> </section>
@@ -36,11 +36,11 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags %} {% tag_cloud tags selected_tags %}
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script> <script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script> <script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View File

@@ -7,6 +7,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/> <link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<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">
@@ -27,19 +28,34 @@
{% endif %} {% endif %}
</head> </head>
<body> <body>
<header class="navbar container grid-lg"> <header>
<section class="navbar-section"> {% if has_toasts %}
<a href="/" class="navbar-brand text-bold"> <div class="toasts container grid-lg">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo"> <form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
<h1>linkding</h1> {% csrf_token %}
</a> {% for toast in toast_messages %}
</section> <div class="toast">
{# Only show nav items menu when logged in #} {{ toast.message }}
{% if request.user.is_authenticated %} <button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
<section class="navbar-section"> </div>
{% include 'bookmarks/nav_menu.html' %} {% endfor %}
</section> </form>
</div>
{% endif %} {% endif %}
<div class="navbar container grid-lg">
<section class="navbar-section">
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>
</section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
<section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %}
</section>
{% endif %}
</div>
</header> </header>
<div class="content container grid-lg"> <div class="content container grid-lg">
{% block content %} {% block content %}

View File

@@ -1,7 +1,33 @@
{# 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 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a> <div class="dropdown">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
Bookmarks
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" style="height:1rem;width:1rem;vertical-align: text-bottom;">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a>
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
</li>
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
<li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a> <a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a> <a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div> </div>
@@ -21,7 +47,21 @@
<!-- menu component --> <!-- menu component -->
<ul class="menu"> <ul class="menu">
<li> <li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a> <a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li> </li>
<li> <li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a> <a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>

View File

@@ -3,10 +3,13 @@
<div class="input-group"> <div class="input-group">
<span id="search-input-wrap"> <span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags" <input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ query }}"> value="{{ filters.query }}">
</span> </span>
<input type="submit" value="Search" class="btn input-group-btn"> <input type="submit" value="Search" class="btn input-group-btn">
</div> </div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}">
{% endif %}
</form> </form>
</div> </div>
@@ -15,6 +18,11 @@
window.addEventListener("load", function() { window.addEventListener("load", function() {
const currentTagsString = '{{ tags_string }}'; const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' '); const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const filters = {
q: '{{ filters.query }}',
user: '{{ filters.user }}',
}
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}') const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap') const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div') const newWrapper = document.createElement('div')
@@ -23,10 +31,11 @@
props: { props: {
name: 'q', name: 'q',
placeholder: 'Search for words or #tags', placeholder: 'Search for words or #tags',
value: '{{ query }}', value: '{{ filters.query }}',
tags: currentTags, tags: uniqueTags,
mode: '{{ mode }}', mode: '{{ mode }}',
apiClient apiClient,
filters,
} }
}) })
wrapper.parentElement.replaceChild(newWrapper, wrapper) wrapper.parentElement.replaceChild(newWrapper, wrapper)

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<form id="user-select" action="" method="get">
{% if filters.query %}
<input type="hidden" name="q" value="{{ filters.query }}">
{% endif %}
<div class="form-group">
<div class="d-flex">
<select name="user" class="form-select">
<option value="">Everyone</option>
{% for user in users %}
<option value="{{ user.username }}"
{% if user.username == filters.user %}selected{% endif %}
data-is-user-option>
{{ user.username }}
</option>
{% endfor %}
</select>
<noscript>
<button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript>
</div>
</div>
</form>
<script>
const form = document.getElementById('user-select');
const select = form.querySelector('select');
select.addEventListener('change', () => {
form.submit();
});
</script>

View File

@@ -0,0 +1,21 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block title %}Password changed{% endblock %}
{% block content %}
<div class="auth-page">
<div class="columns">
<section class="content-area column col-5 col-md-12">
<div class="content-area-header">
<h2>Password Changed</h2>
</div>
<p class="text-success">
Your password was changed successfully.
</p>
</section>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block title %}Change Password{% endblock %}
{% block content %}
<div class="auth-page">
<div class="columns">
<section class="content-area column col-5 col-md-12">
<div class="content-area-header">
<h2>Change Password</h2>
</div>
<form method="post" action="{% url 'change_password' %}">
{% csrf_token %}
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
{% if form.old_password.errors %}
<div class="form-input-hint">
{{ form.old_password.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password1.errors %}
<div class="form-input-hint">
{{ form.new_password1.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password2.errors %}
<div class="form-input-hint">
{{ form.new_password2.errors }}
</div>
{% endif %}
</div>
<br/>
<div class="columns">
<div class="column col-3">
<input type="submit" value="Change Password" class="btn btn-primary">
</div>
</div>
</form>
</section>
</div>
</div>
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
<section class="content-area">
<h2>API Token</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
token can access and manage all your bookmarks.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
</section>
</div>
{% endblock %}

View File

@@ -9,15 +9,53 @@
{# Profile section #} {# Profile section #}
<section class="content-area"> <section class="content-area">
<h2>Profile</h2> <h2>Profile</h2>
<p>
<a href="{% url 'change_password' %}">Change password</a>
</p>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate> <form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% 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>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }} {{ form.theme|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label> <label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }} {{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label>
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
Machine</a>. This allows
to preserve, and later access, the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified.
</div>
</div>
<div class="form-group">
<label for="{{ form.enable_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_sharing }}
<i class="form-icon"></i> Enable bookmark sharing
</label>
<div class="form-input-hint">
Allows to share bookmarks with other users, and to view shared bookmarks.
Disabling this feature will hide all previously shared bookmarks from other users.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2"> <input type="submit" value="Save" class="btn btn-primary mt-2">
@@ -69,6 +107,31 @@
{% endif %} {% endif %}
</section> </section>
{# About section #}
<section class="content-area about">
<h2>About</h2>
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ version_info }}</td>
</tr>
<tr>
<td rowspan="3" style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding#documentation"
target="_blank">Documentation</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a></td>
</tr>
</tbody>
</table>
</section>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,6 @@
{% include 'settings/nav.html' %} {% include 'settings/nav.html' %}
{# Integrations section #}
<section class="content-area"> <section class="content-area">
<h2>Browser Extension</h2> <h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p> <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>
@@ -29,5 +28,41 @@
class="btn btn-primary">📎 Add bookmark</a> class="btn btn-primary">📎 Add bookmark</a>
</section> </section>
<section class="content-area">
<h2>REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" readonly>
</div>
</div>
</div>
<p>
<strong>Please treat this token as you would any other credential.</strong>
Any party with access to this token can access and manage all your bookmarks.
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page.
</p>
</section>
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul>
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
</ul>
<p>
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
</p>
<p>
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
Any party with access to these URLs can read all your bookmarks.
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page.
</p>
</section>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,6 @@
{% url 'bookmarks:settings.index' as index_url %} {% url 'bookmarks:settings.index' as index_url %}
{% url 'bookmarks:settings.general' as general_url %} {% url 'bookmarks:settings.general' as general_url %}
{% url 'bookmarks:settings.integrations' as integrations_url %} {% url 'bookmarks:settings.integrations' as integrations_url %}
{% url 'bookmarks:settings.api' as api_url %}
<ul class="tab tab-block"> <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 %}"> <li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == general_url%}active{% endif %}">
@@ -10,9 +9,6 @@
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}"> <li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a> <a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
</li> </li>
<li class="tab-item {% if request.get_full_path == api_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.api' %}">API</a>
</li>
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations."> <li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank"> <a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span> <span>Admin</span>

View File

@@ -1,16 +1,18 @@
from typing import List from typing import List, Set
from django import template from django import template
from django.core.paginator import Page from django.core.paginator import Page
from bookmarks.models import BookmarkForm, Tag, build_tag_string from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User
from bookmarks.utils import unique
register = template.Library() register = template.Library()
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form') @register.inclusion_tag('bookmarks/form.html', name='bookmark_form', takes_context=True)
def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False): def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
return { return {
'request': context['request'],
'form': form, 'form': form,
'auto_close': auto_close, 'auto_close': auto_close,
'bookmark_id': bookmark_id, 'bookmark_id': bookmark_id,
@@ -24,7 +26,8 @@ class TagGroup:
self.char = char self.char = char
def create_tag_groups(tags: List[Tag]): def create_tag_groups(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name)) sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None group = None
groups = [] groups = []
@@ -43,28 +46,49 @@ def create_tag_groups(tags: List[Tag]):
@register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True) @register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True)
def tag_cloud(context, tags: List[Tag]): def tag_cloud(context, tags: List[Tag], selected_tags: List[Tag]):
groups = create_tag_groups(tags) # Only display each tag name once, ignoring casing
# This covers cases where the tag cloud contains shared tags with duplicate names
# Also means that the cloud can not make assumptions that it will necessarily contain
# all tags of the current user
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = create_tag_groups(unselected_tags)
return { return {
'groups': groups, 'groups': groups,
'selected_tags': unique_selected_tags,
'has_selected_tags': has_selected_tags,
} }
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True) @register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
def bookmark_list(context, bookmarks: Page, return_url: str): def bookmark_list(context, bookmarks: Page, return_url: str, link_target: str = '_blank'):
return { return {
'request': context['request'], 'request': context['request'],
'bookmarks': bookmarks, 'bookmarks': bookmarks,
'return_url': return_url 'return_url': return_url,
'link_target': link_target,
} }
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True) @register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
def bookmark_search(context, query: str, tags: [Tag], mode: str = 'default'): def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''):
tag_names = [tag.name for tag in tags] tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ') tags_string = build_tag_string(tag_names, ' ')
return { return {
'query': query, 'filters': filters,
'tags_string': tags_string, 'tags_string': tags_string,
'mode': mode, 'mode': mode,
} }
@register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True)
def user_select(context, filters: BookmarkFilters, users: List[User]):
sorted_users = sorted(users, key=lambda x: str.lower(x.username))
return {
'filters': filters,
'users': sorted_users,
}

View File

@@ -17,7 +17,7 @@ def update_query_string(context, **kwargs):
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def append_query_param(context, **kwargs): def append_to_query_param(context, **kwargs):
query = context.request.GET.copy() query = context.request.GET.copy()
# Append to or create query param # Append to or create query param
@@ -32,6 +32,34 @@ def append_query_param(context, **kwargs):
return query.urlencode() return query.urlencode()
@register.simple_tag(takes_context=True)
def remove_from_query_param(context, **kwargs):
query = context.request.GET.copy()
# Remove item from query param
for key in kwargs:
if query.__contains__(key):
value = query.__getitem__(key)
parts = value.split()
part_to_remove = kwargs[key]
updated_parts = [part for part in parts if str.lower(part) != str.lower(part_to_remove)]
updated_value = ' '.join(updated_parts)
query.__setitem__(key, updated_value)
return query.urlencode()
@register.simple_tag(takes_context=True)
def replace_query_param(context, **kwargs):
query = context.request.GET.copy()
# Create query param or replace existing
for key in kwargs:
value = kwargs[key]
query.__setitem__(key, value)
return query.urlencode()
@register.filter(name='hash_tag') @register.filter(name='hash_tag')
def hash_tag(tag_name): def hash_tag(tag_name):
return '#' + tag_name return '#' + tag_name

View File

@@ -1,3 +1,8 @@
import random
import logging
from typing import List
from bs4 import BeautifulSoup
from django.contrib.auth.models import User from django.contrib.auth.models import User
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
@@ -16,16 +21,41 @@ class BookmarkFactoryMixin:
return self.user return self.user
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None): def setup_bookmark(self,
is_archived: bool = False,
unread: bool = False,
shared: bool = False,
tags=None,
user: User = None,
url: str = '',
title: str = '',
description: str = '',
website_title: str = '',
website_description: str = '',
web_archive_snapshot_url: str = '',
):
if not title:
title = get_random_string(length=32)
if tags is None:
tags = []
if user is None: if user is None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
unique_id = get_random_string(length=32) if not url:
unique_id = get_random_string(length=32)
url = 'https://example.com/' + unique_id
bookmark = Bookmark( bookmark = Bookmark(
url='https://example.com/' + unique_id, url=url,
title=title,
description=description,
website_title=website_title,
website_description=website_description,
date_added=timezone.now(), date_added=timezone.now(),
date_modified=timezone.now(), date_modified=timezone.now(),
owner=user, owner=user,
is_archived=is_archived is_archived=is_archived,
unread=unread,
shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url,
) )
bookmark.save() bookmark.save()
for tag in tags: for tag in tags:
@@ -33,14 +63,28 @@ class BookmarkFactoryMixin:
bookmark.save() bookmark.save()
return bookmark return bookmark
def setup_tag(self, user: User = None): 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()
name = get_random_string(length=32) if not name:
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=user) tag = Tag(name=name, date_added=timezone.now(), owner=user)
tag.save() tag.save()
return tag return tag
def setup_user(self, name: str = None, enable_sharing: bool = False):
if not name:
name = get_random_string(length=32)
user = User.objects.create_user(name, 'user@example.com', 'password123')
user.profile.enable_sharing = enable_sharing
user.profile.save()
return user
class HtmlTestMixin:
def make_soup(self, html: str):
return BeautifulSoup(html, features="html.parser")
class LinkdingApiTestCase(APITestCase): class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK): def get(self, url, expected_status_code=status.HTTP_200_OK):
@@ -58,7 +102,102 @@ class LinkdingApiTestCase(APITestCase):
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)
return response return response
def patch(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def delete(self, url, expected_status_code=status.HTTP_200_OK): def delete(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.delete(url) response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)
return response return response
class BookmarkHtmlTag:
def __init__(self,
href: str = '',
title: str = '',
description: str = '',
add_date: str = '',
tags: str = '',
to_read: bool = False):
self.href = href
self.title = title
self.description = description
self.add_date = add_date
self.tags = tags
self.to_read = to_read
class ImportTestMixin:
def render_tag(self, tag: BookmarkHtmlTag):
return f'''
<DT>
<A {f'HREF="{tag.href}"' if tag.href else ''}
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
{f'TAGS="{tag.tags}"' if tag.tags else ''}
TOREAD="{1 if tag.to_read else 0}">
{tag.title if tag.title else ''}
</A>
{f'<DD>{tag.description}' if tag.description else ''}
'''
def render_html(self, tags: List[BookmarkHtmlTag] = None, tags_html: str = ''):
if tags:
rendered_tags = [self.render_tag(tag) for tag in tags]
tags_html = '\n'.join(rendered_tags)
return f'''
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
{tags_html}
</DL><p>
'''
_words = [
'quasi',
'consequatur',
'necessitatibus',
'debitis',
'quod',
'vero',
'qui',
'commodi',
'quod',
'odio',
'aliquam',
'veniam',
'architecto',
'consequatur',
'autem',
'qui',
'iste',
'asperiores',
'soluta',
'et',
]
def random_sentence(num_words: int = None, including_word: str = ''):
if num_words is None:
num_words = random.randint(5, 10)
selected_words = random.choices(_words, k=num_words)
if including_word:
selected_words.append(including_word)
random.shuffle(selected_words)
return ' '.join(selected_words)
def disable_logging(f):
def wrapper(*args):
logging.disable(logging.CRITICAL)
result = f(*args)
logging.disable(logging.NOTSET)
return result
return wrapper

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,20 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="https://example.com/1" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag1">test title 1</A>
<DD>test description 1
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559000" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
<DD>test description 2
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559000000" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
<DD>test description 3
</DL><p>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="https://example.com/1" ADD_DATE="invaliddate" PRIVATE="0" TOREAD="0" TAGS="tag1">test title 1</A>
<DD>test description 1
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
<DD>test description 2
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
<DD>test description 3
</DL><p>

View File

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

View File

@@ -0,0 +1,332 @@
from django.contrib.auth.models import User
from django.forms import model_to_dict
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
self.assertEqual(len(bookmarks), Bookmark.objects.count())
for bookmark in bookmarks:
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
def test_archive_should_archive_bookmark(self):
bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'archive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertTrue(bookmark.is_archived)
def test_can_only_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), {
'archive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertFalse(bookmark.is_archived)
def test_unarchive_should_unarchive_bookmark(self):
bookmark = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), {
'unarchive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertFalse(bookmark.is_archived)
def test_unarchive_can_only_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
response = self.client.post(reverse('bookmarks:action'), {
'unarchive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.is_archived)
def test_delete_should_delete_bookmark(self):
bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'remove': [bookmark.id],
})
self.assertEqual(Bookmark.objects.count(), 0)
def test_delete_can_only_delete_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), {
'remove': [bookmark.id],
})
self.assertEqual(response.status_code, 404)
self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())
def test_mark_as_read(self):
bookmark = self.setup_bookmark(unread=True)
self.client.post(reverse('bookmarks:action'), {
'mark_as_read': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertFalse(bookmark.unread)
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_can_only_bulk_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(user=other_user)
bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_unarchive(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), {
'bulk_unarchive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_can_only_bulk_unarchive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(is_archived=True, user=other_user)
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
self.client.post(reverse('bookmarks:action'), {
'bulk_unarchive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_delete(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'bulk_delete': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
def test_can_only_bulk_delete_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(user=other_user)
bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:action'), {
'bulk_delete': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark3.id).first())
def test_bulk_tag(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:action'), {
'bulk_tag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_can_only_bulk_tag_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(user=other_user)
bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user)
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:action'), {
'bulk_tag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [])
def test_bulk_untag(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post(reverse('bookmarks:action'), {
'bulk_untag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [])
def test_can_only_bulk_untag_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
self.client.post(reverse('bookmarks:action'), {
'bulk_untag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_handles_empty_bookmark_id(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
response = self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
})
self.assertEqual(response.status_code, 302)
response = self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
'bookmark_id': [],
})
self.assertEqual(response.status_code, 302)
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_empty_action_does_not_modify_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_should_redirect_to_return_url(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index')
response = self.client.post(url, {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertRedirects(response, reverse('bookmarks:settings.index'))
def test_should_not_redirect_to_external_url(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
def post_with(return_url, follow=None):
url = reverse('bookmarks:action') + f'?return_url={return_url}'
return self.client.post(url, {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}, follow=follow)
response = post_with('https://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('//example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('/foo//example.com', follow=True)
self.assertEqual(response.status_code, 404)

View File

@@ -0,0 +1,185 @@
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html
)
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html,
count=0
)
def assertVisibleTags(self, response, tags: List[Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: List[Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select('p.selected-tags')[0]
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select('a')
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=False),
self.setup_bookmark(is_archived=True, user=other_user),
]
response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=False, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]], user=other_user)
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(is_archived=True, tags=tags)
response = self.client.get(reverse('bookmarks:archived') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
user = self.get_or_create_test_user()
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')

View File

@@ -0,0 +1,42 @@
from django.contrib.auth.models import User
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
def test_should_not_increase_number_of_queries_per_bookmark(self):
# create initial bookmarks
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(user=self.user, is_archived=True)
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks)
number_of_queries = context.final_queries
# add more bookmarks
num_additional_bookmarks = 10
for index in range(num_additional_bookmarks):
self.setup_bookmark(user=self.user, is_archived=True)
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -0,0 +1,186 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import build_tag_string
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
'url': 'http://example.com/edited',
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'unread': False,
'shared': False,
}
return {**form_data, **overrides}
def test_should_edit_bookmark(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertEqual(bookmark.owner, self.user)
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
tags = bookmark.tags.order_by('name').all()
self.assertEqual(tags[0].name, 'editedtag1')
self.assertEqual(tags[1].name, 'editedtag2')
def test_should_edit_unread_state(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id, 'unread': True})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertTrue(bookmark.unread)
form_data = self.create_form_data({'id': bookmark.id, 'unread': False})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertFalse(bookmark.unread)
def test_should_edit_shared_state(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id, 'shared': True})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertTrue(bookmark.shared)
form_data = self.create_form_data({'id': bookmark.id, 'shared': False})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertFalse(bookmark.shared)
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
website_title='website title', website_description='website description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML(f'''
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
autofocus class="form-input" required id="id_url">
''', html)
tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML(f'''
<input type="text" name="tag_string" value="{tag_string}"
autocomplete="off" class="form-input" id="id_tag_string">
''', html)
self.assertInHTML(f'''
<input type="text" name="title" value="{bookmark.title}" maxlength="512" autocomplete="off"
class="form-input" id="id_title">
''', html)
self.assertInHTML(f'''
<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">
{bookmark.description}
</textarea>
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}">
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_description" id="id_website_description"
value="{bookmark.website_description}">
''', html)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data()
url = reverse('bookmarks:edit', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
response = self.client.post(url, form_data)
self.assertRedirects(response, reverse('bookmarks:close'))
def test_should_redirect_to_bookmark_index_by_default(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
self.assertRedirects(response, reverse('bookmarks:index'))
def test_should_not_redirect_to_external_url(self):
bookmark = self.setup_bookmark()
def post_with(return_url, follow=None):
form_data = self.create_form_data()
url = reverse('bookmarks:edit', args=[bookmark.id]) + f'?return_url={return_url}'
return self.client.post(url, form_data, follow=follow)
response = post_with('https://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('//example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('/foo//example.com', follow=True)
self.assertEqual(response.status_code, 404)
def test_can_only_edit_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
form_data = self.create_form_data({'id': bookmark.id})
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertNotEqual(bookmark.url, form_data['url'])
self.assertEqual(response.status_code, 404)
def test_should_respect_share_profile_setting(self):
bookmark = self.setup_bookmark()
self.user.profile.enable_sharing = False
self.user.profile.save()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', html, count=0)
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', html, count=1)

View File

@@ -0,0 +1,213 @@
from typing import List
import urllib.parse
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html
)
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html,
count=0
)
def assertVisibleTags(self, response, tags: List[Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: List[Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select('p.selected-tags')[0]
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select('a')
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(user=other_user),
]
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[invisible_tags[1]], is_archived=True)
self.setup_bookmark(tags=[invisible_tags[2]], user=other_user)
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(tags=[invisible_tags[0]])
self.setup_bookmark(tags=[invisible_tags[1]])
self.setup_bookmark(tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
user = self.get_or_create_test_user()
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_should_contain_query_params(self):
bookmark = self.setup_bookmark(title='foo')
# without query params
url = reverse('bookmarks:index')
response = self.client.get(url)
html = response.content.decode()
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
''', html)
# with query params
url = reverse('bookmarks:index') + '?q=foo&user=user'
response = self.client.get(url)
html = response.content.decode()
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
''', html)

View File

@@ -0,0 +1,42 @@
from django.contrib.auth.models import User
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
def test_should_not_increase_number_of_queries_per_bookmark(self):
# create initial bookmarks
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
self.setup_bookmark(user=self.user)
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks)
number_of_queries = context.final_queries
# add more bookmarks
num_additional_bookmarks = 10
for index in range(num_additional_bookmarks):
self.setup_bookmark(user=self.user)
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -0,0 +1,140 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
'url': 'http://example.com',
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'unread': False,
'shared': False,
'auto_close': '',
}
return {**form_data, **overrides}
def test_should_create_new_bookmark(self):
form_data = self.create_form_data()
self.client.post(reverse('bookmarks:new'), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
self.assertEqual(bookmark.owner, self.user)
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
tags = bookmark.tags.order_by('name').all()
self.assertEqual(tags[0].name, 'tag1')
self.assertEqual(tags[1].name, 'tag2')
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({'unread': True})
self.client.post(reverse('bookmarks:new'), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
self.assertTrue(bookmark.unread)
def test_should_create_new_shared_bookmark(self):
form_data = self.create_form_data({'shared': True})
self.client.post(reverse('bookmarks:new'), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
self.assertTrue(bookmark.shared)
def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="url" value="http://example.com" '
'placeholder=" " autofocus class="form-input" required '
'id="id_url">',
html)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(
reverse('bookmarks:new') + '?auto_close')
html = response.content.decode()
self.assertInHTML(
'<input type="hidden" name="auto_close" value="true" '
'id="id_auto_close">',
html)
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(
self):
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">', html)
def test_should_redirect_to_index_view(self):
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:index'))
def test_should_not_redirect_to_external_url(self):
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:new') + '?return_url=https://example.com', form_data)
self.assertRedirects(response, reverse('bookmarks:index'))
def test_auto_close_should_redirect_to_close_view(self):
form_data = self.create_form_data({'auto_close': 'true'})
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:close'))
def test_should_respect_share_profile_setting(self):
self.user.profile.enable_sharing = False
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', html, count=0)
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', html, count=1)

View File

@@ -0,0 +1,40 @@
from django.db.models import QuerySet
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkFilters, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
rf = RequestFactory()
request = rf.get(url)
filters = BookmarkFilters(request)
context = RequestContext(request, {
'request': request,
'filters': filters,
'tags': tags,
})
template_to_render = Template(
'{% load bookmarks %}'
'{% bookmark_search filters tags %}'
)
return template_to_render.render(context)
def test_render_hidden_inputs_for_filter_params(self):
# Should render hidden inputs if query param exists
url = '/test?q=foo&user=john'
rendered_template = self.render_template(url)
self.assertInHTML('''
<input type="hidden" name="user" value="john">
''', rendered_template)
# Should not render hidden inputs if query param does not exist
url = '/test?q=foo'
rendered_template = self.render_template(url)
self.assertInHTML('''
<input type="hidden" name="user" value="john">
''', rendered_template, count=0)

View File

@@ -0,0 +1,255 @@
from typing import List
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html, count=count
)
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 1, link_target)
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode()
for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 0, link_target)
def assertVisibleTags(self, response, tags: [Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
def assertVisibleUserOptions(self, response, users: List[User]):
html = response.content.decode()
self.assertContains(response, 'data-is-user-option', count=len(users))
for user in users:
self.assertInHTML(f'''
<option value="{user.username}" data-is-user-option>
{user.username}
</option>
''', html)
def assertInvisibleUserOptions(self, response, users: List[User]):
html = response.content.decode()
for user in users:
self.assertInHTML(f'''
<option value="{user.username}" data-is-user-option>
{user.username}
</option>
''', html, count=0)
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
user4 = self.setup_user(enable_sharing=False)
visible_bookmarks = [
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user2),
self.setup_bookmark(shared=True, user=user3),
]
invisible_bookmarks = [
self.setup_bookmark(shared=False, user=user1),
self.setup_bookmark(shared=False, user=user2),
self.setup_bookmark(shared=False, user=user3),
self.setup_bookmark(shared=True, user=user4),
]
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_shared_bookmarks_from_selected_user(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
visible_bookmarks = [
self.setup_bookmark(shared=True, user=user1),
]
invisible_bookmarks = [
self.setup_bookmark(shared=True, user=user2),
self.setup_bookmark(shared=True, user=user3),
]
url = reverse('bookmarks:shared') + '?user=' + user1.username
response = self.client.get(url)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
user = self.setup_user(enable_sharing=True)
visible_bookmarks = [
self.setup_bookmark(shared=True, title='searchvalue', user=user),
self.setup_bookmark(shared=True, title='searchvalue', user=user),
self.setup_bookmark(shared=True, title='searchvalue', user=user)
]
invisible_bookmarks = [
self.setup_bookmark(shared=True, user=user),
self.setup_bookmark(shared=True, user=user),
self.setup_bookmark(shared=True, user=user)
]
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
user4 = self.setup_user(enable_sharing=False)
visible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
invisible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
self.setup_tag(user=user4),
]
self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[visible_tags[1]])
self.setup_bookmark(shared=True, user=user3, tags=[visible_tags[2]])
self.setup_bookmark(shared=False, user=user1, tags=[invisible_tags[0]])
self.setup_bookmark(shared=False, user=user2, tags=[invisible_tags[1]])
self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]])
self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]])
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_shared_bookmarks_from_selected_user(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
visible_tags = [
self.setup_tag(user=user1),
]
invisible_tags = [
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]])
url = reverse('bookmarks:shared') + '?user=' + user1.username
response = self.client.get(url)
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
visible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
invisible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
self.setup_bookmark(shared=True, user=user1, title='searchvalue', tags=[visible_tags[0]])
self.setup_bookmark(shared=True, user=user2, title='searchvalue', tags=[visible_tags[1]])
self.setup_bookmark(shared=True, user=user3, title='searchvalue', tags=[visible_tags[2]])
self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
expected_visible_users = [
self.setup_user(enable_sharing=True),
self.setup_user(enable_sharing=True),
]
self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1])
expected_invisible_users = [
self.setup_user(enable_sharing=True),
self.setup_user(enable_sharing=False),
]
self.setup_bookmark(shared=False, user=expected_invisible_users[0])
self.setup_bookmark(shared=True, user=expected_invisible_users[1])
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users)
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True)
]
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
user = self.get_or_create_test_user()
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True)
]
response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')

View File

@@ -0,0 +1,44 @@
from django.contrib.auth.models import User
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.db import connections
from django.db.utils import DEFAULT_DB_ALIAS
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
def test_should_not_increase_number_of_queries_per_bookmark(self):
# create initial users and bookmarks
num_initial_bookmarks = 10
for index in range(num_initial_bookmarks):
user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True)
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks)
number_of_queries = context.final_queries
# add more users and bookmarks
num_additional_bookmarks = 10
for index in range(num_additional_bookmarks):
user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True)
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -34,6 +34,27 @@ class BookmarkValidationTestCase(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123') self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def test_bookmark_model_should_not_allow_missing_url(self):
bookmark = Bookmark(
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
with self.assertRaises(ValidationError):
bookmark.full_clean()
def test_bookmark_model_should_not_allow_empty_url(self):
bookmark = Bookmark(
url='',
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
with self.assertRaises(ValidationError):
bookmark.full_clean()
@override_settings(LD_DISABLE_URL_VALIDATION=False) @override_settings(LD_DISABLE_URL_VALIDATION=False)
def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self): def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):
self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES) self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)

View File

@@ -34,6 +34,9 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['description'] = bookmark.description expectation['description'] = bookmark.description
expectation['website_title'] = bookmark.website_title expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
expectation['unread'] = bookmark.unread
expectation['shared'] = bookmark.shared
expectation['tag_names'] = tag_names expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z') expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z') expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
@@ -44,32 +47,13 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertCountEqual(data_list, expectations) self.assertCountEqual(data_list, expectations)
def test_create_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_minimal_payload(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_list_bookmarks(self): def test_list_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3]) self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
def test_list_bookmarks_should_filter_by_query(self): def test_list_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1]) self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self): def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
@@ -77,9 +61,181 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2]) self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
def test_list_archived_bookmarks_should_filter_by_query(self): def test_list_archived_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1]) self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_list_shared_bookmarks(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
user4 = self.setup_user(enable_sharing=False)
shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user2),
self.setup_bookmark(shared=True, user=user3),
]
# Unshared bookmarks
self.setup_bookmark(shared=False, user=user1)
self.setup_bookmark(shared=False, user=user2)
self.setup_bookmark(shared=False, user=user3)
self.setup_bookmark(shared=True, user=user4)
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
def test_list_shared_bookmarks_should_filter_by_query_and_user(self):
# Search by query
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
expected_bookmarks = [
self.setup_bookmark(title='searchvalue', shared=True, user=user1),
self.setup_bookmark(title='searchvalue', shared=True, user=user2),
self.setup_bookmark(title='searchvalue', shared=True, user=user3),
]
self.setup_bookmark(shared=True, user=user1),
self.setup_bookmark(shared=True, user=user2),
self.setup_bookmark(shared=True, user=user3),
response = self.get(reverse('bookmarks:bookmark-shared') + '?q=searchvalue',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
# Search by user
user_search_user = self.setup_user(enable_sharing=True)
expected_bookmarks = [
self.setup_bookmark(shared=True, user=user_search_user),
self.setup_bookmark(shared=True, user=user_search_user),
self.setup_bookmark(shared=True, user=user_search_user),
]
response = self.get(reverse('bookmarks:bookmark-shared') + '?user=' + user_search_user.username,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
# Search by query and user
combined_search_user = self.setup_user(enable_sharing=True)
expected_bookmarks = [
self.setup_bookmark(title='searchvalue', shared=True, user=combined_search_user),
self.setup_bookmark(title='searchvalue', shared=True, user=combined_search_user),
self.setup_bookmark(title='searchvalue', shared=True, user=combined_search_user),
]
response = self.get(
reverse('bookmarks:bookmark-shared') + '?q=searchvalue&user=' + combined_search_user.username,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
def test_create_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'is_archived': False,
'unread': False,
'shared': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread'])
self.assertFalse(bookmark.shared, data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
original_bookmark = self.setup_bookmark()
data = {
'url': original_bookmark.url,
'title': 'Updated title',
'description': 'Updated description',
'unread': True,
'shared': True,
'is_archived': True,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.id, original_bookmark.id)
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
# Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(bookmark.is_archived)
self.assertEqual(bookmark.unread, data['unread'])
self.assertEqual(bookmark.shared, data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_replaces_whitespace_in_tag_names(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'tag_names': ['tag 1', 'tag 2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
def test_create_bookmark_minimal_payload(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_create_archived_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'is_archived': True,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertTrue(bookmark.is_archived)
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_is_not_archived_by_default(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived)
def test_create_unread_bookmark(self):
data = {'url': 'https://example.com/', 'unread': True}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.unread)
def test_create_bookmark_is_not_unread_by_default(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.unread)
def test_create_shared_bookmark(self):
data = {'url': 'https://example.com/', 'shared': True}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.shared)
def test_create_bookmark_is_not_shared_by_default(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.shared)
def test_get_bookmark(self): def test_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
@@ -92,6 +248,94 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url']) self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self):
data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '')
self.assertEqual(updated_bookmark.tag_names, [])
def test_update_bookmark_unread_flag(self):
data = {'url': 'https://example.com/', 'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.unread, True)
def test_update_bookmark_shared_flag(self):
data = {'url': 'https://example.com/', 'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.shared, True)
def test_patch_bookmark(self):
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.url, data['url'])
data = {'title': 'Updated title'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.title, data['title'])
data = {'description': 'Updated description'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description'])
data = {'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertTrue(self.bookmark1.unread)
data = {'unread': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.unread)
data = {'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertTrue(self.bookmark1.shared)
data = {'shared': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.shared)
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
tag_names = [tag.name for tag in self.bookmark1.tags.all()]
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, self.bookmark1.url)
self.assertEqual(updated_bookmark.title, self.bookmark1.title)
self.assertEqual(updated_bookmark.description, self.bookmark1.description)
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
def test_delete_bookmark(self): def test_delete_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
@@ -112,6 +356,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_can_only_access_own_bookmarks(self): def test_can_only_access_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user) inaccessible_bookmark = self.setup_bookmark(user=other_user)
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
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('bookmarks:bookmark-list')
@@ -125,14 +370,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id]) url = reverse('bookmarks: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('bookmarks:bookmark-detail', args=[inaccessible_shared_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id]) url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND) self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_shared_bookmark.id])
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id]) url = reverse('bookmarks: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('bookmarks:bookmark-detail', args=[inaccessible_shared_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-archive', args=[inaccessible_bookmark.id]) url = reverse('bookmarks: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('bookmarks:bookmark-archive', args=[inaccessible_shared_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_bookmark.id]) url = reverse('bookmarks: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('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)

View File

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

View File

@@ -1,11 +1,15 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, Tag
from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.services import website_loader
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
User = get_user_model() User = get_user_model()
@@ -13,7 +17,65 @@ User = get_user_model()
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin): class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123') self.get_or_create_test_user()
def test_create_should_update_existing_bookmark_with_same_url(self):
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
unread=True,
shared=True,
is_archived=True)
updated_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title)
self.assertEqual(updated_bookmark.description, bookmark_data.description)
self.assertEqual(updated_bookmark.unread, bookmark_data.unread)
self.assertEqual(updated_bookmark.shared, bookmark_data.shared)
# Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(updated_bookmark.is_archived)
def test_create_should_create_web_archive_snapshot(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, True)
def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_not_called()
def test_update_should_update_website_metadata_if_url_did_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_website_metadata.assert_called_once()
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_website_metadata.assert_not_called()
def test_archive_bookmark(self): def test_archive_bookmark(self):
bookmark = Bookmark( bookmark = Bookmark(
@@ -190,7 +252,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}', tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user()) self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
@@ -206,7 +268,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user()) tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1,tag2', self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
bookmark2.refresh_from_db() bookmark2.refresh_from_db()
@@ -231,7 +293,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user()) tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
bookmark2.refresh_from_db() bookmark2.refresh_from_db()
@@ -249,7 +311,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}', tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user()) self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
@@ -267,7 +329,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}', tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user()) self.get_or_create_test_user())
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
@@ -281,7 +343,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}', untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user()) self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
@@ -299,7 +361,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user()) untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
bookmark2.refresh_from_db() bookmark2.refresh_from_db()
@@ -317,7 +379,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2]) inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}', untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user()) self.get_or_create_test_user())
bookmark1.refresh_from_db() bookmark1.refresh_from_db()
@@ -335,7 +397,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}', untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user()) self.get_or_create_test_user())
self.assertCountEqual(bookmark1.tags.all(), []) self.assertCountEqual(bookmark1.tags.all(), [])

View File

@@ -0,0 +1,172 @@
from unittest.mock import patch
import waybackpy
from background_task.models import Task
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class MockWaybackMachineSaveAPI:
def __init__(self, archive_url: str):
self.archive_url = archive_url
def save(self):
return self
class MockWaybackUrlWithSaveError:
def save(self):
raise NotImplementedError
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.save()
@disable_logging
def run_pending_task(self, task_function):
func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0]
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@disable_logging
def run_all_pending_tasks(self, task_function):
func = getattr(task_function, 'task_function', None)
tasks = Task.objects.all()
for task in tasks:
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')) as mock_wayback_url:
tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_wayback_url.assert_not_called()
def test_create_web_archive_snapshot_should_handle_wayback_save_error(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackUrlWithSaveError()):
with self.assertRaises(NotImplementedError):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.assertEqual(Task.objects.count(), 0)
def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(self):
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_snapshots_should_create_snapshot_task_for_all_bookmarks_without_snapshot(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_schedule_bookmarks_without_snapshots_should_not_update_bookmarks_with_existing_snapshot(self):
user = self.get_or_create_test_user()
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(self):
user = self.get_or_create_test_user()
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI('https://example.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all().filter(owner=user):
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
for bookmark in Bookmark.objects.all().filter(owner=other_user):
self.assertEqual(bookmark.web_archive_snapshot_url, '')
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(self):
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(self):
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
self.user.profile.save()
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)

View File

@@ -1,132 +0,0 @@
from django.forms import model_to_dict
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
self.assertEqual(len(bookmarks), Bookmark.objects.count())
for bookmark in bookmarks:
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_unarchive(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_unarchive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_delete(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_delete': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertFalse(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertFalse(Bookmark.objects.filter(id=bookmark3.id).first())
def test_bulk_tag(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_tag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_bulk_untag(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_untag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [])
def test_bulk_edit_handles_empty_bookmark_id(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
response = self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_archive': [''],
})
self.assertEqual(response.status_code, 302)
response = self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_archive': [''],
'bookmark_id': [],
})
self.assertEqual(response.status_code, 302)
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_empty_action_does_not_modify_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])

View File

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

View File

@@ -0,0 +1,191 @@
import datetime
import email
import urllib.parse
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import FeedToken, User
def rfc2822_date(date):
if not isinstance(date, datetime.datetime):
date = datetime.datetime.combine(date, datetime.time())
return email.utils.format_datetime(date)
class FeedsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0]
def test_all_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse('bookmarks:feeds.all', args=['foo']))
self.assertEqual(response.status_code, 404)
def test_all_metadata(self):
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
response = self.client.get(feed_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<title>All bookmarks</title>')
self.assertContains(response, '<description>All bookmarks</description>')
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
def test_all_returns_all_unarchived_bookmarks(self):
bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark(unread=True),
]
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=len(bookmarks))
for bookmark in bookmarks:
expected_item = '<item>' \
f'<title>{bookmark.resolved_title}</title>' \
f'<link>{bookmark.url}</link>' \
f'<description>{bookmark.resolved_description}</description>' \
f'<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>' \
f'<guid>{bookmark.url}</guid>' \
'</item>'
self.assertContains(response, expected_item, count=1)
def test_all_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
url = feed_url + f'?q={bookmark1.title}'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark1.url}</guid>', count=1)
url = feed_url + '?q=' + urllib.parse.quote('#' + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=2)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
self.assertContains(response, f'<guid>{bookmark3.url}</guid>', count=1)
url = feed_url + '?q=' + urllib.parse.quote(f'#{tag1.name} {bookmark2.title}')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
def test_all_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.setup_bookmark(unread=True, user=other_user)
self.setup_bookmark(unread=True, user=other_user)
self.setup_bookmark(unread=True, user=other_user)
response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=0)
def test_unread_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse('bookmarks:feeds.unread', args=['foo']))
self.assertEqual(response.status_code, 404)
def test_unread_metadata(self):
feed_url = reverse('bookmarks:feeds.unread', args=[self.token.key])
response = self.client.get(feed_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<title>Unread bookmarks</title>')
self.assertContains(response, '<description>All unread bookmarks</description>')
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
def test_unread_returns_unread_and_unarchived_bookmarks(self):
self.setup_bookmark(unread=False)
self.setup_bookmark(unread=False)
self.setup_bookmark(unread=False)
self.setup_bookmark(unread=True, is_archived=True)
self.setup_bookmark(unread=True, is_archived=True)
self.setup_bookmark(unread=False, is_archived=True)
unread_bookmarks = [
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
]
response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=len(unread_bookmarks))
for bookmark in unread_bookmarks:
expected_item = '<item>' \
f'<title>{bookmark.resolved_title}</title>' \
f'<link>{bookmark.url}</link>' \
f'<description>{bookmark.resolved_description}</description>' \
f'<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>' \
f'<guid>{bookmark.url}</guid>' \
'</item>'
self.assertContains(response, expected_item, count=1)
def test_unread_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True, tags=[tag1])
bookmark3 = self.setup_bookmark(unread=True, tags=[tag1])
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
url = feed_url + f'?q={bookmark1.title}'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark1.url}</guid>', count=1)
url = feed_url + '?q=' + urllib.parse.quote('#' + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=2)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
self.assertContains(response, f'<guid>{bookmark3.url}</guid>', count=1)
url = feed_url + '?q=' + urllib.parse.quote(f'#{tag1.name} {bookmark2.title}')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
def test_unread_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.setup_bookmark(unread=True, user=other_user)
self.setup_bookmark(unread=True, user=other_user)
self.setup_bookmark(unread=True, user=other_user)
response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<item>', count=0)

View File

@@ -0,0 +1,264 @@
from typing import List
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.utils import timezone
from bookmarks.models import Bookmark, Tag, parse_tag_string
from bookmarks.services import tasks
from bookmarks.services.importer import import_netscape_html
from bookmarks.tests.helpers import BookmarkFactoryMixin, ImportTestMixin, BookmarkHtmlTag, disable_logging
from bookmarks.utils import parse_timestamp
class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
def assertBookmarksImported(self, html_tags: List[BookmarkHtmlTag]):
for html_tag in html_tags:
bookmark = Bookmark.objects.get(url=html_tag.href)
self.assertIsNotNone(bookmark)
self.assertEqual(bookmark.title, html_tag.title)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
self.assertEqual(bookmark.unread, html_tag.to_read)
tag_names = parse_tag_string(html_tag.tags)
# Check assigned tags
for tag_name in tag_names:
tag = next(
(tag for tag in bookmark.tags.all() if tag.name == tag_name), None)
self.assertIsNotNone(tag)
def test_import(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://example.com/bar', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/baz', title='Baz title', description='Baz description',
add_date='4', to_read=True),
]
import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user())
# Check result
self.assertEqual(result.total, 4)
self.assertEqual(result.success, 4)
self.assertEqual(result.failed, 0)
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 4)
self.assertBookmarksImported(html_tags)
def test_synchronize(self):
# Initial import
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://example.com/bar', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=True),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
# Change data, add some new data
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
description='Updated Example description', add_date='111', tags='updated-example-tag'),
BookmarkHtmlTag(href='https://example.com/foo', title='Updated Foo title', description='Updated Foo description',
add_date='222', tags='new-tag'),
BookmarkHtmlTag(href='https://example.com/bar', title='Updated Bar title', description='Updated Bar description',
add_date='333', tags='updated-bar-tag, updated-other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=False),
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
]
# Import updated data
import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user())
# Check result
self.assertEqual(result.total, 5)
self.assertEqual(result.success, 5)
self.assertEqual(result.failed, 0)
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 5)
self.assertBookmarksImported(html_tags)
def test_import_with_some_invalid_bookmarks(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com'),
# Invalid URL
BookmarkHtmlTag(href='foo.com'),
# No URL
BookmarkHtmlTag(),
]
import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user())
# Check result
self.assertEqual(result.total, 3)
self.assertEqual(result.success, 1)
self.assertEqual(result.failed, 2)
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 1)
self.assertBookmarksImported(html_tags[1:1])
def test_import_invalid_bookmark_does_not_associate_tags(self):
html_tags = [
# No URL
BookmarkHtmlTag(tags='tag1, tag2, tag3'),
]
import_html = self.render_html(tags=html_tags)
# Sqlite silently ignores relationships that have a non-persisted bookmark,
# thus testing if the bulk create receives no relationships
BookmarkToTagRelationShip = Bookmark.tags.through
with patch.object(BookmarkToTagRelationShip.objects, 'bulk_create') as mock_bulk_create:
import_netscape_html(import_html, self.get_or_create_test_user())
mock_bulk_create.assert_called_once_with([], ignore_conflicts=True)
def test_import_tags(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
BookmarkHtmlTag(href='https://foo.com', tags='tag2'),
BookmarkHtmlTag(href='https://bar.com', tags='tag3'),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
self.assertEqual(Tag.objects.count(), 3)
def test_create_missing_tags(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
BookmarkHtmlTag(href='https://foo.com', tags='tag2'),
BookmarkHtmlTag(href='https://bar.com', tags='tag3'),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
html_tags.append(
BookmarkHtmlTag(href='https://baz.com', tags='tag4')
)
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
self.assertEqual(Tag.objects.count(), 4)
def test_create_missing_tags_does_not_duplicate_tags(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
BookmarkHtmlTag(href='https://foo.com', tags='tag1'),
BookmarkHtmlTag(href='https://bar.com', tags='tag1'),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
self.assertEqual(Tag.objects.count(), 1)
def test_should_append_tags_to_bookmark_when_reimporting_with_different_tags(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
html_tags.append(
BookmarkHtmlTag(href='https://example.com', tags='tag2, tag3')
)
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].tags.all().count(), 3)
@override_settings(USE_TZ=False)
def test_use_current_date_when_no_add_date(self):
test_html = self.render_html(tags_html=f'''
<DT><A HREF="https://example.com">Example.com</A>
<DD>Example.com
''')
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1))
def test_keep_title_if_imported_bookmark_has_empty_title(self):
test_html = self.render_html(tags=[
BookmarkHtmlTag(href='https://example.com', title='Example.com')
])
import_netscape_html(test_html, self.get_or_create_test_user())
test_html = self.render_html(tags=[
BookmarkHtmlTag(href='https://example.com')
])
import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].title, 'Example.com')
def test_keep_description_if_imported_bookmark_has_empty_description(self):
test_html = self.render_html(tags=[
BookmarkHtmlTag(href='https://example.com', description='Example.com')
])
import_netscape_html(test_html, self.get_or_create_test_user())
test_html = self.render_html(tags=[
BookmarkHtmlTag(href='https://example.com')
])
import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].description, 'Example.com')
def test_replace_whitespace_in_tag_names(self):
test_html = self.render_html(tags_html=f'''
<DT><A HREF="https://example.com" TAGS="tag 1, tag 2, tag 3">Example.com</A>
<DD>Example.com
''')
import_netscape_html(test_html, self.get_or_create_test_user())
tags = Tag.objects.all()
tag_names = [tag.name for tag in tags]
self.assertListEqual(tag_names, ['tag-1', 'tag-2', 'tag-3'])
@disable_logging
def test_validate_empty_or_missing_bookmark_url(self):
test_html = self.render_html(tags_html=f'''
<DT><A HREF="">Empty URL</A>
<DD>Empty URL
<DT><A>Missing URL</A>
<DD>Missing URL
''')
import_result = import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 0)
self.assertEqual(import_result.success, 0)
self.assertEqual(import_result.failed, 2)
def test_schedule_snapshot_creation(self):
user = self.get_or_create_test_user()
test_html = self.render_html(tags_html='')
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)

View File

@@ -0,0 +1,30 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class NavMenuTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_respect_share_profile_setting(self):
self.user.profile.enable_sharing = False
self.user.profile.save()
response = self.client.get(reverse('bookmarks:index'))
html = response.content.decode()
self.assertInHTML(f'''
<a href="{reverse('bookmarks:shared')}" class="btn btn-link">Shared</a>
''', html, count=0)
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:index'))
html = response.content.decode()
self.assertInHTML(f'''
<a href="{reverse('bookmarks:shared')}" class="btn btn-link">Shared</a>
''', html, count=2)

View File

@@ -1,6 +1,6 @@
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.test import SimpleTestCase, RequestFactory
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import SimpleTestCase, RequestFactory
class PaginationTagTest(SimpleTestCase): class PaginationTagTest(SimpleTestCase):

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