Compare commits

...

27 Commits

Author SHA1 Message Date
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
51 changed files with 1277 additions and 212 deletions

View File

@@ -1,109 +1,190 @@
# Changelog
## 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
### 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 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)
- [**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
- [bug] fix bookmark access restrictions
- [bug] prevent external redirects
- [chore] bump dependencies
---
## v1.8.5 (13/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)
## 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)
- [**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/)
- [**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)
- [**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
- 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 (07/04/2021)
## 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)
@@ -112,46 +193,57 @@
---
## v1.6.1 (31/03/2021)
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
---
## v1.6.0 (29/03/2021)
## v1.6.0 (28/03/2021)
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
---
## v1.5.0 (28/03/2021)
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
---
## v1.4.1 (20/03/2021)
- Security patches
- Security patches
- Documentation improvements
---
## v1.4.0 (24/02/2021)
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
---
## v1.3.3 (18/02/2021)
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
---
## v1.3.2 (18/02/2021)
- [**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)
---
## v1.3.1 (15/02/2021)
[enhancement] Enhance delete links with inline confirmation
---
## v1.3.0 (14/02/2021)
- [**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)
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
@@ -163,21 +255,40 @@
---
## 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)
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
---
## v1.2.0 (09/01/2021)
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
---
## v1.1.1 (01/01/2021)
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
---
## v1.1.0 (31/12/2020)
- [**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**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
---
## v1.0.0 (31/12/2020)
- [**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)
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
- [**enhancement**] API for app development [#24](https://github.com/sissbruecker/linkding/issues/24)
- [**enhancement**] Enhancement: detect duplicates at entry time [#23](https://github.com/sissbruecker/linkding/issues/23)
- [**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**] 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)

View File

@@ -129,6 +129,8 @@ This section lists community projects around using linkding, in alphabetical ord
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
## Development

View File

@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 1.9.x | :white_check_mark: |
| 1.10.x | :white_check_mark: |
## Reporting a Vulnerability

Binary file not shown.

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.admin import AdminSite
from django.contrib.auth.admin import UserAdmin
@@ -7,7 +9,7 @@ from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, Tag, UserProfile, Toast
from bookmarks.models import Bookmark, Tag, UserProfile, Toast, FeedToken
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -19,9 +21,9 @@ class LinkdingAdminSite(AdminSite):
class AdminBookmark(admin.ModelAdmin):
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
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',)
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):
for bookmark in queryset:
@@ -43,6 +45,24 @@ class AdminBookmark(admin.ModelAdmin):
bookmarks_count,
) % 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):
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
@@ -101,9 +121,18 @@ class AdminToast(admin.ModelAdmin):
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.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
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

@@ -19,6 +19,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
'description',
'website_title',
'website_description',
'is_archived',
'unread',
'tag_names',
'date_added',
'date_modified'
@@ -33,6 +35,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
@@ -41,14 +45,22 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url']
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread']
tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data):
instance.url = validated_data['url']
instance.title = validated_data['title']
instance.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'])
# Update fields if they were provided in the payload
for key in ['url', 'title', 'description', 'unread']:
if key in validated_data:
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'])

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

@@ -1,3 +1,5 @@
import binascii
import os
from typing import List
from django import forms
@@ -50,7 +52,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
unread = models.BooleanField(default=True)
unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
date_added = models.DateTimeField()
date_modified = models.DateTimeField()
@@ -97,12 +99,13 @@ class BookmarkForm(forms.ModelForm):
required=False)
description = forms.CharField(required=False,
widget=forms.Textarea())
unread = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'auto_close']
fields = ['url', 'tag_string', 'title', 'description', 'unread', 'auto_close']
class UserProfile(models.Model):
@@ -166,3 +169,27 @@ class Toast(models.Model):
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

@@ -60,6 +60,11 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
query_set = query_set.filter(
tags=None
)
# Unread bookmarks
if query['unread']:
query_set = query_set.filter(
unread=True
)
# Sort by date added
query_set = query_set.order_by('-date_added')
@@ -102,9 +107,11 @@ def _parse_query_string(query_string):
# Special search commands
untagged = '!untagged' in keywords
unread = '!unread' in keywords
return {
'search_terms': search_terms,
'tag_names': tag_names,
'untagged': untagged,
'unread': unread,
}

View File

@@ -93,6 +93,7 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
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)
@@ -172,6 +173,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
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)
@@ -190,7 +192,7 @@ def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
bookmark.unread = False
bookmark.unread = netscape_bookmark.to_read
if netscape_bookmark.title:
bookmark.title = netscape_bookmark.title
if netscape_bookmark.description:

View File

@@ -10,6 +10,7 @@ class NetscapeBookmark:
description: str
date_added: str
tag_string: str
to_read: bool
class BookmarkParser(HTMLParser):
@@ -24,6 +25,7 @@ class BookmarkParser(HTMLParser):
self.tags = ''
self.title = ''
self.description = ''
self.toread = ''
def handle_starttag(self, tag: str, attrs: list):
name = 'handle_start_' + tag.lower()
@@ -56,6 +58,7 @@ class BookmarkParser(HTMLParser):
description='',
date_added=self.add_date,
tag_string=self.tags,
to_read=self.toread == '1'
)
def handle_a_data(self, data):
@@ -75,6 +78,7 @@ class BookmarkParser(HTMLParser):
self.tags = ''
self.title = ''
self.description = ''
self.toread = ''
def parse(html: str) -> List[NetscapeBookmark]:

View File

@@ -8,6 +8,7 @@ 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__)
@@ -38,10 +39,10 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
logger.debug(f'Create web archive link for bookmark: {bookmark}...')
wayback = waybackpy.Url(bookmark.url)
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT)
try:
archive = wayback.save()
archive.save()
except WaybackError as error:
logger.exception(f'Error creating web archive link for bookmark: {bookmark}...', exc_info=error)
raise

View File

@@ -34,7 +34,8 @@ def load_website_metadata(url: str):
def load_page(url: str):
r = requests.get(url, timeout=10)
headers = fake_request_headers()
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
@@ -42,3 +43,16 @@ def load_page(url: str):
# 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,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

@@ -33,10 +33,8 @@
{# Tag list #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header align-baseline">
<div class="content-area-header">
<h2>Tags</h2>
<div class="spacer"></div>
<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>
</div>
{% tag_cloud tags %}
</section>

View File

@@ -9,7 +9,7 @@
<i class="form-icon"></i>
</label>
<div class="title truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" 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 class="description truncate">
{% if bookmark.tag_names %}
@@ -65,6 +65,11 @@
{% 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 %}
</div>
</li>
{% endfor %}

View File

@@ -49,7 +49,7 @@
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
@@ -65,6 +65,16 @@
</div>
{{ form.description.errors }}
</div>
<div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }}
<i class="form-icon"></i>
<span>Mark as unread</span>
</label>
<div class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div>
</div>
<br/>
<div class="form-group">
{% if auto_close %}

View File

@@ -33,10 +33,8 @@
{# Tag list #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header align-baseline">
<div class="content-area-header">
<h2>Tags</h2>
<div class="spacer"></div>
<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>
</div>
{% tag_cloud tags %}
</section>

View File

@@ -7,6 +7,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">

View File

@@ -1,7 +1,28 @@
{# Basic menu list #}
<div class="hide-md">
<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>
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div>
@@ -21,7 +42,16 @@
<!-- menu component -->
<ul class="menu">
<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>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>

View File

@@ -98,13 +98,29 @@
</section>
{# About section #}
<section class="content-area">
<section class="content-area about">
<h2>About</h2>
<p>Version: {{ app_version }}</p>
<p>
Code: <a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a>
</p>
<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>

View File

@@ -38,9 +38,31 @@
</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_tokenproxy_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
<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>
{% endblock %}

View File

@@ -1,7 +1,6 @@
import random
import logging
from dataclasses import dataclass
from typing import Optional, List
from typing import List
from django.contrib.auth.models import User
from django.utils import timezone
@@ -23,6 +22,7 @@ class BookmarkFactoryMixin:
def setup_bookmark(self,
is_archived: bool = False,
unread: bool = False,
tags=None,
user: User = None,
url: str = '',
@@ -32,6 +32,8 @@ class BookmarkFactoryMixin:
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:
@@ -49,6 +51,7 @@ class BookmarkFactoryMixin:
date_modified=timezone.now(),
owner=user,
is_archived=is_archived,
unread=unread,
web_archive_snapshot_url=web_archive_snapshot_url,
)
bookmark.save()
@@ -83,6 +86,11 @@ class LinkdingApiTestCase(APITestCase):
self.assertEqual(response.status_code, expected_status_code)
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):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)
@@ -90,12 +98,19 @@ class LinkdingApiTestCase(APITestCase):
class BookmarkHtmlTag:
def __init__(self, href: str = '', title: str = '', description: str = '', add_date: str = '', tags: str = ''):
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:
@@ -104,7 +119,8 @@ class ImportTestMixin:
<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 ''}>
{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 ''}

View File

@@ -84,6 +84,16 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
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()

View File

@@ -18,7 +18,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html
)
@@ -27,7 +27,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html,
count=0
)

View File

@@ -20,6 +20,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'unread': False,
}
return {**form_data, **overrides}
@@ -35,10 +36,21 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
def test_should_mark_bookmark_as_unread(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)
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
@@ -65,7 +77,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'value="{0}" id="id_title">'.format(bookmark.title),
html)
self.assertInHTML('<textarea name="description" cols="40" rows="4" class="form-input" id="id_description">{0}'
self.assertInHTML('<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">{0}'
'</textarea>'.format(bookmark.description),
html)

View File

@@ -18,7 +18,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html
)
@@ -27,7 +27,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html,
count=0
)
@@ -156,8 +156,3 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_should_show_link_for_untagged_bookmarks(self):
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>')

View File

@@ -19,6 +19,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'unread': False,
'auto_close': '',
}
return {**form_data, **overrides}
@@ -35,10 +36,21 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[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_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode()
@@ -64,7 +76,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">',html)
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()

View File

@@ -34,6 +34,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['description'] = bookmark.description
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
expectation['unread'] = bookmark.unread
expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
@@ -49,6 +51,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'is_archived': False,
'unread': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
@@ -56,6 +60,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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.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)
@@ -92,6 +98,55 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_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_bookmark_minimal_payload_does_not_archive(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_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
@@ -104,6 +159,68 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
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_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 = {'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):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -10,9 +10,14 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
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">{bookmark.resolved_title}</a>',
f'''
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener"
class="{'text-italic' if unread else ''}">{bookmark.resolved_title}</a>
''',
html
)
@@ -114,6 +119,15 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
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)

View File

@@ -10,15 +10,13 @@ from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class MockWaybackUrl:
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
@@ -52,7 +50,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
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()
@@ -60,7 +58,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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, 'Url', return_value=MockWaybackUrl('https://example.com')) as mock_wayback_url:
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)
@@ -69,7 +67,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_handle_wayback_save_error(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'Url',
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)
@@ -78,7 +76,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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, 'Url', return_value=MockWaybackUrl('https://other.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()
@@ -88,7 +86,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.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()
@@ -117,7 +115,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark()
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
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)
@@ -131,7 +129,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.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)
@@ -149,7 +147,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
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)

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

@@ -21,6 +21,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
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)
@@ -34,52 +35,16 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
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, 3)
self.assertEqual(result.success, 3)
self.assertEqual(result.failed, 0)
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 3)
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://foo.com', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
]
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://foo.com', title='Updated Foo title', description='Updated Foo description',
add_date='222', tags='new-tag'),
BookmarkHtmlTag(href='https://bar.com', title='Updated Bar title', description='Updated Bar description',
add_date='333', tags='updated-bar-tag, updated-other-tag'),
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, 4)
self.assertEqual(result.success, 4)
@@ -90,6 +55,48 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
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'),
@@ -111,6 +118,19 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
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'),
@@ -139,6 +159,17 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
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'),

View File

@@ -17,15 +17,18 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual(bookmark.date_added, html_tag.add_date)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.tag_string, html_tag.tags)
self.assertEqual(bookmark.to_read, html_tag.to_read)
def test_parse_bookmarks(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
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='3', to_read=True),
]
html = self.render_html(html_tags)
bookmarks = parse(html)

View File

@@ -343,6 +343,32 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmarks(self.user, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
unread_bookmarks = [
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
]
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_bookmarks(self.user, '!unread')
self.assertCountEqual(list(query), unread_bookmarks)
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
unread_bookmarks = [
self.setup_bookmark(is_archived=True, unread=True),
self.setup_bookmark(is_archived=True, unread=True),
self.setup_bookmark(is_archived=True, unread=True),
]
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_archived_bookmarks(self.user, '!unread')
self.assertCountEqual(list(query), unread_bookmarks)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data()

View File

@@ -1,8 +1,14 @@
import random
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock
import requests
from requests import RequestException
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -38,3 +44,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML(f'''
<tr>
<td>Version</td>
<td>{get_version_info(random.random())}</td>
</tr>
''', html)
def test_get_version_info_just_displays_latest_when_versions_are_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v{app_version}'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest)')
def test_get_version_info_shows_latest_version_when_versions_are_not_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v123.0.1'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest: 123.0.1)')
def test_get_version_info_silently_ignores_request_errors(self):
with patch.object(requests, 'get', side_effect=RequestException()):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version}')

View File

@@ -3,6 +3,7 @@ from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import FeedToken
class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -38,3 +39,28 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
self.client.get(reverse('bookmarks:settings.integrations'))
self.assertEqual(Token.objects.count(), 1)
def test_should_generate_feed_token_if_not_exists(self):
self.assertEqual(FeedToken.objects.count(), 0)
self.client.get(reverse('bookmarks:settings.integrations'))
self.assertEqual(FeedToken.objects.count(), 1)
token = FeedToken.objects.first()
self.assertEqual(token.user, self.user)
def test_should_not_generate_feed_token_if_exists(self):
FeedToken.objects.get_or_create(user=self.user)
self.assertEqual(FeedToken.objects.count(), 1)
self.client.get(reverse('bookmarks:settings.integrations'))
self.assertEqual(FeedToken.objects.count(), 1)
def test_should_display_feed_urls(self):
response = self.client.get(reverse('bookmarks:settings.integrations'))
html = response.content.decode()
token = FeedToken.objects.first()
self.assertInHTML(f'<a href="http://testserver/feeds/{token.key}/all">All bookmarks</a>', html)
self.assertInHTML(f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>', html)

View File

@@ -4,6 +4,7 @@ from django.views.generic import RedirectView
from bookmarks.api.routes import router
from bookmarks import views
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
app_name = 'bookmarks'
urlpatterns = [
@@ -25,5 +26,8 @@ urlpatterns = [
# Toasts
path('toasts/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'),
# API
path('api/', include(router.urls), name='api')
path('api/', include(router.urls), name='api'),
# Feeds
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
]

View File

@@ -162,6 +162,16 @@ def unarchive(request, bookmark_id: int):
unarchive_bookmark(bookmark)
def mark_as_read(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404('Bookmark does not exist')
bookmark.unread = False
bookmark.save()
@login_required
def action(request):
# Determine action
@@ -171,6 +181,8 @@ def action(request):
unarchive(request, request.POST['unarchive'])
if 'remove' in request.POST:
remove(request, request.POST['remove'])
if 'mark_as_read' in request.POST:
mark_as_read(request, request.POST['mark_as_read'])
if 'bulk_archive' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
archive_bookmarks(bookmark_ids, request.user)

View File

@@ -1,5 +1,8 @@
import logging
import time
from functools import lru_cache
import requests
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, HttpResponse
@@ -7,7 +10,7 @@ from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm
from bookmarks.models import UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter
from bookmarks.services import importer
@@ -17,10 +20,11 @@ logger = logging.getLogger(__name__)
try:
with open("version.txt", "r") as f:
app_version = f.read().strip("\n")
except Exception as exc:
except Exception as exc:
logging.exception(exc)
pass
@login_required
def general(request):
if request.method == 'POST':
@@ -32,21 +36,53 @@ def general(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash())
return render(request, 'settings/general.html', {
'form': form,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'app_version': app_version
'version_info': version_info,
})
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):
latest_version = None
try:
latest_version_url = 'https://api.github.com/repos/sissbruecker/linkding/releases/latest'
response = requests.get(latest_version_url, timeout=5)
json = response.json()
latest_version = json['name'][1:]
except requests.exceptions.RequestException:
pass
latest_version_info = ''
if latest_version == app_version:
latest_version_info = ' (latest)'
elif latest_version is not None:
latest_version_info = f' (latest: {latest_version})'
return f'{app_version}{latest_version_info}'
def get_ttl_hash(seconds=3600):
"""Return the same value within `seconds` time period"""
return round(time.time() / seconds)
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0]
feed_token = FeedToken.objects.get_or_create(user=request.user)[0]
all_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.all', args=[feed_token.key]))
unread_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.unread', args=[feed_token.key]))
return render(request, 'settings/integrations.html', {
'application_url': application_url,
'api_token': api_token.key
'api_token': api_token.key,
'all_feed_url': all_feed_url,
'unread_feed_url': unread_feed_url,
})

View File

@@ -24,7 +24,7 @@ The following resources are available:
GET /api/bookmarks/
```
List bookmarks.
List bookmarks.
Parameters:
@@ -47,6 +47,8 @@ Example response:
"description": "Example description",
"website_title": "Website title",
"website_description": "Website description",
"is_archived": false,
"unread": false,
"tag_names": [
"tag1",
"tag2"
@@ -65,7 +67,7 @@ Example response:
GET /api/bookmarks/archived/
```
List archived bookmarks.
List archived bookmarks.
Parameters and response are the same as for the regular list endpoint.
@@ -83,7 +85,34 @@ Retrieves a single bookmark by ID.
POST /api/bookmarks/
```
Creates a new bookmark. Tags are simply assigned using their names.
Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive.
Example payload:
```json
{
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"is_archived": false,
"unread": false,
"tag_names": [
"tag1",
"tag2"
]
}
```
**Update**
```
PUT /api/bookmarks/<id>/
```
Updates a bookmark.
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
Tags are simply assigned using their names.
Example payload:
@@ -99,13 +128,15 @@ Example payload:
}
```
**Update**
**Patch**
```
PUT /api/bookmarks/<id>/
PATCH /api/bookmarks/<id>/
```
Updates a bookmark. Tags are simply assigned using their names.
Updates a bookmark partially.
Allows to modify individual fields of a bookmark.
Tags are simply assigned using their names.
Example payload:
@@ -201,4 +232,3 @@ Example payload:
"name": "example"
}
```

View File

@@ -13,7 +13,7 @@ As described in the installation docs, you should mount the `/etc/linkding/data`
Below, we describe several methods to create a backup of the database:
- Manual backup using the export function from the UI
- Create a copy of the SQLite databse with the SQLite backup function
- Create a copy of the SQLite database with the SQLite backup function
- Create a plain textfile with the contents of the SQLite database with the SQLite dump function
Choose the method that fits you best.

View File

@@ -13,7 +13,7 @@ Chrome on Android does not permit running bookmarklets in the same way you can o
Create a bookmark of your linkding deployment by clicking the star icon which you find in the three dots menu in the top right. Next you have to edit the bookmark. Edit the URL and replace it it with the bookmarklet code of your instance and give it an easy to type name like `bm` for bookmark or `ld` for linkding:
```
javascript: (function() { var bookmarkUrl = window.location; var applicationUrl = 'http://<YOUR_INSTANCE_HERE>/bookmarks/new'; applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '&auto_close'; window.open(applicationUrl);})();
javascript:window.open(`http://<YOUR_INSTANCE_HERE>/bookmarks/new?url=${encodeURIComponent(window.location)}&auto_close`)
```
Now when you are browsing the web and you want to save the current page as a bookmark to your linkding instance simply type `bm` into the address bar and select it from the results. The bookmarklet code will trigger and you will be redirected so save the current page.

208
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "linkding",
"version": "1.8.6",
"version": "1.11.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "linkding",
"version": "1.8.6",
"version": "1.11.1",
"license": "ISC",
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.2",
@@ -15,7 +15,7 @@
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"spectre.css": "^0.5.8",
"svelte": "^3.46.4"
"svelte": "^3.49.0"
}
},
"node_modules/@babel/code-frame": {
@@ -41,6 +41,58 @@
"js-tokens": "^4.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
"dependencies": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
"integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@rollup/plugin-commonjs": {
"version": "21.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
@@ -119,6 +171,17 @@
"@types/node": "*"
}
},
"node_modules/acorn": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
@@ -145,9 +208,9 @@
}
},
"node_modules/buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/builtin-modules": {
"version": "3.2.0",
@@ -524,23 +587,6 @@
}
},
"node_modules/source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"engines": {
"node": ">= 8"
}
},
"node_modules/source-map-support": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/source-map-support/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
@@ -548,6 +594,15 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
@@ -570,21 +625,22 @@
}
},
"node_modules/svelte": {
"version": "3.46.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.49.0.tgz",
"integrity": "sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA==",
"engines": {
"node": ">= 8"
}
},
"node_modules/terser": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
"integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
"dependencies": {
"@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.19"
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
@@ -623,6 +679,49 @@
"js-tokens": "^4.0.0"
}
},
"@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
"requires": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="
},
"@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw=="
},
"@jridgewell/source-map": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
"requires": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"@jridgewell/trace-mapping": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
"integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
"requires": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@rollup/plugin-commonjs": {
"version": "21.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
@@ -685,6 +784,11 @@
"@types/node": "*"
}
},
"acorn": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
@@ -708,9 +812,9 @@
}
},
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"builtin-modules": {
"version": "3.2.0",
@@ -1000,24 +1104,17 @@
}
},
"source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"source-map-support": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
"sourcemap-codec": {
@@ -1039,18 +1136,19 @@
}
},
"svelte": {
"version": "3.46.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg=="
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.49.0.tgz",
"integrity": "sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA=="
},
"terser": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
"integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
"requires": {
"@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.19"
"source-map-support": "~0.5.20"
}
},
"wrappy": {

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.10.0",
"version": "1.12.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -25,6 +25,6 @@
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"spectre.css": "^0.5.8",
"svelte": "^3.46.4"
"svelte": "^3.49.0"
}
}

View File

@@ -3,7 +3,7 @@ beautifulsoup4==4.7.1
certifi==2019.6.16
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
Django==3.2.13
Django==3.2.14
django-background-tasks==1.2.5
django-compat==1.0.15
django-generate-secret-key==1.0.2
@@ -22,4 +22,4 @@ supervisor==4.2.2
typing-extensions==3.10.0.0
urllib3==1.26.6
uWSGI==2.0.18
waybackpy==2.4.3
waybackpy==3.0.6

View File

@@ -4,7 +4,7 @@ certifi==2019.6.16
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
coverage==5.5
Django==3.2.13
Django==3.2.14
django-appconf==1.0.4
django-background-tasks==1.2.5
django-compat==1.0.15
@@ -28,4 +28,4 @@ soupsieve==1.9.2
sqlparse==0.4.2
typing-extensions==3.10.0.0
urllib3==1.26.6
waybackpy==2.4.3
waybackpy==3.0.6

View File

@@ -1 +1 @@
1.10.0
1.12.0