Compare commits

...

33 Commits

Author SHA1 Message Date
Sascha Ißbrücker
695b0dc300 Bump version 2024-06-16 11:45:06 +02:00
Sascha Ißbrücker
fe40139838 Make backup include preview images 2024-06-16 10:37:02 +02:00
Sascha Ißbrücker
44b49a4cfe Preview auto tags in bookmark form (#737) 2024-06-16 10:04:38 +02:00
dependabot[bot]
469883a674 --- (#740)
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-16 10:01:36 +02:00
Viacheslav Slinko
fa5f78cf71 Automatically add tags to bookmarks based on URL pattern (#736)
* [WIP] DSL

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* dsl2

* full feature

* upd

* upd

* upd

* upd

* rename to auto_tagging_rules

* update migration after rebase

* add REST API tests

* improve settings view

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-17 09:39:46 +02:00
Viacheslav Slinko
e03f536925 Add option for disabling tag grouping (#735)
* Configurable tag grouping

* update tag group name

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-17 08:38:08 +02:00
Viacheslav Slinko
a92a35cfb8 Thumbnails lazy loading (#734) 2024-05-16 09:44:38 +02:00
Viacheslav Slinko
ff334e0888 Hide tooltip on mobile (#733) 2024-05-15 09:06:30 +02:00
Sascha Ißbrücker
0f9ba57fef Load missing thumbnails after enabling the feature (#725) 2024-05-10 09:50:19 +02:00
Viacheslav Slinko
b4376a9ff1 Load bookmark thumbnails after import (#724)
* Update thumbnails after import

* Safer way to download thumbnails

* small test improvements

* add missing tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-10 09:19:00 +02:00
Viacheslav Slinko
87cd4061cb Add support for bookmark thumbnails (#721)
* Preview Image

* fix tests

* add test

* download preview image

* relative path

* gst

* details view

* fix tests

* Improve preview image styles

* Remove preview image URL from model

* Revert form changes

* update tests

* make it work in uwsgi

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-07 18:58:52 +02:00
Sascha Ißbrücker
e2415f652b Remove leading/trailing whitespace in description 2024-04-21 18:56:01 +02:00
Sascha Ißbrücker
9cf5eb5ec0 Use temp dir for favicon loader tests 2024-04-20 19:45:57 +02:00
Sascha Ißbrücker
023a213ba6 Update CHANGELOG.md 2024-04-20 19:23:21 +02:00
Sascha Ißbrücker
23d97db016 Bump version 2024-04-20 14:11:14 +02:00
dependabot[bot]
0fb1bbd0e2 Bump sqlparse from 0.4.4 to 0.5.0 (#704)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.4 to 0.5.0.
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.4...0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-20 12:17:01 +02:00
Sascha Ißbrücker
5d2acca122 Allow uploading custom files for bookmarks (#713) 2024-04-20 12:14:11 +02:00
Sascha Ißbrücker
0cbaf927e4 Add reader mode (#703)
* Add reader mode view

* Show link for latest snapshot instead
2024-04-20 09:18:57 +02:00
ab623
0586983602 Show proper name for bookmark assets in admin (#708) 2024-04-17 23:18:23 +02:00
ab623
9dc3521d5e Add option for marking bookmarks as unread by default (#706)
* Added new option to set Mark as unread with a default

* Added additional test

* tweak test a bit

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-04-17 23:08:18 +02:00
Sascha Ißbrücker
a1822e2091 Close bookmark details with escape (#702) 2024-04-15 19:41:18 +02:00
Sascha Ißbrücker
22ffecbb9d Make blocking cookie banners more reliable (#699) 2024-04-15 19:33:25 +02:00
Sascha Ißbrücker
d9096eacd6 Update CHANGELOG.md 2024-04-14 21:10:27 +02:00
Sascha Ißbrücker
e50912df12 Bump version 2024-04-14 20:48:30 +02:00
Sascha Ißbrücker
393d688247 Fix directory name 2024-04-14 20:31:53 +02:00
Sascha Ißbrücker
6e38587174 Fix missing home directory in background tasks 2024-04-14 20:28:39 +02:00
dependabot[bot]
123c6fe02a Bump idna from 3.6 to 3.7 (#694)
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 14:41:53 +02:00
Sascha Ißbrücker
1b7731e506 Refresh file list when there are queued snapshots (#697)
* add destroy hook

* refresh details modal in interval

* refactor to refresh assets list

* disable create snapshot button when there is a pending snapshot
2024-04-14 14:41:22 +02:00
Sascha Ißbrücker
df9f0095cc Add button for creating missing HTML snapshots (#696)
* add button for creating missing HTML snapshots

* refactor messages in settings view

* show alternative text when there are no missing snapshots
2024-04-14 13:21:15 +02:00
Sascha Ißbrücker
25470edb2c Remove ads and cookie banners from HTML snapshots (#695)
* integrate ublock with single-file

* reuse chromium profile
2024-04-14 13:09:46 +02:00
Sascha Ißbrücker
22a1fc80ad Update README.md 2024-04-14 06:44:08 +02:00
Sascha Ißbrücker
65f0eb2a04 Refactor client-side fetch logic (#693)
* extract generic behaviors

* preserve query string when refreshing content

* refactor details modal refresh

* refactor bulk edit

* update tests

* restore tag modal

* Make IntelliJ aware of custom attributes

* improve e2e test coverage
2024-04-11 19:07:20 +02:00
Sascha Ißbrücker
82f86bf537 Update CHANGELOG.md 2024-04-09 20:46:59 +02:00
94 changed files with 5648 additions and 791 deletions

5
.gitignore vendored
View File

@@ -191,3 +191,8 @@ typings/
/tmp /tmp
# Database file # Database file
/data /data
# ublock + chromium
/uBlock0.chromium
/chromium-profile
# direnv
/.direnv

View File

@@ -1,5 +1,54 @@
# Changelog # Changelog
## v1.30.0 (20/04/2024)
### What's Changed
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
### New Contributors
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
---
## v1.29.0 (14/04/2024)
### What's Changed
* Remove ads and cookie banners from HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/695
* Add button for creating missing HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/696
* Refresh file list when there are queued snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/697
* Bump idna from 3.6 to 3.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/694
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.28.0...v1.29.0
---
## v1.28.0 (09/04/2024)
### What's Changed
* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684
* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686
* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687
* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688
* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691
* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689
### New Contributors
* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684
* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0
---
## v1.27.1 (07/04/2024) ## v1.27.1 (07/04/2024)
### What's Changed ### What's Changed

View File

@@ -45,7 +45,7 @@ The name comes from:
- Admin panel for user self-service and raw data access - Admin panel for user self-service and raw data access
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it) **Demo:** https://demo.linkding.link/
**Screenshot:** **Screenshot:**

View File

@@ -200,9 +200,13 @@ class AdminBookmark(admin.ModelAdmin):
class AdminBookmarkAsset(admin.ModelAdmin): class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("display_name", "date_created", "status") @admin.display(description="Display Name")
def custom_display_name(self, obj):
return str(obj)
list_display = ("custom_display_name", "date_created", "status")
search_fields = ( search_fields = (
"display_name", "custom_display_name",
"file", "file",
) )
list_filter = ("status",) list_filter = ("status",)

View File

@@ -11,6 +11,7 @@ from bookmarks.api.serializers import (
UserProfileSerializer, UserProfileSerializer,
) )
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
from bookmarks.services import auto_tagging
from bookmarks.services.bookmarks import ( from bookmarks.services.bookmarks import (
archive_bookmark, archive_bookmark,
unarchive_bookmark, unarchive_bookmark,
@@ -99,13 +100,26 @@ class BookmarkViewSet(
# Either return metadata from existing bookmark, or scrape from URL # Either return metadata from existing bookmark, or scrape from URL
if bookmark: if bookmark:
metadata = WebsiteMetadata( metadata = WebsiteMetadata(
url, bookmark.website_title, bookmark.website_description url,
bookmark.website_title,
bookmark.website_description,
None,
) )
else: else:
metadata = website_loader.load_website_metadata(url) metadata = website_loader.load_website_metadata(url)
# Return tags that would be automatically applied to the bookmark
profile = request.user.profile
auto_tags = []
if profile.auto_tagging_rules:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
return Response( return Response(
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()}, {
"bookmark": existing_bookmark_data,
"metadata": metadata.to_dict(),
"auto_tags": auto_tags,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )

View File

@@ -1,3 +1,4 @@
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import sync_playwright, expect
@@ -33,6 +34,11 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
overlay.click(position={"x": 0, "y": 0}) overlay.click(position={"x": 0, "y": 0})
expect(details_modal).to_be_hidden() expect(details_modal).to_be_hidden()
# close with escape
details_modal = self.open_details_modal(bookmark)
self.page.keyboard.press("Escape")
expect(details_modal).to_be_hidden()
def test_toggle_archived(self): def test_toggle_archived(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -44,14 +50,17 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click() details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertReloads(0)
# unarchive # unarchive
url = reverse("bookmarks:archived") url = reverse("bookmarks:archived")
self.page.goto(self.live_server_url + url) self.page.goto(self.live_server_url + url)
self.resetReloads()
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click() details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertReloads(0)
def test_toggle_unread(self): def test_toggle_unread(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -66,11 +75,13 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal.get_by_text("Unread").click() details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).to_be_visible() expect(bookmark_item.get_by_text("Unread")).to_be_visible()
self.assertReloads(0)
# mark as read # mark as read
details_modal.get_by_text("Unread").click() details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible() expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
self.assertReloads(0)
def test_toggle_shared(self): def test_toggle_shared(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -89,11 +100,13 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal.get_by_text("Shared").click() details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).to_be_visible() expect(bookmark_item.get_by_text("Shared")).to_be_visible()
self.assertReloads(0)
# unshare bookmark # unshare bookmark
details_modal.get_by_text("Shared").click() details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible() expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
self.assertReloads(0)
def test_edit_return_url(self): def test_edit_return_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -131,3 +144,33 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertEqual(Bookmark.objects.count(), 0) self.assertEqual(Bookmark.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_remove_snapshot(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
asset_list = details_modal.locator(".assets")
# No snapshots initially
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
expect(snapshot).not_to_be_visible()
# Create snapshot
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
self.assertReloads(0)
# Has new snapshots
expect(snapshot).to_be_visible()
# Create snapshot
asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click()
# Snapshot is removed
expect(snapshot).not_to_be_visible()
self.assertReloads(0)

View File

@@ -85,3 +85,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
page.get_by_label("URL").fill(bookmark.url) page.get_by_label("URL").fill(bookmark.url)
expect(details).to_have_attribute("open", value="") expect(details).to_have_attribute("open", value="")
def test_create_should_preview_auto_tags(self):
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = "github.com dev github"
profile.save()
with sync_playwright() as p:
# Open page with URL that should have auto tags
browser = self.setup_browser(p)
page = browser.new_page()
url = self.live_server_url + reverse("bookmarks:new")
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
page.goto(url)
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
expect(auto_tags_hint).to_be_visible()
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
# Change to URL without auto tags
page.get_by_label("URL").fill("https://example.com")
expect(auto_tags_hint).to_be_hidden()

View File

@@ -194,7 +194,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input") checkboxes = page.locator("label.bulk-edit-checkbox input")
self.assertEqual(6, checkboxes.count()) self.assertEqual(6, checkboxes.count())
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()
@@ -264,13 +264,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
# Hide select across by toggling a single bookmark # Hide select across by toggling a single bookmark
self.locate_bookmark("Bookmark 1").locator( self.locate_bookmark("Bookmark 1").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible() expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked # Show select across again, verify it is unchecked
self.locate_bookmark("Bookmark 1").locator( self.locate_bookmark("Bookmark 1").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked() expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
@@ -297,7 +297,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
# Verify bulk edit checkboxes are reset # Verify bulk edit checkboxes are reset
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input") checkboxes = page.locator("label.bulk-edit-checkbox input")
self.assertEqual(31, checkboxes.count()) self.assertEqual(31, checkboxes.count())
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()

View File

@@ -169,7 +169,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator( self.locate_bookmark("Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Archive") self.select_bulk_action("Archive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -187,7 +187,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator( self.locate_bookmark("Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -230,7 +230,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator( self.locate_bookmark("Archived Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Unarchive") self.select_bulk_action("Unarchive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -248,7 +248,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator( self.locate_bookmark("Archived Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()

View File

@@ -0,0 +1,76 @@
from django.test import override_settings
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect, Locator
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
def test_show_modal_close_modal(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
)
modal_trigger.click()
# verify modal is visible
modal = page.locator(".modal")
expect(modal).to_be_visible()
expect(modal.locator(".modal-title")).to_have_text("Tags")
# close with close button
modal.locator("button.close").click()
expect(modal).to_be_hidden()
# open modal again
modal_trigger.click()
# close with backdrop
backdrop = modal.locator(".modal-overlay")
backdrop.click(position={"x": 0, "y": 0})
expect(modal).to_be_hidden()
def test_select_tag(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
)
modal_trigger.click()
# verify tags are displayed
modal = page.locator(".modal")
unselected_tags = modal.locator(".unselected-tags")
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
# select tag
unselected_tags.get_by_text("cooking").click()
# open modal again
modal_trigger.click()
# verify tag is selected, other tag is not visible anymore
selected_tags = modal.locator(".selected-tags")
expect(selected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()

View File

@@ -39,6 +39,9 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def assertReloads(self, count: int): def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count) self.assertEqual(self.num_loads, count)
def resetReloads(self):
self.num_loads = 0
def locate_bookmark_list(self): def locate_bookmark_list(self):
return self.page.locator("ul[ld-bookmark-list]") return self.page.locator("ul[ld-bookmark-list]")
@@ -62,7 +65,7 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
return self.page.locator(".bulk-edit-bar") return self.page.locator(".bulk-edit-bar")
def locate_bulk_edit_select_all(self): def locate_bulk_edit_select_all(self):
return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]") return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
def locate_bulk_edit_select_across(self): def locate_bulk_edit_select_across(self):
return self.locate_bulk_edit_bar().locator("label.select-across") return self.locate_bulk_edit_bar().locator("label.select-across")

View File

@@ -1,67 +1,8 @@
import { registerBehavior, swapContent } from "./index"; import { Behavior, registerBehavior } from "./index";
class BookmarkPage { class BookmarkItem extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.form = element.querySelector("form.bookmark-actions");
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
document.addEventListener("bookmark-page-refresh", () => {
this.refresh();
});
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.form.action;
const formData = new FormData(this.form);
formData.append(event.submitter.name, event.submitter.value);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
await this.refresh();
}
async refresh() {
const query = window.location.search;
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
const tagsUrl = this.element.getAttribute("tags-url");
Promise.all([
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swapContent(this.bookmarkList, bookmarkListHtml);
swapContent(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(
"ul[data-bookmarks-total]",
);
const bookmarksTotal =
(listElement && listElement.dataset.bookmarksTotal) || 0;
this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", {
bubbles: true,
detail: { bookmarksTotal },
}),
);
});
}
}
registerBehavior("ld-bookmark-page", BookmarkPage);
class BookmarkItem {
constructor(element) {
this.element = element;
// Toggle notes // Toggle notes
const notesToggle = element.querySelector(".toggle-notes"); const notesToggle = element.querySelector(".toggle-notes");
@@ -72,9 +13,11 @@ class BookmarkItem {
// Add tooltip to title if it is truncated // Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a"); const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span"); const titleSpan = titleAnchor.querySelector("span");
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) { requestAnimationFrame(() => {
titleAnchor.dataset.tooltip = titleSpan.textContent; if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
} titleAnchor.dataset.tooltip = titleSpan.textContent;
}
});
} }
onToggleNotes(event) { onToggleNotes(event) {

View File

@@ -1,46 +1,60 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class BulkEdit { class BulkEdit extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.active = false; this.active = false;
this.actionSelect = element.querySelector("select[name='bulk_action']");
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
this.selectAcross = element.querySelector("label.select-across");
element.addEventListener( this.onToggleActive = this.onToggleActive.bind(this);
"bulk-edit-toggle-active", this.onToggleAll = this.onToggleAll.bind(this);
this.onToggleActive.bind(this), this.onToggleBookmark = this.onToggleBookmark.bind(this);
); this.onActionSelected = this.onActionSelected.bind(this);
element.addEventListener(
"bulk-edit-toggle-all",
this.onToggleAll.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-bookmark",
this.onToggleBookmark.bind(this),
);
element.addEventListener(
"bookmark-list-updated",
this.onListUpdated.bind(this),
);
this.actionSelect.addEventListener( this.init();
"change", // Reset when bookmarks are refreshed
this.onActionSelected.bind(this), document.addEventListener("refresh-bookmark-list-done", () => this.init());
);
} }
get allCheckbox() { init() {
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input"); // Update elements
} this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
this.actionSelect = this.element.querySelector(
"select[name='bulk_action']",
);
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
this.selectAcross = this.element.querySelector("label.select-across");
this.allCheckbox = this.element.querySelector(
".bulk-edit-checkbox.all input",
);
this.bookmarkCheckboxes = Array.from(
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
get bookmarkCheckboxes() { // Remove previous listeners if elements are the same
return [ this.activeToggle.removeEventListener("click", this.onToggleActive);
...this.element.querySelectorAll( this.actionSelect.removeEventListener("change", this.onActionSelected);
"[ld-bulk-edit-checkbox]:not([all]) input", this.allCheckbox.removeEventListener("change", this.onToggleAll);
), this.bookmarkCheckboxes.forEach((checkbox) => {
]; checkbox.removeEventListener("change", this.onToggleBookmark);
});
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
// Add new listeners
this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", this.onToggleBookmark);
});
} }
onToggleActive() { onToggleActive() {
@@ -81,16 +95,6 @@ class BulkEdit {
} }
} }
onListUpdated(event) {
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const total = event.detail.bookmarksTotal;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
updateSelectAcross(allChecked) { updateSelectAcross(allChecked) {
if (allChecked) { if (allChecked) {
this.selectAcross.classList.remove("d-none"); this.selectAcross.classList.remove("d-none");
@@ -109,33 +113,4 @@ class BulkEdit {
} }
} }
class BulkEditActiveToggle {
constructor(element) {
this.element = element;
element.addEventListener("click", this.onClick.bind(this));
}
onClick() {
this.element.dispatchEvent(
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
);
}
}
class BulkEditCheckbox {
constructor(element) {
this.element = element;
element.addEventListener("change", this.onChange.bind(this));
}
onChange() {
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
this.element.dispatchEvent(
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
);
}
}
registerBehavior("ld-bulk-edit", BulkEdit); registerBehavior("ld-bulk-edit", BulkEdit);
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);

View File

@@ -1,25 +1,29 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior { class ConfirmButtonBehavior extends Behavior {
constructor(element) { constructor(element) {
const button = element; super(element);
button.dataset.type = button.type; element.dataset.type = element.type;
button.dataset.name = button.name; element.dataset.name = element.name;
button.dataset.value = button.value; element.dataset.value = element.value;
button.removeAttribute("type"); element.removeAttribute("type");
button.removeAttribute("name"); element.removeAttribute("name");
button.removeAttribute("value"); element.removeAttribute("value");
button.addEventListener("click", this.onClick.bind(this)); element.addEventListener("click", this.onClick.bind(this));
this.button = button; }
destroy() {
Behavior.interacting = false;
} }
onClick(event) { onClick(event) {
event.preventDefault(); event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span"); const container = document.createElement("span");
container.className = "confirmation"; container.className = "confirmation";
const icon = this.button.getAttribute("confirm-icon"); const icon = this.element.getAttribute("ld-confirm-icon");
if (icon) { if (icon) {
const iconElement = document.createElementNS( const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg", "http://www.w3.org/2000/svg",
@@ -31,27 +35,27 @@ class ConfirmButtonBehavior {
container.append(iconElement); container.append(iconElement);
} }
const question = this.button.getAttribute("confirm-question"); const question = this.element.getAttribute("ld-confirm-question");
if (question) { if (question) {
const questionElement = document.createElement("span"); const questionElement = document.createElement("span");
questionElement.innerText = question; questionElement.innerText = question;
container.append(question); container.append(question);
} }
const buttonClasses = Array.from(this.button.classList.values()) const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn")) .filter((cls) => cls.startsWith("btn"))
.join(" "); .join(" ");
const cancelButton = document.createElement(this.button.nodeName); const cancelButton = document.createElement(this.element.nodeName);
cancelButton.type = "button"; cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel"; cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`; cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this)); cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName); const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.button.dataset.type; confirmButton.type = this.element.dataset.type;
confirmButton.name = this.button.dataset.name; confirmButton.name = this.element.dataset.name;
confirmButton.value = this.button.dataset.value; confirmButton.value = this.element.dataset.value;
confirmButton.innerText = question ? "Yes" : "Confirm"; confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses; confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this)); confirmButton.addEventListener("click", this.reset.bind(this));
@@ -59,14 +63,15 @@ class ConfirmButtonBehavior {
container.append(cancelButton, confirmButton); container.append(cancelButton, confirmButton);
this.container = container; this.container = container;
this.button.before(container); this.element.before(container);
this.button.classList.add("d-none"); this.element.classList.add("d-none");
} }
reset() { reset() {
setTimeout(() => { setTimeout(() => {
Behavior.interacting = false;
this.container.remove(); this.container.remove();
this.button.classList.remove("d-none"); this.element.classList.remove("d-none");
}); });
} }
} }

View File

@@ -1,8 +1,8 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class DropdownBehavior { class DropdownBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.opened = false; this.opened = false;
this.onOutsideClick = this.onOutsideClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this);

View File

@@ -0,0 +1,48 @@
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
class FetchBehavior extends Behavior {
constructor(element) {
super(element);
const eventName = element.getAttribute("ld-on");
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
this.onFetch = this.onFetch.bind(this);
this.onInterval = this.onInterval.bind(this);
element.addEventListener(eventName, this.onFetch);
if (interval) {
this.intervalId = setInterval(this.onInterval, interval);
}
}
destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
async onFetch(maybeEvent) {
if (maybeEvent) {
maybeEvent.preventDefault();
}
const url = this.element.getAttribute("ld-fetch");
const html = await fetch(url).then((response) => response.text());
const target = this.element.getAttribute("ld-target");
const select = this.element.getAttribute("ld-select");
swap(this.element, html, { target, select });
const events = this.element.getAttribute("ld-fire");
fireEvents(events);
}
onInterval() {
if (Behavior.interacting) {
return;
}
this.onFetch();
}
}
registerBehavior("ld-fetch", FetchBehavior);

View File

@@ -1,12 +1,13 @@
import { registerBehavior, swap } from "./index"; import { Behavior, fireEvents, registerBehavior } from "./index";
class FormBehavior { class FormBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
element.addEventListener("submit", this.onFormSubmit.bind(this));
element.addEventListener("submit", this.onSubmit.bind(this));
} }
async onFormSubmit(event) { async onSubmit(event) {
event.preventDefault(); event.preventDefault();
const url = this.element.action; const url = this.element.action;
@@ -21,34 +22,43 @@ class FormBehavior {
redirect: "manual", // ignore redirect redirect: "manual", // ignore redirect
}); });
// Dispatch refresh events const events = this.element.getAttribute("ld-fire");
const refreshEvents = this.element.getAttribute("refresh-events"); if (fireEvents) {
if (refreshEvents) { fireEvents(events);
refreshEvents.split(",").forEach((eventName) => {
document.dispatchEvent(new CustomEvent(eventName));
});
} }
// Refresh form
await this.refresh();
}
async refresh() {
const refreshUrl = this.element.getAttribute("refresh-url");
const html = await fetch(refreshUrl).then((response) => response.text());
swap(this.element, html);
} }
} }
class FormAutoSubmitBehavior { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.element.addEventListener("change", () => {
const form = this.element.closest("form"); element.addEventListener("change", () => {
const form = element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true })); form.dispatchEvent(new Event("submit", { cancelable: true }));
}); });
} }
} }
class UploadButton extends Behavior {
constructor(element) {
super(element);
const fileInput = element.nextElementSibling;
element.addEventListener("click", () => {
fileInput.click();
});
fileInput.addEventListener("change", () => {
const form = fileInput.closest("form");
const event = new Event("submit", { cancelable: true });
event.submitter = element;
form.dispatchEvent(event);
});
}
}
registerBehavior("ld-form", FormBehavior); registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

@@ -1,7 +1,9 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class GlobalShortcuts extends Behavior {
constructor(element) {
super(element);
class GlobalShortcuts {
constructor() {
document.addEventListener("keydown", this.onKeyDown.bind(this)); document.addEventListener("keydown", this.onKeyDown.bind(this));
} }

View File

@@ -1,4 +1,35 @@
const behaviorRegistry = {}; const behaviorRegistry = {};
const debug = false;
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && !node.isConnected) {
destroyBehaviors(node);
}
});
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.isConnected) {
applyBehaviors(node);
}
});
});
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
export class Behavior {
constructor(element) {
this.element = element;
}
destroy() {}
}
Behavior.interacting = false;
export function registerBehavior(name, behavior) { export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior; behaviorRegistry[name] = behavior;
@@ -33,18 +64,82 @@ export function applyBehaviors(container, behaviorNames = null) {
const behaviorInstance = new behavior(element); const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance); element.__behaviors.push(behaviorInstance);
if (debug) {
console.log(
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
);
}
}); });
}); });
} }
export function swap(element, html) { export function destroyBehaviors(element) {
const dom = new DOMParser().parseFromString(html, "text/html"); const behaviorNames = Object.keys(behaviorRegistry);
const newElement = dom.body.firstChild;
element.replaceWith(newElement); behaviorNames.forEach((behaviorName) => {
applyBehaviors(newElement); const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
elements.push(element);
elements.forEach((element) => {
if (!element.__behaviors) {
return;
}
element.__behaviors.forEach((behavior) => {
behavior.destroy();
if (debug) {
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
}
});
delete element.__behaviors;
});
});
} }
export function swapContent(element, html) { export function swap(element, html, options) {
element.innerHTML = html; const dom = new DOMParser().parseFromString(html, "text/html");
applyBehaviors(element);
let targetElement = element;
let strategy = "innerHTML";
if (options.target) {
const parts = options.target.split("|");
targetElement =
parts[0] === "self" ? element : document.querySelector(parts[0]);
strategy = parts[1] || "innerHTML";
}
let contents = Array.from(dom.body.children);
if (options.select) {
contents = Array.from(dom.querySelectorAll(options.select));
}
switch (strategy) {
case "append":
targetElement.append(...contents);
break;
case "outerHTML":
targetElement.parentElement.replaceChild(contents[0], targetElement);
break;
case "innerHTML":
default:
Array.from(targetElement.children).forEach((child) => {
child.remove();
});
targetElement.append(...contents);
}
}
export function fireEvents(events) {
if (!events) {
return;
}
events.split(",").forEach((eventName) => {
const targets = Array.from(
document.querySelectorAll(`[ld-on='${eventName}']`),
);
targets.push(document);
targets.forEach((target) => {
target.dispatchEvent(new CustomEvent(eventName));
});
});
} }

View File

@@ -1,97 +1,48 @@
import { applyBehaviors, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class ModalBehavior { class ModalBehavior extends Behavior {
constructor(element) { constructor(element) {
const toggle = element; super(element);
toggle.addEventListener("click", this.onToggleClick.bind(this));
this.toggle = toggle; this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
} }
async onToggleClick(event) { destroy() {
// Ignore Ctrl + click document.removeEventListener("keydown", this.onKeyDown);
if (event.ctrlKey || event.metaKey) { }
return;
}
event.preventDefault();
event.stopPropagation();
// Create modal either by teleporting existing content or fetching from URL onKeyDown(event) {
const modal = this.toggle.hasAttribute("modal-content") // Skip if event occurred within an input element
? this.createFromContent() const targetNodeName = event.target.nodeName;
: await this.createFromUrl(); const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (!modal) { if (isInputTarget) {
return; return;
} }
// Register close handlers if (event.key === "Escape") {
const modalOverlay = modal.querySelector(".modal-overlay"); event.preventDefault();
const closeButton = modal.querySelector("button.close"); this.onClose();
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
applyBehaviors(document.body);
this.modal = modal;
}
async createFromUrl() {
const url = this.toggle.getAttribute("modal-url");
const modalHtml = await fetch(url).then((response) => response.text());
const parser = new DOMParser();
const doc = parser.parseFromString(modalHtml, "text/html");
return doc.querySelector(".modal");
}
createFromContent() {
const contentSelector = this.toggle.getAttribute("modal-content");
const content = document.querySelector(contentSelector);
if (!content) {
return;
} }
// Todo: make title configurable, only used for tag cloud for now
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;
return modal;
} }
onClose() { onClose() {
// Teleport content back document.removeEventListener("keydown", this.onKeyDown);
if (this.content && this.contentOwner) { this.element.classList.add("closing");
this.contentOwner.append(this.content); this.element.addEventListener("animationend", (event) => {
}
// Remove modal
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") { if (event.animationName === "fade-out") {
this.modal.remove(); this.element.remove();
} }
}); });
} }

View File

@@ -1,9 +1,10 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api"; import { ApiClient } from "../api";
class TagAutocomplete { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
super(element);
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl); const apiClient = new ApiClient(apiBaseUrl);

View File

@@ -2,6 +2,7 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit"; import "./behaviors/bulk-edit";
import "./behaviors/confirm-button"; import "./behaviors/confirm-button";
import "./behaviors/dropdown"; import "./behaviors/dropdown";
import "./behaviors/fetch";
import "./behaviors/form"; import "./behaviors/form";
import "./behaviors/modal"; import "./behaviors/modal";
import "./behaviors/global-shortcuts"; import "./behaviors/global-shortcuts";

View File

@@ -48,6 +48,19 @@ class Command(BaseCommand):
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
zip_file.write(file_path, os.path.join("favicons", file)) zip_file.write(file_path, os.path.join("favicons", file))
# Backup the previews folder
if not os.path.exists(os.path.join("data", "previews")):
self.stdout.write(
self.style.WARNING("No previews folder found. Skipping...")
)
else:
self.stdout.write("Backup bookmark previews...")
previews_folder = os.path.join("data", "previews")
for root, _, files in os.walk(previews_folder):
for file in files:
file_path = os.path.join(root, file)
zip_file.write(file_path, os.path.join("previews", file))
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}")) self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
def backup_database(self, backup_db_file): def backup_database(self, backup_db_file):

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-04-17 19:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0032_html_snapshots_hint_toast"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_mark_unread",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.3 on 2024-05-10 07:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0033_userprofile_default_mark_unread"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="preview_image_file",
field=models.CharField(blank=True, max_length=512),
),
migrations.AddField(
model_name="userprofile",
name="enable_preview_images",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0.3 on 2024-05-14 08:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="tag_grouping",
field=models.CharField(
choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")],
default="alphabetical",
max_length=12,
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-05-17 07:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0035_userprofile_tag_grouping"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="auto_tagging_rules",
field=models.TextField(blank=True),
),
]

View File

@@ -59,6 +59,7 @@ class Bookmark(models.Model):
website_description = models.TextField(blank=True, null=True) website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
favicon_file = models.CharField(max_length=512, blank=True) favicon_file = models.CharField(max_length=512, blank=True)
preview_image_file = models.CharField(max_length=512, blank=True)
unread = models.BooleanField(default=False) unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
shared = models.BooleanField(default=False) shared = models.BooleanField(default=False)
@@ -91,6 +92,7 @@ class Bookmark(models.Model):
class BookmarkAsset(models.Model): class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot" TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload"
CONTENT_TYPE_HTML = "text/html" CONTENT_TYPE_HTML = "text/html"
@@ -118,6 +120,9 @@ class BookmarkAsset(models.Model):
pass pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self):
return self.display_name or f"Bookmark Asset #{self.pk}"
@receiver(post_delete, sender=BookmarkAsset) @receiver(post_delete, sender=BookmarkAsset)
def bookmark_asset_deleted(sender, instance, **kwargs): def bookmark_asset_deleted(sender, instance, **kwargs):
@@ -347,6 +352,12 @@ class UserProfile(models.Model):
(TAG_SEARCH_STRICT, "Strict"), (TAG_SEARCH_STRICT, "Strict"),
(TAG_SEARCH_LAX, "Lax"), (TAG_SEARCH_LAX, "Lax"),
] ]
TAG_GROUPING_ALPHABETICAL = "alphabetical"
TAG_GROUPING_DISABLED = "disabled"
TAG_GROUPING_CHOICES = [
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
(TAG_GROUPING_DISABLED, "Disabled"),
]
user = models.OneToOneField( user = models.OneToOneField(
get_user_model(), related_name="profile", on_delete=models.CASCADE get_user_model(), related_name="profile", on_delete=models.CASCADE
) )
@@ -387,9 +398,16 @@ class UserProfile(models.Model):
blank=False, blank=False,
default=TAG_SEARCH_STRICT, default=TAG_SEARCH_STRICT,
) )
tag_grouping = models.CharField(
max_length=12,
choices=TAG_GROUPING_CHOICES,
blank=False,
default=TAG_GROUPING_ALPHABETICAL,
)
enable_sharing = models.BooleanField(default=False, null=False) enable_sharing = models.BooleanField(default=False, null=False)
enable_public_sharing = models.BooleanField(default=False, null=False) enable_public_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False) enable_favicons = models.BooleanField(default=False, null=False)
enable_preview_images = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False) display_url = models.BooleanField(default=False, null=False)
display_view_bookmark_action = models.BooleanField(default=True, null=False) display_view_bookmark_action = models.BooleanField(default=True, null=False)
display_edit_bookmark_action = models.BooleanField(default=True, null=False) display_edit_bookmark_action = models.BooleanField(default=True, null=False)
@@ -397,8 +415,10 @@ class UserProfile(models.Model):
display_remove_bookmark_action = models.BooleanField(default=True, null=False) display_remove_bookmark_action = models.BooleanField(default=True, null=False)
permanent_notes = models.BooleanField(default=False, null=False) permanent_notes = models.BooleanField(default=False, null=False)
custom_css = models.TextField(blank=True, null=False) custom_css = models.TextField(blank=True, null=False)
auto_tagging_rules = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False) search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
@@ -412,9 +432,11 @@ class UserProfileForm(forms.ModelForm):
"bookmark_link_target", "bookmark_link_target",
"web_archive_integration", "web_archive_integration",
"tag_search", "tag_search",
"tag_grouping",
"enable_sharing", "enable_sharing",
"enable_public_sharing", "enable_public_sharing",
"enable_favicons", "enable_favicons",
"enable_preview_images",
"enable_automatic_html_snapshots", "enable_automatic_html_snapshots",
"display_url", "display_url",
"display_view_bookmark_action", "display_view_bookmark_action",
@@ -422,7 +444,9 @@ class UserProfileForm(forms.ModelForm):
"display_archive_bookmark_action", "display_archive_bookmark_action",
"display_remove_bookmark_action", "display_remove_bookmark_action",
"permanent_notes", "permanent_notes",
"default_mark_unread",
"custom_css", "custom_css",
"auto_tagging_rules",
] ]

View File

@@ -0,0 +1,70 @@
from urllib.parse import urlparse, parse_qs
import re
import idna
def get_tags(script: str, url: str):
parsed_url = urlparse(url.lower())
result = set()
for line in script.lower().split("\n"):
if "#" in line:
i = line.index("#")
line = line[:i]
parts = line.split()
if len(parts) < 2:
continue
domain_pattern = re.sub("^https?://", "", parts[0])
path_pattern = None
qs_pattern = None
if "/" in domain_pattern:
i = domain_pattern.index("/")
path_pattern = domain_pattern[i:]
domain_pattern = domain_pattern[:i]
if path_pattern and "?" in path_pattern:
i = path_pattern.index("?")
qs_pattern = path_pattern[i + 1 :]
path_pattern = path_pattern[:i]
if not _domains_matches(domain_pattern, parsed_url.netloc):
continue
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
continue
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
continue
for tag in parts[1:]:
result.add(tag)
return result
def _path_matches(expected_path: str, actual_path: str) -> bool:
return actual_path.startswith(expected_path)
def _domains_matches(expected_domain: str, actual_domain: str) -> bool:
expected_domain = idna.encode(expected_domain)
actual_domain = idna.encode(actual_domain)
return actual_domain.endswith(expected_domain)
def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
expected_qs = parse_qs(expected_qs, keep_blank_values=True)
actual_qs = parse_qs(actual_qs, keep_blank_values=True)
for key in expected_qs:
if key not in actual_qs:
return False
for value in expected_qs[key]:
if value != "" and value not in actual_qs[key]:
return False
return True

View File

@@ -1,12 +1,19 @@
import logging
import os
from typing import Union from typing import Union
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
from bookmarks.services.tags import get_or_create_tags
from bookmarks.services import website_loader
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services import auto_tagging
from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
@@ -34,6 +41,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
tasks.create_web_archive_snapshot(current_user, bookmark, False) tasks.create_web_archive_snapshot(current_user, bookmark, False)
# Load favicon # Load favicon
tasks.load_favicon(current_user, bookmark) tasks.load_favicon(current_user, bookmark)
# Load preview image
tasks.load_preview_image(current_user, bookmark)
# Create HTML snapshot # Create HTML snapshot
if current_user.profile.enable_automatic_html_snapshots: if current_user.profile.enable_automatic_html_snapshots:
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
@@ -52,6 +61,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
bookmark.save() bookmark.save()
# Update favicon # Update favicon
tasks.load_favicon(current_user, bookmark) tasks.load_favicon(current_user, bookmark)
# Update preview image
tasks.load_preview_image(current_user, bookmark)
if has_url_changed: if has_url_changed:
# Update web archive snapshot, if URL changed # Update web archive snapshot, if URL changed
@@ -176,6 +187,46 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
) )
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
asset.save()
try:
filename = _generate_upload_asset_filename(asset, upload_file.name)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.file_size = upload_file.size
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
return asset
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description
@@ -192,6 +243,15 @@ def _update_website_metadata(bookmark: Bookmark):
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User): def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string) tag_names = parse_tag_string(tag_string)
if user.profile.auto_tagging_rules:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
tags = get_or_create_tags(tag_names, user) tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags) bookmark.tags.set(tags)

View File

@@ -83,6 +83,8 @@ def import_netscape_html(
tasks.schedule_bookmarks_without_snapshots(user) tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks # Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user) tasks.schedule_bookmarks_without_favicons(user)
# Load previews for newly imported bookmarks
tasks.schedule_bookmarks_without_previews(user)
end = timezone.now() end = timezone.now()
logger.debug(f"Import duration: {end - import_start}") logger.debug(f"Import duration: {end - import_start}")

View File

@@ -0,0 +1,88 @@
import logging
import mimetypes
import os.path
import hashlib
from pathlib import Path
import requests
from django.conf import settings
from bookmarks.services import website_loader
logger = logging.getLogger(__name__)
def _ensure_preview_folder():
Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True)
def _url_to_filename(preview_image: str) -> str:
return hashlib.md5(preview_image.encode()).hexdigest()
def _get_image_path(preview_image_file: str) -> Path:
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file))
def load_preview_image(url: str) -> str | None:
_ensure_preview_folder()
metadata = website_loader.load_website_metadata(url)
if not metadata.preview_image:
logger.debug(f"Could not find preview image in metadata: {url}")
return None
image_url = metadata.preview_image
logger.debug(f"Loading preview image: {image_url}")
with requests.get(image_url, stream=True) as response:
if response.status_code < 200 or response.status_code >= 300:
logger.debug(
f"Bad response status code for preview image: {image_url} status_code={response.status_code}"
)
return None
if "Content-Length" not in response.headers:
logger.debug(f"Empty Content-Length for preview image: {image_url}")
return None
content_length = int(response.headers["Content-Length"])
if content_length > settings.LD_PREVIEW_MAX_SIZE:
logger.debug(
f"Content-Length exceeds LD_PREVIEW_MAX_SIZE: {image_url} length={content_length}"
)
return None
if "Content-Type" not in response.headers:
logger.debug(f"Empty Content-Type for preview image: {image_url}")
return None
content_type = response.headers["Content-Type"].split(";", 1)[0]
file_extension = mimetypes.guess_extension(content_type)
if file_extension not in settings.LD_PREVIEW_ALLOWED_EXTENSIONS:
logger.debug(
f"Unsupported Content-Type for preview image: {image_url} content_type={content_type}"
)
return None
preview_image_hash = _url_to_filename(url)
preview_image_file = f"{preview_image_hash}{file_extension}"
preview_image_path = _get_image_path(preview_image_file)
with open(preview_image_path, "wb") as file:
downloaded = 0
for chunk in response.iter_content(chunk_size=8192):
downloaded += len(chunk)
if downloaded > content_length:
logger.debug(
f"Content-Length mismatch for preview image: {image_url} length={content_length} downloaded={downloaded}"
)
file.close()
preview_image_path.unlink()
return None
file.write(chunk)
logger.debug(f"Saved preview image as: {preview_image_path}")
return preview_image_file

View File

@@ -18,11 +18,12 @@ logger = logging.getLogger(__name__)
def create_snapshot(url: str, filepath: str): def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH singlefile_path = settings.LD_SINGLEFILE_PATH
# parse string to list of arguments # parse options to list of arguments
singlefile_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS) ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
temp_filepath = filepath + ".tmp" temp_filepath = filepath + ".tmp"
# concat lists # concat lists
args = [singlefile_path] + singlefile_options + [url, temp_filepath] args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
try: try:
# Use start_new_session=True to create a new process group # Use start_new_session=True to create a new process group
process = subprocess.Popen(args, start_new_session=True) process = subprocess.Popen(args, start_new_session=True)

View File

@@ -1,11 +1,13 @@
import functools import functools
import logging import logging
import os import os
from typing import List
import waybackpy import waybackpy
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q
from django.utils import timezone, formats from django.utils import timezone, formats
from huey import crontab from huey import crontab
from huey.contrib.djhuey import HUEY as huey from huey.contrib.djhuey import HUEY as huey
@@ -14,7 +16,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
import bookmarks.services.wayback import bookmarks.services.wayback
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile from bookmarks.services import favicon_loader, singlefile, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -165,6 +167,12 @@ def is_favicon_feature_active(user: User) -> bool:
return background_tasks_enabled and user.profile.enable_favicons return background_tasks_enabled and user.profile.enable_favicons
def is_preview_feature_active(user: User) -> bool:
return (
user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS
)
def load_favicon(user: User, bookmark: Bookmark): def load_favicon(user: User, bookmark: Bookmark):
if is_favicon_feature_active(user): if is_favicon_feature_active(user):
_load_favicon_task(bookmark.id) _load_favicon_task(bookmark.id)
@@ -220,6 +228,51 @@ def _schedule_refresh_favicons_task(user_id: int):
_load_favicon_task(bookmark.id) _load_favicon_task(bookmark.id)
def load_preview_image(user: User, bookmark: Bookmark):
if is_preview_feature_active(user):
_load_preview_image_task(bookmark.id)
@task()
def _load_preview_image_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
logger.info(f"Load preview image for bookmark. url={bookmark.url}")
new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url)
if new_preview_image_file != bookmark.preview_image_file:
bookmark.preview_image_file = new_preview_image_file or ""
bookmark.save(update_fields=["preview_image_file"])
logger.info(
f"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}"
)
def schedule_bookmarks_without_previews(user: User):
if is_preview_feature_active(user):
_schedule_bookmarks_without_previews_task(user.id)
@task()
def _schedule_bookmarks_without_previews_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(
Q(preview_image_file__exact=""),
owner=user,
)
# TODO: Implement bulk task creation
for bookmark in bookmarks:
try:
_load_preview_image_task(bookmark.id)
except Exception as exc:
logging.exception(exc)
def is_html_snapshot_feature_active() -> bool: def is_html_snapshot_feature_active() -> bool:
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
@@ -228,6 +281,26 @@ def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active(): if not is_html_snapshot_feature_active():
return return
asset = _create_snapshot_asset(bookmark)
asset.save()
def create_html_snapshots(bookmark_list: List[Bookmark]):
if not is_html_snapshot_feature_active():
return
assets_to_create = []
for bookmark in bookmark_list:
asset = _create_snapshot_asset(bookmark)
assets_to_create.append(asset)
BookmarkAsset.objects.bulk_create(assets_to_create)
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT") timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset( asset = BookmarkAsset(
bookmark=bookmark, bookmark=bookmark,
@@ -236,10 +309,7 @@ def create_html_snapshot(bookmark: Bookmark):
display_name=f"HTML snapshot from {timestamp}", display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING, status=BookmarkAsset.STATUS_PENDING,
) )
asset.save() return asset
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _generate_snapshot_filename(asset: BookmarkAsset) -> str: def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
@@ -305,3 +375,23 @@ def _create_html_snapshot_task(asset_id: int):
) )
asset.status = BookmarkAsset.STATUS_FAILURE asset.status = BookmarkAsset.STATUS_FAILURE
asset.save() asset.save()
def create_missing_html_snapshots(user: User) -> int:
if not is_html_snapshot_feature_active():
return 0
bookmarks_without_snapshots = Bookmark.objects.filter(owner=user).exclude(
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT,
bookmarkasset__status__in=[
BookmarkAsset.STATUS_PENDING,
BookmarkAsset.STATUS_COMPLETE,
],
)
bookmarks_without_snapshots |= Bookmark.objects.filter(owner=user).exclude(
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT
)
create_html_snapshots(list(bookmarks_without_snapshots))
return bookmarks_without_snapshots.count()

View File

@@ -1,6 +1,7 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from urllib.parse import urljoin
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -15,12 +16,14 @@ class WebsiteMetadata:
url: str url: str
title: str title: str
description: str description: str
preview_image: str | None
def to_dict(self): def to_dict(self):
return { return {
"url": self.url, "url": self.url,
"title": self.title, "title": self.title,
"description": self.description, "description": self.description,
"preview_image": self.preview_image,
} }
@@ -30,6 +33,7 @@ class WebsiteMetadata:
def load_website_metadata(url: str): def load_website_metadata(url: str):
title = None title = None
description = None description = None
preview_image = None
try: try:
start = timezone.now() start = timezone.now()
page_text = load_page(url) page_text = load_page(url)
@@ -55,10 +59,21 @@ def load_website_metadata(url: str):
else None else None
) )
image_tag = soup.find("meta", attrs={"property": "og:image"})
preview_image = image_tag["content"].strip() if image_tag else None
if (
preview_image
and not preview_image.startswith("http://")
and not preview_image.startswith("https://")
):
preview_image = urljoin(url, preview_image)
end = timezone.now() end = timezone.now()
logger.debug(f"Parsing duration: {end - start}") logger.debug(f"Parsing duration: {end - start}")
finally: finally:
return WebsiteMetadata(url=url, title=title, description=description) return WebsiteMetadata(
url=url, title=title, description=description, preview_image=preview_image
)
CHUNK_SIZE = 50 * 1024 CHUNK_SIZE = 50 * 1024

2314
bookmarks/static/vendor/Readability.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,15 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.preview-image {
margin: $unit-4 0;
img {
max-width: 100%;
max-height: 200px;
}
}
dl { dl {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -61,16 +70,22 @@
.assets .asset-text { .assets .asset-text {
flex: 1 1 0; flex: 1 1 0;
gap: $unit-2;
min-width: 0;
display: flex;
}
.assets .asset-text .truncate {
flex-shrink: 1;
} }
.assets .asset-text .filesize { .assets .asset-text .filesize {
color: $gray-color; color: $gray-color;
margin-left: $unit-2;
} }
.assets .asset-actions, .assets-actions { .assets .asset-actions, .assets-actions {
display: flex; display: flex;
gap: $unit-3; gap: $unit-4;
align-items: center; align-items: center;
} }

View File

@@ -36,12 +36,11 @@
.form-input-hint.bookmark-exists { .form-input-hint.bookmark-exists {
display: none; display: none;
color: $warning-color; color: $warning-color;
}
a { .form-input-hint.auto-tags {
color: $warning-color; display: none;
text-decoration: underline; color: $success-color;
font-weight: bold;
}
} }
details.notes textarea { details.notes textarea {

View File

@@ -128,9 +128,26 @@ ul.bookmark-list {
/* Bookmarks */ /* Bookmarks */
li[ld-bookmark-item] { li[ld-bookmark-item] {
position: relative; position: relative;
display: flex;
gap: $unit-2;
margin-top: $unit-2; margin-top: $unit-2;
[ld-bulk-edit-checkbox].form-checkbox { .content {
flex: 1 1 0;
min-width: 0;
}
img.preview-image {
flex: 0 0 auto;
width: 100px;
height: 60px;
margin-top: $unit-h;
object-fit: cover;
border-radius: $border-radius;
border: solid 1px $border-color-dark;
}
.form-checkbox.bulk-edit-checkbox {
display: none; display: none;
} }
@@ -182,6 +199,12 @@ li[ld-bookmark-item] {
animation: 0.3s ease 0s appear; animation: 0.3s ease 0s appear;
} }
@media (pointer:coarse) {
.title a[data-tooltip]::after {
display: none;
}
}
&.unread .title a { &.unread .title a {
font-style: italic; font-style: italic;
} }
@@ -323,7 +346,7 @@ $bulk-edit-transition-duration: 400ms;
} }
/* All checkbox */ /* All checkbox */
[ld-bulk-edit-checkbox][all].form-checkbox { .form-checkbox.bulk-edit-checkbox.all {
display: block; display: block;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset; margin: 0 0 0 $bulk-edit-toggle-offset;
@@ -331,7 +354,7 @@ $bulk-edit-transition-duration: 400ms;
} }
/* Bookmark checkboxes */ /* Bookmark checkboxes */
li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox { li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
display: block; display: block;
position: absolute; position: absolute;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
@@ -346,11 +369,11 @@ $bulk-edit-transition-duration: 400ms;
transition: all $bulk-edit-transition-duration; transition: all $bulk-edit-transition-duration;
.form-icon { .form-icon {
top: 0; top: 0;
} }
} }
&.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox { &.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }

View File

@@ -0,0 +1,27 @@
html.reader-mode {
--font-size: 1rem;
line-height: 1.6;
body {
margin: 3rem 2rem;
}
.container {
max-width: 600px;
}
.byline {
font-style: italic;
font-size: 0.8rem;
}
.reading-time {
font-size: 0.7rem;
}
img {
max-width: 100%;
height: auto;
}
}

View File

@@ -12,6 +12,7 @@
@import "bookmark-form"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "markdown"; @import "markdown";
@import "reader-mode";
/* Dark theme overrides */ /* Dark theme overrides */

View File

@@ -12,3 +12,4 @@
@import "bookmark-form"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "markdown"; @import "markdown";
@import "reader-mode";

View File

@@ -4,11 +4,7 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1" <div ld-bulk-edit class="bookmarks-page grid columns-md-1">
ld-bulk-edit
ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <section class="content-area col-2">
@@ -17,17 +13,22 @@
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %} {% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button> <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div class="bookmark-list-container"> <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@@ -38,7 +39,8 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>

View File

@@ -10,142 +10,143 @@
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %} {% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}> <li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<div class="title"> <div class="content">
<label ld-bulk-edit-checkbox class="form-checkbox"> <div class="title">
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}"> <label class="form-checkbox bulk-edit-checkbox">
<i class="form-icon"></i> <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
</label> <i class="form-icon"></i>
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %} </label>
<img src="{% static bookmark_item.favicon_file %}" alt=""> {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
{% endif %} <img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"> {% endif %}
<span>{{ bookmark_item.title }}</span> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
</a> <span>{{ bookmark_item.title }}</span>
</div>
{% if bookmark_list.show_url %}
<div class="url-path truncate">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display">
{{ bookmark_item.url }}
</a> </a>
</div> </div>
{% endif %} {% if bookmark_list.show_url %}
{% if bookmark_list.description_display == 'inline' %} <div class="url-path truncate">
<div class="description inline truncate"> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display">
{{ bookmark_item.url }}
</a>
</div>
{% endif %}
{% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate">
{% if bookmark_item.tag_names %}
<span class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% else %}
{% if bookmark_item.description %}
<div class="description separate">{{ bookmark_item.description }}</div>
{% endif %}
{% if bookmark_item.tag_names %} {% if bookmark_item.tag_names %}
<span class="tags"> <div class="tags">
{% for tag_name in bookmark_item.tag_names %} {% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</div>
{% endif %}
{% endif %}
{% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="markdown">{% markdown bookmark_item.notes %}</div>
</div>
{% endif %}
<div class="actions text-gray">
{% if bookmark_item.display_date %}
{% if bookmark_item.web_archive_snapshot_url %}
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{{ bookmark_item.display_date }} ∞
</a>
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
ld-on="click" ld-target="body|append"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span> </span>
{% endif %} {% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %} {% if bookmark_item.has_extra_actions %}
{% if bookmark_item.description %} <div class="extra-actions">
<span>{{ bookmark_item.description }}</span> <span class="hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
{% endif %}
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
{% else %}
{% if bookmark_item.description %}
<div class="description separate">
{{ bookmark_item.description }}
</div>
{% endif %}
{% if bookmark_item.tag_names %}
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="markdown">
{% markdown bookmark_item.notes %}
</div>
</div>
{% endif %}
<div class="actions text-gray">
{% if bookmark_item.display_date %}
{% if bookmark_item.web_archive_snapshot_url %}
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{{ bookmark_item.display_date }} ∞
</a>
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a ld-modal
modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span>
{% endif %}
{% if bookmark_item.has_extra_actions %}
<div class="extra-actions">
<span class="hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
{% endif %}
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %}
</div> </div>
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -2,7 +2,7 @@
{% htmlmin %} {% htmlmin %}
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray"> <div class="bulk-edit-actions bg-gray">
<label ld-bulk-edit-checkbox all class="form-checkbox"> <label class="form-checkbox bulk-edit-checkbox all">
<input type="checkbox"> <input type="checkbox">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>

View File

@@ -1,4 +1,4 @@
<button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" title="Bulk edit"> <button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
height="20px"> height="20px">
<path <path

View File

@@ -1,37 +1,48 @@
{% if details.assets %} <div {% if details.has_pending_assets %}
<div class="assets"> ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
{% for asset in details.assets %} ld-interval="5" ld-target="self|outerHTML"
<div class="asset" data-asset-id="{{ asset.id }}"> {% endif %}>
<div class="asset-icon {{ asset.icon_classes }}"> {% if details.assets %}
{% include 'bookmarks/details/asset_icon.html' %} <div class="assets">
{% for asset in details.assets %}
<div class="asset" data-asset-id="{{ asset.id }}">
<div class="asset-icon {{ asset.icon_classes }}">
{% include 'bookmarks/details/asset_icon.html' %}
</div>
<div class="asset-text {{ asset.text_classes }}">
<span class="truncate">
{{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %}
{% if asset.status == 'failure' %}(failed){% endif %}
</span>
{% if asset.file_size %}
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %}
</div>
<div class="asset-actions">
{% if asset.file %}
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a>
{% endif %}
{% if details.is_editable %}
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
Remove
</button>
{% endif %}
</div>
</div> </div>
<div class="asset-text truncate {{ asset.text_classes }}"> {% endfor %}
<span> </div>
{{ asset.display_name }} {% endif %}
{% if asset.status == 'pending' %}(queued){% endif %}
{% if asset.status == 'failure' %}(failed){% endif %}
</span>
{% if asset.file_size %}
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %}
</div>
<div class="asset-actions">
{% if asset.file %}
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a>
{% endif %}
{% if details.is_editable %}
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
Remove
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-link">Create HTML snapshot</button> <button type="submit" name="create_snapshot" class="btn btn-link"
</div> {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
{% endif %} </button>
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
class="btn btn-link">Upload file
</button>
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div>
{% endif %}
</div>

View File

@@ -1,9 +1,8 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
<form ld-form action="{% url 'bookmarks:details' details.bookmark.id %}" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
refresh-url="{% url 'bookmarks:partials.details_form' details.bookmark.id %}" action="{% url 'bookmarks:details' details.bookmark.id %}"
refresh-events="bookmark-page-refresh"
method="post"> method="post">
<div class="weblinks"> <div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener" <a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
@@ -13,6 +12,17 @@
{% endif %} {% endif %}
<span>{{ details.bookmark.url }}</span> <span>{{ details.bookmark.url }}</span>
</a> </a>
{% if details.latest_snapshot %}
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}"
target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %}
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
<use xlink:href="#ld-icon-unread"></use>
</svg>
{% endif %}
<span>Reader mode</span>
</a>
{% endif %}
{% if details.bookmark.web_archive_snapshot_url %} {% if details.bookmark.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}" <a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
@@ -23,10 +33,15 @@
fill="currentColor" fill-rule="evenodd"/> fill="currentColor" fill-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
<span>View on Internet Archive</span> <span>Internet Archive</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/>
</div>
{% endif %}
<dl class="grid columns-2 columns-sm-1 gap-0"> <dl class="grid columns-2 columns-sm-1 gap-0">
{% if details.is_editable %} {% if details.is_editable %}
<div class="status col-2"> <div class="status col-2">
@@ -35,14 +50,14 @@
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="is_archived" <input ld-auto-submit type="checkbox" name="is_archived"
{% if details.bookmark.is_archived %}checked{% endif %}> {% if details.bookmark.is_archived %}checked{% endif %}>
<i class="form-icon"></i> Archived <i class="form-icon"></i> Archived
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="unread" <input ld-auto-submit type="checkbox" name="unread"
{% if details.bookmark.unread %}checked{% endif %}> {% if details.bookmark.unread %}checked{% endif %}>
<i class="form-icon"></i> Unread <i class="form-icon"></i> Unread
</label> </label>
@@ -50,7 +65,7 @@
{% if details.profile.enable_sharing %} {% if details.profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="shared" <input ld-auto-submit type="checkbox" name="shared"
{% if details.bookmark.shared %}checked{% endif %}> {% if details.bookmark.shared %}checked{% endif %}>
<i class="form-icon"></i> Shared <i class="form-icon"></i> Shared
</label> </label>

View File

@@ -1,4 +1,7 @@
<div class="modal active bookmark-details"> <div ld-modal
ld-fetch="{% url 'bookmarks:details_modal' details.bookmark.id %}" ld-on="refresh-details"
ld-select=".content" ld-target=".modal.bookmark-details .content|outerHTML"
class="modal active bookmark-details">
<div class="modal-overlay" aria-label="Close"></div> <div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">

View File

@@ -23,10 +23,10 @@
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} {{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint"> <div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not Enter any number of tags separated by space and <strong>without</strong> the hash (#).
exist it will be If a tag does not exist it will be automatically created.
automatically created.
</div> </div>
<div class="form-input-hint auto-tags"></div>
{{ form.tag_string.errors }} {{ form.tag_string.errors }}
</div> </div>
<div class="form-group has-icon-right"> <div class="form-group has-icon-right">
@@ -197,6 +197,18 @@
} else { } else {
bookmarkExistsHint.style['display'] = 'none'; bookmarkExistsHint.style['display'] = 'none';
} }
// Preview auto tags
const autoTags = data.auto_tags;
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
if (autoTags.length > 0) {
autoTags.sort();
autoTagsHint.style['display'] = 'block';
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
} else {
autoTagsHint.style['display'] = 'none';
}
}); });
} }

View File

@@ -4,11 +4,7 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1" <div ld-bulk-edit class="bookmarks-page grid columns-md-1">
ld-bulk-edit
ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <section class="content-area col-2">
@@ -17,17 +13,22 @@
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags %} {% bookmark_search bookmark_list.search tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button> <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div class="bookmark-list-container"> <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@@ -38,7 +39,8 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>

View File

@@ -0,0 +1,83 @@
{% load sass_tags %}
{% load static %}
<!DOCTYPE html>
<html lang="en" class="reader-mode">
<head>
<meta charset="UTF-8">
<title>Reader view</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
{% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
{% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
{% endif %}
</head>
<body>
<template id="content">{{ content|safe }}</template>
<script src="{% static 'vendor/Readability.js' %}" type="application/javascript"></script>
<script type="application/javascript">
function estimateReadingTime(charCount, wordsPerMinute) {
const avgWordLength = 5;
const totalWords = charCount / avgWordLength;
return Math.ceil(totalWords / wordsPerMinute);
}
function postProcess(articleContent) {
articleContent.querySelectorAll('table').forEach(table => {
table.classList.add('table');
});
}
function makeReadable() {
const content = document.getElementById('content');
const contentHtml = content.innerHTML;
const dom = new DOMParser().parseFromString(contentHtml, 'text/html');
const article = new Readability(dom).parse();
document.title = article.title;
const container = document.createElement('div');
container.classList.add('container');
const articleTitle = document.createElement('h1');
articleTitle.textContent = article.title;
container.append(articleTitle);
const byline = [article.byline, article.siteName].filter(Boolean);
if (byline.length > 0) {
const articleByline = document.createElement('p');
articleByline.textContent = byline.join(' | ');
articleByline.classList.add('byline');
container.append(articleByline);
}
if(article.length) {
const minTime = estimateReadingTime(article.length, 225);
const maxTime = estimateReadingTime(article.length, 175);
const articleReadingTime = document.createElement('p');
articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;
articleReadingTime.classList.add('reading-time');
container.append(articleReadingTime);
}
const divider = document.createElement('hr');
container.append(divider);
const articleContent = document.createElement('div');
articleContent.innerHTML = article.content;
postProcess(articleContent);
container.append(articleContent);
content.replaceWith(container);
}
makeReadable();
</script>
</body>
</html>

View File

@@ -4,10 +4,7 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1" <div class="bookmarks-page grid columns-md-1">
ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <section class="content-area col-2">
@@ -15,15 +12,20 @@
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %} {% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button> <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
method="post"> class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
<div class="bookmark-list-container"> ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@@ -41,7 +43,8 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>

View File

@@ -0,0 +1,21 @@
<div ld-modal class="modal active">
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,12 @@
{# Profile section #} {# Profile section #}
<section class="content-area"> <section class="content-area">
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
@@ -104,6 +110,29 @@
result will also include bookmarks where a search term matches otherwise. result will also include bookmarks where a search term matches otherwise.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
In alphabetical mode, tags will be grouped by the first letter.
If disabled, tags will not be grouped.
</div>
</div>
<div class="form-group">
<details {% if form.auto_tagging_rules.value %}open{% endif %}>
<summary>Auto Tagging</summary>
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
<div class="mt-2">
{{ form.auto_tagging_rules|add_class:"form-input custom-css"|attr:"rows:6" }}
</div>
</details>
<div class="form-input-hint">
Automatically adds tags to bookmarks based on predefined rules.
Each line is a single rule that maps a URL to one or more tags. For example:
<pre>youtube.com video
reddit.com/r/Music music reddit</pre>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox"> <label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
{{ form.enable_favicons }} {{ form.enable_favicons }}
@@ -111,6 +140,7 @@
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">
Automatically loads favicons for bookmarked websites and displays them next to each bookmark. Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
Enabling this feature automatically downloads all missing favicons.
By default, this feature uses a <b>Google service</b> to download favicons. By default, this feature uses a <b>Google service</b> to download favicons.
If you don't want to use this service, check the <a If you don't want to use this service, check the <a
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider" href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
@@ -120,13 +150,16 @@
{% if request.user_profile.enable_favicons and enable_refresh_favicons %} {% if request.user_profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button> <button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %} {% endif %}
{% if refresh_favicons_success_message %} </div>
<div class="has-success"> <div class="form-group">
<p class="form-input-hint"> <label for="{{ form.enable_preview_images.id_for_label }}" class="form-checkbox">
{{ refresh_favicons_success_message }} {{ form.enable_preview_images }}
</p> <i class="form-icon"></i> Enable Preview Images
</div> </label>
{% endif %} <div class="form-input-hint">
Automatically loads preview images for bookmarked websites and displays them next to each bookmark.
Enabling this feature automatically downloads all missing preview images.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
@@ -173,8 +206,20 @@
Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be
created manually in the details view of a bookmark. created manually in the details view of a bookmark.
</div> </div>
<button class="btn mt-2" name="create_missing_html_snapshots">Create missing HTML snapshots</button>
</div> </div>
{% endif %} {% endif %}
<div class="form-group">
<label for="{{ form.default_mark_unread.id_for_label }}" class="form-checkbox">
{{ form.default_mark_unread }}
<i class="form-icon"></i> Create bookmarks as unread by default
</label>
<div class="form-input-hint">
Sets the default state for the "Mark as unread" option when creating a new bookmark.
Setting this option will make all new bookmarks default to unread.
This can be overridden when creating each new bookmark.
</div>
</div>
<div class="form-group"> <div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}> <details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary> <summary>Custom CSS</summary>
@@ -189,13 +234,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2"> <input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ update_profile_success_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>
@@ -224,20 +262,6 @@
<input class="form-input" type="file" name="import_file"> <input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn btn btn-primary" value="Upload"> <input type="submit" class="input-group-btn btn btn-primary" value="Upload">
</div> </div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>

View File

@@ -39,6 +39,7 @@ class BookmarkFactoryMixin:
website_description: str = "", website_description: str = "",
web_archive_snapshot_url: str = "", web_archive_snapshot_url: str = "",
favicon_file: str = "", favicon_file: str = "",
preview_image_file: str = "",
added: datetime = None, added: datetime = None,
): ):
if title is None: if title is None:
@@ -67,6 +68,7 @@ class BookmarkFactoryMixin:
shared=shared, shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url, web_archive_snapshot_url=web_archive_snapshot_url,
favicon_file=favicon_file, favicon_file=favicon_file,
preview_image_file=preview_image_file,
) )
bookmark.save() bookmark.save()
for tag in tags: for tag in tags:

View File

@@ -0,0 +1,179 @@
from bookmarks.services import auto_tagging
from django.test import TestCase
class AutoTaggingTestCase(TestCase):
def test_auto_tag_by_domain(self):
script = """
example.com example
test.com test
"""
url = "https://example.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"]))
def test_auto_tag_by_domain_ignores_case(self):
script = """
EXAMPLE.com example
"""
url = "https://example.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"]))
def test_auto_tag_by_domain_should_add_all_tags(self):
script = """
example.com one two three
"""
url = "https://example.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one", "two", "three"]))
def test_auto_tag_by_domain_work_with_idn_domains(self):
script = """
रजिस्ट्री.भारत tag1
"""
url = "https://www.xn--81bg3cc2b2bk5hb.xn--h2brj9c/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
script = """
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
"""
url = "https://www.रजिस्ट्री.भारत/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
def test_auto_tag_by_domain_and_path(self):
script = """
example.com/one one
example.com/two two
test.com test
"""
url = "https://example.com/one/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
def test_auto_tag_by_domain_and_path_ignores_case(self):
script = """
example.com/One one
"""
url = "https://example.com/one/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
script = """
example.com/one one
example.com/two two
test.com test
"""
url = "https://example.com/one/two"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
def test_auto_tag_by_domain_ignores_domain_in_path(self):
script = """
example.com example
"""
url = "https://test.com/example.com"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
def test_auto_tag_by_domain_includes_subdomains(self):
script = """
example.com example
test.example.com test
some.example.com some
"""
url = "https://test.example.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example", "test"]))
def test_auto_tag_by_domain_matches_domain_rtl(self):
script = """
example.com example
"""
url = "https://example.com.bad-website.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
def test_auto_tag_by_domain_ignores_schema(self):
script = """
https://example.com/ https
http://example.com/ http
"""
url = "http://example.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["https", "http"]))
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
script = """
example.com
"""
url = "https://example.com/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
def test_auto_tag_by_domain_path_and_qs(self):
script = """
example.com/page?a=b tag1 # true, matches a=b
example.com/page?a=c&c=d tag2 # true, matches both a=c and c=d
example.com/page?c=d&l=p tag3 # false, l=p doesn't exists
example.com/page?a=bb tag4 # false bb != b
example.com/page?a=b&a=c tag5 # true, matches both a=b and a=c
example.com/page?a=B tag6 # true, matches a=b because case insensitive
example.com/page?A=b tag7 # true, matches a=b because case insensitive
"""
url = "https://example.com/page/some?z=x&a=b&v=b&c=d&o=p&a=c"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"]))
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
script = """
example.com/page?a= tag1
example.com/page?b= tag2
"""
url = "https://example.com/page/some?a=value"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
script = """
example.com/page?a=йцу tag1
example.com/page?a=%D0%B9%D1%86%D1%83 tag2
"""
url = "https://example.com/page?a=%D0%B9%D1%86%D1%83"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2"]))

View File

@@ -94,15 +94,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
) )
def assertBulkActionForm(self, response, url: str): def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode()) soup = self.make_soup(response.content.decode())
needle = collapse_whitespace( form = soup.select_one("form.bookmark-actions")
f""" self.assertIsNotNone(form)
<form class="bookmark-actions" self.assertEqual(form.attrs["action"], url)
action="{url}"
method="post" autocomplete="off">
"""
)
self.assertIn(needle, html)
def test_should_list_archived_and_user_owned_bookmarks(self): def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(

View File

@@ -34,12 +34,12 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
asset = self.setup_asset(bookmark=bookmark, file=filename) asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset return asset
def test_view_access(self): def view_access_test(self, view_name: str):
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# other user's bookmark # other user's bookmark
@@ -47,14 +47,14 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing disabled # shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, sharing enabled # unshared, sharing enabled
@@ -64,31 +64,31 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user, shared=False) bookmark = self.setup_bookmark(user=other_user, shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing enabled # shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_view_access_guest_user(self): def view_access_guest_user_test(self, view_name: str):
self.client.logout() self.client.logout()
# unshared, sharing disabled # unshared, sharing disabled
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing disabled # shared, sharing disabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, sharing enabled # unshared, sharing enabled
@@ -98,14 +98,14 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(shared=False) bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing enabled # shared, sharing enabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, public sharing enabled # unshared, public sharing enabled
@@ -114,12 +114,24 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(shared=False) bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, public sharing enabled # shared, public sharing enabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_view_access(self):
self.view_access_test("bookmarks:assets.view")
def test_view_access_guest_user(self):
self.view_access_guest_user_test("bookmarks:assets.view")
def test_reader_view_access(self):
self.view_access_test("bookmarks:assets.read")
def test_reader_view_access_guest_user(self):
self.view_access_guest_user_test("bookmarks:assets.read")

View File

@@ -1,11 +1,13 @@
import re
from unittest.mock import patch from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import formats from django.utils import formats
from bookmarks.models import BookmarkAsset, UserProfile from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks from bookmarks.services import bookmarks, tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@@ -45,6 +47,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_weblink(self, soup, url): def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url}) return soup.find("a", {"class": "weblink", "href": url})
def count_weblinks(self, soup):
return len(soup.find_all("a", {"class": "weblink"}))
def find_asset(self, soup, asset): def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id}) return soup.find("div", {"data-asset-id": asset.id})
@@ -105,17 +110,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_access_with_sharing(self): def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True) self.details_route_sharing_access_test(self.get_view_name(), True)
def test_form_partial_access(self): def test_assets_access(self):
# form partial is only used when submitting forms, which should be only self.details_route_access_test("bookmarks:details_assets", True)
# accessible to the owner of the bookmark. As such assume it requires
# login.
self.details_route_access_test("bookmarks:partials.details_form", False)
def test_form_partial_access_with_sharing(self): def test_assets_access_with_sharing(self):
# form partial is only used when submitting forms, which should be only self.details_route_sharing_access_test("bookmarks:details_assets", True)
# accessible to the owner of the bookmark. As such assume it requires
# login.
self.details_route_sharing_access_test("bookmarks:partials.details_form", False)
def test_displays_title(self): def test_displays_title(self):
# with title # with title
@@ -177,6 +176,48 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(image) self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png") self.assertEqual(image["src"], "/static/example.png")
def test_reader_mode_link(self):
# no latest snapshot
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
# snapshot is not complete
self.setup_asset(
bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_PENDING,
)
self.setup_asset(
bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_FAILURE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
# not a snapshot
self.setup_asset(
bookmark,
asset_type="upload",
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
# snapshot is complete
asset = self.setup_asset(
bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 2)
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
link = self.find_weblink(soup, reader_mode_url)
self.assertIsNotNone(link)
def test_internet_archive_link(self): def test_internet_archive_link(self):
# without snapshot url # without snapshot url
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -190,7 +231,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url) self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
self.assertEqual(link.text.strip(), "View on Internet Archive") self.assertEqual(link.text.strip(), "Internet Archive")
# favicons disabled # favicons disabled
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
@@ -259,6 +300,36 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
) )
def test_preview_image(self):
# without image
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNone(image)
# with image
bookmark = self.setup_bookmark(preview_image_file="example.png")
soup = self.get_details(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNone(image)
# preview images enabled, no image
profile = self.get_or_create_test_user().profile
profile.enable_preview_images = True
profile.save()
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNone(image)
# preview images enabled, image present
bookmark = self.setup_bookmark(preview_image_file="example.png")
soup = self.get_details(bookmark)
image = soup.select_one("div.preview-image img")
self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png")
def test_status(self): def test_status(self):
# renders form # renders form
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -765,6 +836,27 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_refresh_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
fetch_url = reverse("bookmarks:details_assets", args=[bookmark.id])
# no pending asset
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNone(assets_wrapper)
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNotNone(assets_wrapper)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self): def test_create_snapshot(self):
with patch.object( with patch.object(
@@ -777,3 +869,58 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1) self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
# no pending asset
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
self.assertFalse(create_button.has_attr("disabled"))
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
self.assertTrue(create_button.has_attr("disabled"))
def test_upload_file(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": "", "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 302)
mock_upload_asset.assert_called_once()
args, kwargs = mock_upload_asset.call_args
self.assertEqual(args[0], bookmark)
upload_file = args[1]
self.assertEqual(upload_file.name, "test.txt")
def test_upload_file_without_file(self):
bookmark = self.setup_bookmark()
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": ""},
)
self.assertEqual(response.status_code, 400)
mock_upload_asset.assert_not_called()

View File

@@ -94,15 +94,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
) )
def assertBulkActionForm(self, response, url: str): def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode()) soup = self.make_soup(response.content.decode())
needle = collapse_whitespace( form = soup.select_one("form.bookmark-actions")
f""" self.assertIsNotNone(form)
<form class="bookmark-actions" self.assertEqual(form.attrs["action"], url)
action="{url}"
method="post" autocomplete="off">
"""
)
self.assertIn(needle, html)
def test_should_list_unarchived_and_user_owned_bookmarks(self): def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(

View File

@@ -210,3 +210,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1) self.assertContains(response, '<details class="notes">', count=1)
def test_should_not_check_unread_by_default(self):
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML(
'<input type="checkbox" name="unread" id="id_unread">',
html,
)
def test_should_check_unread_when_configured_in_profile(self):
self.user.profile.default_mark_unread = True
self.user.profile.save()
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML(
'<input type="checkbox" name="unread" value="true" '
'id="id_unread" checked="">',
html,
)

View File

@@ -440,6 +440,20 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.get(url=data["url"]) bookmark = Bookmark.objects.get(url=data["url"])
self.assertFalse(bookmark.shared) self.assertFalse(bookmark.shared)
def test_create_bookmark_should_add_tags_from_auto_tagging(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.authenticate()
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = f"example.com {tag2.name}"
profile.save()
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data["url"])
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
def test_get_bookmark(self): def test_get_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -512,6 +526,22 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=bookmark.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.shared, True) self.assertEqual(updated_bookmark.shared, True)
def test_update_bookmark_adds_tags_from_auto_tagging(self):
bookmark = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.authenticate()
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = f"example.com {tag2.name}"
profile.save()
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
def test_patch_bookmark(self): def test_patch_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -583,6 +613,22 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.description, bookmark.description) self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names) self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
bookmark = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.authenticate()
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = f"example.com {tag2.name}"
profile.save()
data = {"tag_names": [tag1.name]}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
def test_delete_bookmark(self): def test_delete_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -628,7 +674,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
website_loader, "load_website_metadata" website_loader, "load_website_metadata"
) as mock_load_website_metadata: ) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata( expected_metadata = WebsiteMetadata(
"https://example.com", "Scraped metadata", "Scraped description" "https://example.com",
"Scraped metadata",
"Scraped description",
"https://example.com/preview.png",
) )
mock_load_website_metadata.return_value = expected_metadata mock_load_website_metadata.return_value = expected_metadata
@@ -640,9 +689,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
metadata = response.data["metadata"] metadata = response.data["metadata"]
self.assertIsNotNone(metadata) self.assertIsNotNone(metadata)
self.assertIsNotNone(expected_metadata.url, metadata["url"]) self.assertEqual(expected_metadata.url, metadata["url"])
self.assertIsNotNone(expected_metadata.title, metadata["title"]) self.assertEqual(expected_metadata.title, metadata["title"])
self.assertIsNotNone(expected_metadata.description, metadata["description"]) self.assertEqual(expected_metadata.description, metadata["description"])
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
def test_check_returns_bookmark_if_url_is_bookmarked(self): def test_check_returns_bookmark_if_url_is_bookmarked(self):
self.authenticate() self.authenticate()
@@ -687,9 +737,38 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
mock_load_website_metadata.assert_not_called() mock_load_website_metadata.assert_not_called()
self.assertIsNotNone(metadata) self.assertIsNotNone(metadata)
self.assertIsNotNone(bookmark.url, metadata["url"]) self.assertEqual(bookmark.url, metadata["url"])
self.assertIsNotNone(bookmark.website_title, metadata["title"]) self.assertEqual(bookmark.website_title, metadata["title"])
self.assertIsNotNone(bookmark.website_description, metadata["description"]) self.assertEqual(bookmark.website_description, metadata["description"])
self.assertIsNone(metadata["preview_image"])
def test_check_returns_no_auto_tags_if_none_configured(self):
self.authenticate()
url = reverse("bookmarks:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com")
response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
)
auto_tags = response.data["auto_tags"]
self.assertCountEqual(auto_tags, [])
def test_check_returns_matching_auto_tags(self):
self.authenticate()
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = "example.com tag1 tag2"
profile.save()
url = reverse("bookmarks:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com")
response = self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
)
auto_tags = response.data["auto_tags"]
self.assertCountEqual(auto_tags, ["tag1", "tag2"])
def test_can_only_access_own_bookmarks(self): def test_can_only_access_own_bookmarks(self):
self.authenticate() self.authenticate()

View File

@@ -20,7 +20,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self, html: str, bookmark: Bookmark, link_target: str = "_blank" self, html: str, bookmark: Bookmark, link_target: str = "_blank"
): ):
favicon_img = ( favicon_img = (
f'<img src="/static/{bookmark.favicon_file}" alt="">' f'<img class="favicon" src="/static/{bookmark.favicon_file}" alt="">'
if bookmark.favicon_file if bookmark.favicon_file
else "" else ""
) )
@@ -80,7 +80,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id]) details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
self.assertInHTML( self.assertInHTML(
f""" f"""
<a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a> <a ld-fetch="{details_modal_url}?return_url={return_url}"
ld-on="click" ld-target="body|append"
href="{details_url}">View</a>
""", """,
html, html,
count=count, count=count,
@@ -146,19 +148,41 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
) )
def assertFaviconVisible(self, html: str, bookmark: Bookmark): def assertFaviconVisible(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 1) self.assertFavicon(html, bookmark, True)
def assertFaviconHidden(self, html: str, bookmark: Bookmark): def assertFaviconHidden(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 0) self.assertFavicon(html, bookmark, False)
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1): def assertFavicon(self, html: str, bookmark: Bookmark, visible=True):
self.assertInHTML( soup = self.make_soup(html)
f"""
<img src="/static/{bookmark.favicon_file}" alt=""> favicon = soup.select_one(".favicon")
""",
html, if not visible:
count=count, self.assertIsNone(favicon)
) return
url = f"/static/{bookmark.favicon_file}"
self.assertIsNotNone(favicon)
self.assertEqual(favicon["src"], url)
def assertPreviewImageVisible(self, html: str, bookmark: Bookmark):
self.assertPreviewImage(html, bookmark, True)
def assertPreviewImageHidden(self, html: str, bookmark: Bookmark):
self.assertPreviewImage(html, bookmark, False)
def assertPreviewImage(self, html: str, bookmark: Bookmark, visible=True):
soup = self.make_soup(html)
preview_image = soup.select_one(".preview-image")
if not visible:
self.assertIsNone(preview_image)
return
url = f"/static/{bookmark.preview_image_file}"
self.assertIsNotNone(preview_image)
self.assertEqual(preview_image["src"], url)
def assertBookmarkURLCount( def assertBookmarkURLCount(
self, html: str, bookmark: Bookmark, link_target: str = "_blank", count=0 self, html: str, bookmark: Bookmark, link_target: str = "_blank", count=0
@@ -216,7 +240,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="unshare" value="{bookmark.id}" <button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?"> ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>
@@ -232,7 +256,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}" <button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?"> ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
@@ -301,7 +325,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
if has_description and has_tags: if has_description and has_tags:
self.assertTrue("|" in description.text) self.assertTrue("|" in description.text)
# contains description text # contains description text, without leading/trailing whitespace
if has_description: if has_description:
description_text = description.find("span", text=bookmark.description) description_text = description.find("span", text=bookmark.description)
self.assertIsNotNone(description_text) self.assertIsNotNone(description_text)
@@ -370,10 +394,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
description = soup.select_one(".description") description = soup.select_one(".description")
self.assertIsNone(description) self.assertIsNone(description)
else: else:
# contains description text # contains description text, without leading/trailing whitespace
description = soup.select_one(".description.separate") description = soup.select_one(".description.separate")
self.assertIsNotNone(description) self.assertIsNotNone(description)
self.assertEqual(description.text.strip(), bookmark.description) self.assertEqual(description.text, bookmark.description)
if not has_tags: if not has_tags:
# no tags element # no tags element
@@ -638,6 +662,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
html, html,
) )
def test_preview_image_should_be_visible_when_preview_images_enabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_preview_images = True
profile.save()
bookmark = self.setup_bookmark(preview_image_file="preview.png")
html = self.render_template()
self.assertPreviewImageVisible(html, bookmark)
def test_preview_image_should_be_hidden_when_preview_images_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_preview_images = False
profile.save()
bookmark = self.setup_bookmark(preview_image_file="preview.png")
html = self.render_template()
self.assertPreviewImageHidden(html, bookmark)
def test_preview_image_should_be_hidden_when_there_is_no_preview_image(self):
profile = self.get_or_create_test_user().profile
profile.enable_preview_images = True
profile.save()
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertPreviewImageHidden(html, bookmark)
def test_favicon_should_be_visible_when_favicons_enabled(self): def test_favicon_should_be_visible_when_favicons_enabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
profile.enable_favicons = True profile.enable_favicons = True
@@ -782,10 +836,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_note_cleans_html(self): def test_note_cleans_html(self):
self.setup_bookmark(notes='<script>alert("test")</script>') self.setup_bookmark(notes='<script>alert("test")</script>')
self.setup_bookmark(
notes='<b ld-fetch="https://example.com" ld-on="click">bold text</b>'
)
html = self.render_template() html = self.render_template()
note_html = '&lt;script&gt;alert("test")&lt;/script&gt;' note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
self.assertNotes(html, note_html, 1) self.assertIn(note_html, html, 1)
note_html = "<b>bold text</b>"
self.assertIn(note_html, html, 1)
def test_notes_are_hidden_initially_by_default(self): def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes="Test note") self.setup_bookmark(notes="Test note")

View File

@@ -1,10 +1,13 @@
import os
import tempfile
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, BookmarkAsset, Tag
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services.bookmarks import ( from bookmarks.services.bookmarks import (
@@ -21,6 +24,7 @@ from bookmarks.services.bookmarks import (
mark_bookmarks_as_unread, mark_bookmarks_as_unread,
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
upload_asset,
) )
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -38,7 +42,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
website_loader, "load_website_metadata" website_loader, "load_website_metadata"
) as mock_load_website_metadata: ) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata( expected_metadata = WebsiteMetadata(
"https://example.com", "Website title", "Website description" "https://example.com",
"Website title",
"Website description",
"https://example.com/preview.png",
) )
mock_load_website_metadata.return_value = expected_metadata mock_load_website_metadata.return_value = expected_metadata
@@ -123,6 +130,18 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_html_snapshot.assert_not_called() mock_create_html_snapshot.assert_not_called()
def test_create_should_add_tags_from_auto_tagging(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = f"example.com {tag2.name}"
profile.save()
bookmark_data = Bookmark(url="https://example.com")
bookmark = create_bookmark(bookmark_data, tag1.name, self.user)
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
def test_update_should_create_web_archive_snapshot_if_url_did_change(self): def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object( with patch.object(
tasks, "create_web_archive_snapshot" tasks, "create_web_archive_snapshot"
@@ -153,6 +172,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
"https://example.com/updated", "https://example.com/updated",
"Updated website title", "Updated website title",
"Updated website description", "Updated website description",
"https://example.com/preview.png",
) )
mock_load_website_metadata.return_value = expected_metadata mock_load_website_metadata.return_value = expected_metadata
@@ -193,6 +213,18 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_html_snapshot.assert_not_called() mock_create_html_snapshot.assert_not_called()
def test_update_should_add_tags_from_auto_tagging(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
profile = self.get_or_create_test_user().profile
profile.auto_tagging_rules = f"example.com {tag2.name}"
profile.save()
bookmark = self.setup_bookmark(url="https://example.com")
update_bookmark(bookmark, tag1.name, self.user)
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
def test_archive_bookmark(self): def test_archive_bookmark(self):
bookmark = Bookmark( bookmark = Bookmark(
url="https://example.com", url="https://example.com",
@@ -835,3 +867,50 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_upload_asset_should_save_file(self):
bookmark = self.setup_bookmark()
with tempfile.TemporaryDirectory() as temp_assets:
with override_settings(LD_ASSET_FOLDER=temp_assets):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertEqual(upload_file.size, asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_COMPLETE, asset.status)
self.assertTrue(asset.file.startswith("upload_"))
self.assertTrue(asset.file.endswith(upload_file.name))
# check file exists
filepath = os.path.join(temp_assets, asset.file)
self.assertTrue(os.path.exists(filepath))
with open(filepath, "rb") as f:
self.assertEqual(file_content, f.read())
def test_upload_asset_should_be_failed_if_saving_file_fails(self):
bookmark = self.setup_bookmark()
# Use an invalid path to force an error
with override_settings(LD_ASSET_FOLDER="/non/existing/folder"):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertIsNone(asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
self.assertEqual("", asset.file)

View File

@@ -74,11 +74,18 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.mock_singlefile_create_snapshot_patcher.start() self.mock_singlefile_create_snapshot_patcher.start()
) )
self.mock_load_preview_image_patcher = mock.patch(
"bookmarks.services.preview_image_loader.load_preview_image"
)
self.mock_load_preview_image = self.mock_load_preview_image_patcher.start()
self.mock_load_preview_image.return_value = "preview_image.png"
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
user.profile.web_archive_integration = ( user.profile.web_archive_integration = (
UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
) )
user.profile.enable_favicons = True user.profile.enable_favicons = True
user.profile.enable_preview_images = True
user.profile.save() user.profile.save()
def tearDown(self): def tearDown(self):
@@ -86,6 +93,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.mock_cdx_api_patcher.stop() self.mock_cdx_api_patcher.stop()
self.mock_load_favicon_patcher.stop() self.mock_load_favicon_patcher.stop()
self.mock_singlefile_create_snapshot_patcher.stop() self.mock_singlefile_create_snapshot_patcher.stop()
self.mock_load_preview_image_patcher.stop()
huey.storage.flush_results() huey.storage.flush_results()
huey.immediate = False huey.immediate = False
@@ -507,6 +515,136 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.executed_count(), 0) self.assertEqual(self.executed_count(), 0)
def test_load_preview_image_should_create_preview_image_file(self):
bookmark = self.setup_bookmark()
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
bookmark.refresh_from_db()
self.assertEqual(self.executed_count(), 1)
self.assertEqual(bookmark.preview_image_file, "preview_image.png")
def test_load_preview_image_should_update_preview_image_file(self):
bookmark = self.setup_bookmark(
preview_image_file="preview_image.png",
)
self.mock_load_preview_image.return_value = "preview_image_upd.png"
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
bookmark.refresh_from_db()
self.mock_load_preview_image.assert_called_once()
self.assertEqual(bookmark.preview_image_file, "preview_image_upd.png")
def test_load_preview_image_should_set_blank_when_none_is_returned(self):
bookmark = self.setup_bookmark(
preview_image_file="preview_image.png",
)
self.mock_load_preview_image.return_value = None
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
bookmark.refresh_from_db()
self.mock_load_preview_image.assert_called_once()
self.assertEqual(bookmark.preview_image_file, "")
def test_load_preview_image_should_handle_missing_bookmark(self):
tasks._load_preview_image_task(123)
self.mock_load_preview_image.assert_not_called()
def test_load_preview_image_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
# update bookmark during API call to check that saving
# the image does not overwrite updated bookmark data
def mock_load_preview_image_impl(url):
bookmark.title = "Updated title"
bookmark.save()
return "test.png"
self.mock_load_preview_image.side_effect = mock_load_preview_image_impl
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
bookmark.refresh_from_db()
self.assertEqual(bookmark.title, "Updated title")
self.assertEqual(bookmark.preview_image_file, "test.png")
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_load_preview_image_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
self.assertEqual(self.executed_count(), 0)
def test_load_preview_image_should_not_run_when_preview_image_feature_is_disabled(
self,
):
self.user.profile.enable_preview_images = False
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
self.assertEqual(self.executed_count(), 0)
def test_schedule_bookmarks_without_previews_should_load_preview_for_all_bookmarks_without_preview(
self,
):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(preview_image_file="test.png")
self.setup_bookmark(preview_image_file="test.png")
self.setup_bookmark(preview_image_file="test.png")
tasks.schedule_bookmarks_without_previews(user)
self.assertEqual(self.executed_count(), 4)
self.assertEqual(self.mock_load_preview_image.call_count, 3)
def test_schedule_bookmarks_without_previews_should_only_update_user_owned_bookmarks(
self,
):
user = self.get_or_create_test_user()
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
tasks.schedule_bookmarks_without_previews(user)
self.assertEqual(self.mock_load_preview_image.call_count, 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_previews_should_not_run_when_background_tasks_are_disabled(
self,
):
self.setup_bookmark()
tasks.schedule_bookmarks_without_previews(self.get_or_create_test_user())
self.assertEqual(self.executed_count(), 0)
def test_schedule_bookmarks_without_previews_should_not_run_when_preview_feature_is_disabled(
self,
):
self.user.profile.enable_preview_images = False
self.user.profile.save()
self.setup_bookmark()
tasks.schedule_bookmarks_without_previews(self.get_or_create_test_user())
self.assertEqual(self.executed_count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_create_pending_asset(self): def test_create_html_snapshot_should_create_pending_asset(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -611,3 +749,89 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
self.assertEqual(BookmarkAsset.objects.count(), 0) self.assertEqual(BookmarkAsset.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots(self):
bookmarks_with_snapshots = []
bookmarks_without_snapshots = []
# setup bookmarks with snapshots
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
bookmarks_with_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_PENDING,
)
bookmarks_with_snapshots.append(bookmark)
# setup bookmarks without snapshots
bookmark = self.setup_bookmark()
bookmarks_without_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_FAILURE,
)
bookmarks_without_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type="some_other_type",
status=BookmarkAsset.STATUS_PENDING,
)
bookmarks_without_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type="some_other_type",
status=BookmarkAsset.STATUS_COMPLETE,
)
bookmarks_without_snapshots.append(bookmark)
initial_assets = list(BookmarkAsset.objects.all())
initial_assets_count = len(initial_assets)
initial_asset_ids = [asset.id for asset in initial_assets]
count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())
self.assertEqual(count, 4)
self.assertEqual(BookmarkAsset.objects.count(), initial_assets_count + count)
for bookmark in bookmarks_without_snapshots:
new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(
id__in=initial_asset_ids
)
self.assertEqual(new_assets.count(), 1)
for bookmark in bookmarks_with_snapshots:
new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(
id__in=initial_asset_ids
)
self.assertEqual(new_assets.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots_respects_current_user(self):
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
other_user = self.setup_user()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())
self.assertEqual(count, 3)
self.assertEqual(BookmarkAsset.objects.count(), count)

View File

@@ -1,8 +1,9 @@
import io import io
import os.path import os.path
import time import time
import tempfile
from pathlib import Path from pathlib import Path
from unittest import mock, skip from unittest import mock
from django.conf import settings from django.conf import settings
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@@ -29,17 +30,21 @@ class MockStreamingResponse:
class FaviconLoaderTestCase(TestCase): class FaviconLoaderTestCase(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.ensure_favicon_folder() self.temp_favicon_folder = tempfile.TemporaryDirectory()
self.clear_favicon_folder() self.favicon_folder_override = self.settings(
LD_FAVICON_FOLDER=self.temp_favicon_folder.name
)
self.favicon_folder_override.enable()
def tearDown(self) -> None:
self.temp_favicon_folder.cleanup()
self.favicon_folder_override.disable()
def create_mock_response(self, icon_data=mock_icon_data, content_type="image/png"): def create_mock_response(self, icon_data=mock_icon_data, content_type="image/png"):
mock_response = mock.Mock() mock_response = mock.Mock()
mock_response.raw = io.BytesIO(icon_data) mock_response.raw = io.BytesIO(icon_data)
return MockStreamingResponse(icon_data, content_type) return MockStreamingResponse(icon_data, content_type)
def ensure_favicon_folder(self):
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
def clear_favicon_folder(self): def clear_favicon_folder(self):
folder = Path(settings.LD_FAVICON_FOLDER) folder = Path(settings.LD_FAVICON_FOLDER)
for file in folder.iterdir(): for file in folder.iterdir():
@@ -177,7 +182,6 @@ class FaviconLoaderTestCase(TestCase):
self.assertTrue(self.icon_exists("https_example_com.png")) self.assertTrue(self.icon_exists("https_example_com.png"))
self.clear_favicon_folder() self.clear_favicon_folder()
self.ensure_favicon_folder()
with mock.patch("requests.get") as mock_get: with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response( mock_get.return_value = self.create_mock_response(

View File

@@ -465,3 +465,14 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
import_netscape_html(test_html, user) import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_favicons.assert_called_once_with(user) mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)
def test_schedule_preview_loading(self):
user = self.get_or_create_test_user()
test_html = self.render_html(tags_html="")
with patch.object(
tasks, "schedule_bookmarks_without_previews"
) as mock_schedule_bookmarks_without_previews:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_previews.assert_called_once_with(user)

View File

@@ -0,0 +1,205 @@
import io
import os
import tempfile
from pathlib import Path
from unittest import mock
from django.conf import settings
from django.test import TestCase
from bookmarks.services import preview_image_loader
mock_image_data = b"mock_image"
class MockStreamingResponse:
def __init__(
self,
url,
data=mock_image_data,
content_type="image/png",
content_length=None,
status_code=200,
):
self.url = url
self.chunks = [data]
self.status_code = status_code
if not content_length:
content_length = len(data)
self.headers = {"Content-Type": content_type, "Content-Length": content_length}
def iter_content(self, **kwargs):
return self.chunks
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
pass
class PreviewImageLoaderTestCase(TestCase):
def setUp(self) -> None:
self.temp_folder = tempfile.TemporaryDirectory()
self.settings_override = self.settings(LD_PREVIEW_FOLDER=self.temp_folder.name)
self.settings_override.enable()
self.mock_load_website_metadata_patcher = mock.patch(
"bookmarks.services.website_loader.load_website_metadata"
)
self.mock_load_website_metadata = (
self.mock_load_website_metadata_patcher.start()
)
self.mock_load_website_metadata.return_value = mock.Mock(
preview_image="https://example.com/image.png"
)
def tearDown(self) -> None:
self.temp_folder.cleanup()
self.settings_override.disable()
self.mock_load_website_metadata_patcher.stop()
def create_mock_response(
self,
url="https://example.com/image.png",
icon_data=mock_image_data,
content_type="image/png",
content_length=len(mock_image_data),
status_code=200,
):
mock_response = mock.Mock()
mock_response.raw = io.BytesIO(icon_data)
return MockStreamingResponse(
url, icon_data, content_type, content_length, status_code
)
def get_image_path(self, filename):
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, filename))
def assertImageExists(self, filename, data):
self.assertTrue(self.get_image_path(filename).exists())
self.assertEqual(self.get_image_path(filename).read_bytes(), data)
def assertNoImageExists(self):
self.assertFalse(os.listdir(settings.LD_PREVIEW_FOLDER))
def test_load_preview_image(self):
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response()
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNotNone(file)
self.assertImageExists(file, mock_image_data)
def test_load_preview_image_returns_none_if_no_preview_image_detected(self):
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response()
self.mock_load_website_metadata.return_value = mock.Mock(preview_image=None)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNone(file)
self.assertNoImageExists()
def test_load_preview_image_returns_none_for_invalid_status_code(self):
invalid_status_codes = [199, 300, 400, 500]
for status_code in invalid_status_codes:
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(
status_code=status_code
)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNone(file)
self.assertNoImageExists()
def test_load_preview_image_returns_none_if_content_length_exceeds_limit(self):
# exceeds max size
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(
content_length=settings.LD_PREVIEW_MAX_SIZE + 1
)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNone(file)
self.assertNoImageExists()
# equals max size
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(
content_length=settings.LD_PREVIEW_MAX_SIZE
)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNotNone(file)
self.assertImageExists(file, mock_image_data)
def test_load_preview_image_returns_none_for_invalid_content_type(self):
invalid_content_types = ["text/html", "application/json"]
for content_type in invalid_content_types:
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(
content_type=content_type
)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNone(file)
self.assertNoImageExists()
valid_content_types = ["image/png", "image/jpeg", "image/gif"]
for content_type in valid_content_types:
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(
content_type=content_type
)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNotNone(file)
self.assertImageExists(file, mock_image_data)
def test_load_preview_image_returns_none_if_download_exceeds_content_length(self):
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(content_length=1)
file = preview_image_loader.load_preview_image("https://example.com")
self.assertIsNone(file)
self.assertNoImageExists()
def test_load_preview_image_creates_folder_if_not_exists(self):
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response()
folder = Path(settings.LD_PREVIEW_FOLDER)
folder.rmdir()
self.assertFalse(folder.exists())
preview_image_loader.load_preview_image("https://example.com")
self.assertTrue(folder.exists())
def test_guess_file_extension(self):
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(content_type="image/png")
file = preview_image_loader.load_preview_image("https://example.com")
self.assertImageExists(file, mock_image_data)
self.assertEqual("png", file.split(".")[-1])
with mock.patch("requests.get") as mock_get:
mock_get.return_value = self.create_mock_response(content_type="image/jpeg")
file = preview_image_loader.load_preview_image("https://example.com")
self.assertImageExists(file, mock_image_data)
self.assertEqual("jpg", file.split(".")[-1])

View File

@@ -31,8 +31,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_sharing": False, "enable_sharing": False,
"enable_public_sharing": False, "enable_public_sharing": False,
"enable_favicons": False, "enable_favicons": False,
"enable_preview_images": False,
"enable_automatic_html_snapshots": True, "enable_automatic_html_snapshots": True,
"tag_search": UserProfile.TAG_SEARCH_STRICT, "tag_search": UserProfile.TAG_SEARCH_STRICT,
"tag_grouping": UserProfile.TAG_GROUPING_ALPHABETICAL,
"display_url": False, "display_url": False,
"display_view_bookmark_action": True, "display_view_bookmark_action": True,
"display_edit_bookmark_action": True, "display_edit_bookmark_action": True,
@@ -40,10 +42,29 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"display_remove_bookmark_action": True, "display_remove_bookmark_action": True,
"permanent_notes": False, "permanent_notes": False,
"custom_css": "", "custom_css": "",
"auto_tagging_rules": "",
} }
return {**form_data, **overrides} return {**form_data, **overrides}
def assertSuccessMessage(self, html, message: str, count=1):
self.assertInHTML(
f"""
<div class="toast toast-success mb-4">{ message }</div>
""",
html,
count=count,
)
def assertErrorMessage(self, html, message: str, count=1):
self.assertInHTML(
f"""
<div class="toast toast-error mb-4">{ message }</div>
""",
html,
count=count,
)
def test_should_render_successfully(self): def test_should_render_successfully(self):
response = self.client.get(reverse("bookmarks:settings.general")) response = self.client.get(reverse("bookmarks:settings.general"))
@@ -70,15 +91,19 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_sharing": True, "enable_sharing": True,
"enable_public_sharing": True, "enable_public_sharing": True,
"enable_favicons": True, "enable_favicons": True,
"enable_preview_images": True,
"enable_automatic_html_snapshots": False, "enable_automatic_html_snapshots": False,
"tag_search": UserProfile.TAG_SEARCH_LAX, "tag_search": UserProfile.TAG_SEARCH_LAX,
"tag_grouping": UserProfile.TAG_GROUPING_DISABLED,
"display_url": True, "display_url": True,
"display_view_bookmark_action": False, "display_view_bookmark_action": False,
"display_edit_bookmark_action": False, "display_edit_bookmark_action": False,
"display_archive_bookmark_action": False, "display_archive_bookmark_action": False,
"display_remove_bookmark_action": False, "display_remove_bookmark_action": False,
"permanent_notes": True, "permanent_notes": True,
"default_mark_unread": True,
"custom_css": "body { background-color: #000; }", "custom_css": "body { background-color: #000; }",
"auto_tagging_rules": "example.com tag",
} }
response = self.client.post(reverse("bookmarks:settings.general"), form_data) response = self.client.post(reverse("bookmarks:settings.general"), form_data)
html = response.content.decode() html = response.content.decode()
@@ -112,11 +137,15 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
self.user.profile.enable_favicons, form_data["enable_favicons"] self.user.profile.enable_favicons, form_data["enable_favicons"]
) )
self.assertEqual(
self.user.profile.enable_preview_images, form_data["enable_preview_images"]
)
self.assertEqual( self.assertEqual(
self.user.profile.enable_automatic_html_snapshots, self.user.profile.enable_automatic_html_snapshots,
form_data["enable_automatic_html_snapshots"], form_data["enable_automatic_html_snapshots"],
) )
self.assertEqual(self.user.profile.tag_search, form_data["tag_search"]) self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
self.assertEqual(self.user.profile.tag_grouping, form_data["tag_grouping"])
self.assertEqual(self.user.profile.display_url, form_data["display_url"]) self.assertEqual(self.user.profile.display_url, form_data["display_url"])
self.assertEqual( self.assertEqual(
self.user.profile.display_view_bookmark_action, self.user.profile.display_view_bookmark_action,
@@ -137,13 +166,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
self.user.profile.permanent_notes, form_data["permanent_notes"] self.user.profile.permanent_notes, form_data["permanent_notes"]
) )
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"]) self.assertEqual(
self.assertInHTML( self.user.profile.default_mark_unread, form_data["default_mark_unread"]
"""
<p class="form-input-hint">Profile updated</p>
""",
html,
) )
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertEqual(
self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]
)
self.assertSuccessMessage(html, "Profile updated")
def test_update_profile_should_not_be_called_without_respective_form_action(self): def test_update_profile_should_not_be_called_without_respective_form_action(self):
form_data = { form_data = {
@@ -156,13 +186,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO) self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
self.assertInHTML( self.assertSuccessMessage(html, "Profile updated", count=0)
"""
<p class="form-input-hint">Profile updated</p>
""",
html,
count=0,
)
def test_enable_favicons_should_schedule_icon_update(self): def test_enable_favicons_should_schedule_icon_update(self):
with patch.object( with patch.object(
@@ -210,13 +234,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
mock_schedule_refresh_favicons.assert_called_once() mock_schedule_refresh_favicons.assert_called_once()
self.assertInHTML( self.assertSuccessMessage(
""" html, "Scheduled favicon update. This may take a while..."
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
""",
html,
) )
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self): def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
@@ -230,14 +249,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
mock_schedule_refresh_favicons.assert_not_called() mock_schedule_refresh_favicons.assert_not_called()
self.assertInHTML( self.assertSuccessMessage(
""" html, "Scheduled favicon update. This may take a while...", count=0
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
""",
html,
count=0,
) )
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self): def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
@@ -291,6 +304,39 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
count=0, count=0,
) )
def test_enable_preview_image_should_schedule_preview_update(self):
with patch.object(
tasks, "schedule_bookmarks_without_previews"
) as mock_schedule_bookmarks_without_previews:
# Enabling favicons schedules update
form_data = self.create_profile_form_data(
{
"update_profile": "",
"enable_preview_images": True,
}
)
self.client.post(reverse("bookmarks:settings.general"), form_data)
mock_schedule_bookmarks_without_previews.assert_called_once_with(self.user)
# No update scheduled if favicons are already enabled
mock_schedule_bookmarks_without_previews.reset_mock()
self.client.post(reverse("bookmarks:settings.general"), form_data)
mock_schedule_bookmarks_without_previews.assert_not_called()
# No update scheduled when disabling favicons
form_data = self.create_profile_form_data(
{
"enable_preview_images": False,
}
)
self.client.post(reverse("bookmarks:settings.general"), form_data)
mock_schedule_bookmarks_without_previews.assert_not_called()
def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported( def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(
self, self,
): ):
@@ -365,3 +411,57 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(requests, "get", return_value=latest_version_response_mock): with patch.object(requests, "get", return_value=latest_version_response_mock):
version_info = get_version_info(random.random()) version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version) self.assertEqual(version_info, app_version)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots(self):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 5
form_data = {
"create_missing_html_snapshots": "",
}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while..."
)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots_no_missing_snapshots(self):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 0
form_data = {
"create_missing_html_snapshots": "",
}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(html, "No missing snapshots found.")
def test_create_missing_html_snapshots_should_not_be_called_without_respective_form_action(
self,
):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 5
form_data = {}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_not_called()
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0
)

View File

@@ -11,19 +11,27 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def assertFormSuccessHint(self, response, text: str): def assertSuccessMessage(self, response, message: str):
self.assertContains(response, '<div class="has-success">') self.assertInHTML(
self.assertContains(response, text) f"""
<div class="toast toast-success mb-4">{ message }</div>
""",
response.content.decode("utf-8"),
)
def assertNoFormSuccessHint(self, response): def assertNoSuccessMessage(self, response):
self.assertNotContains(response, '<div class="has-success">') self.assertNotContains(response, '<div class="toast toast-success mb-4">')
def assertFormErrorHint(self, response, text: str): def assertErrorMessage(self, response, message: str):
self.assertContains(response, '<div class="has-error">') self.assertInHTML(
self.assertContains(response, text) f"""
<div class="toast toast-error mb-4">{ message }</div>
""",
response.content.decode("utf-8"),
)
def assertNoFormErrorHint(self, response): def assertNoErrorMessage(self, response):
self.assertNotContains(response, '<div class="has-error">') self.assertNotContains(response, '<div class="toast toast-error mb-4">')
def test_should_import_successfully(self): def test_should_import_successfully(self):
with open( with open(
@@ -36,10 +44,10 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
) )
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertFormSuccessHint( self.assertSuccessMessage(
response, "3 bookmarks were successfully imported" response, "3 bookmarks were successfully imported."
) )
self.assertNoFormErrorHint(response) self.assertNoErrorMessage(response)
def test_should_check_authentication(self): def test_should_check_authentication(self):
self.client.logout() self.client.logout()
@@ -53,8 +61,8 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse("bookmarks:settings.import"), follow=True) response = self.client.post(reverse("bookmarks:settings.import"), follow=True)
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertNoFormSuccessHint(response) self.assertNoSuccessMessage(response)
self.assertFormErrorHint(response, "Please select a file to import.") self.assertErrorMessage(response, "Please select a file to import.")
@disable_logging @disable_logging
def test_should_show_hint_if_import_raises_exception(self): def test_should_show_hint_if_import_raises_exception(self):
@@ -68,8 +76,8 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
) )
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertNoFormSuccessHint(response) self.assertNoSuccessMessage(response)
self.assertFormErrorHint( self.assertErrorMessage(
response, "An error occurred during bookmark import." response, "An error occurred during bookmark import."
) )
@@ -87,10 +95,13 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
) )
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertFormSuccessHint( self.assertSuccessMessage(
response, "2 bookmarks were successfully imported" response, "2 bookmarks were successfully imported."
)
self.assertErrorMessage(
response,
"1 bookmarks could not be imported. Please check the logs for more details.",
) )
self.assertFormErrorHint(response, "1 bookmarks could not be imported")
def test_should_respect_map_private_flag_option(self): def test_should_respect_map_private_flag_option(self):
with open( with open(

View File

@@ -61,6 +61,10 @@ class SingleFileServiceTestCase(TestCase):
expected_args = [ expected_args = [
"single-file", "single-file",
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
"http://example.com", "http://example.com",
self.html_filepath + ".tmp", self.html_filepath + ".tmp",
] ]
@@ -79,6 +83,10 @@ class SingleFileServiceTestCase(TestCase):
expected_args = [ expected_args = [
"single-file", "single-file",
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
"--some-option", "--some-option",
"some value", "some value",
"--another-option", "--another-option",
@@ -97,9 +105,9 @@ class SingleFileServiceTestCase(TestCase):
with mock.patch("subprocess.Popen", return_value=mock_process): with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath) singlefile.create_snapshot("http://example.com", self.html_filepath)
mock_process.wait.assert_called_with(timeout=60) mock_process.wait.assert_called_with(timeout=120)
@override_settings(LD_SINGLEFILE_TIMEOUT_SEC=120) @override_settings(LD_SINGLEFILE_TIMEOUT_SEC=180)
def test_create_snapshot_custom_timeout_setting(self): def test_create_snapshot_custom_timeout_setting(self):
mock_process = mock.Mock() mock_process = mock.Mock()
mock_process.wait.return_value = 0 mock_process.wait.return_value = 0
@@ -108,4 +116,4 @@ class SingleFileServiceTestCase(TestCase):
with mock.patch("subprocess.Popen", return_value=mock_process): with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath) singlefile.create_snapshot("http://example.com", self.html_filepath)
mock_process.wait.assert_called_with(timeout=120) mock_process.wait.assert_called_with(timeout=180)

View File

@@ -140,6 +140,43 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
], ],
) )
def test_group_when_grouping_disabled(self):
profile = self.get_or_create_test_user().profile
profile.tag_grouping = UserProfile.TAG_GROUPING_DISABLED
profile.save()
tags = [
self.setup_tag(name="Cockatoo"),
self.setup_tag(name="Badger"),
self.setup_tag(name="Buffalo"),
self.setup_tag(name="Chihuahua"),
self.setup_tag(name="Alpaca"),
self.setup_tag(name="Coyote"),
self.setup_tag(name="Aardvark"),
self.setup_tag(name="Bumblebee"),
self.setup_tag(name="Armadillo"),
]
self.setup_bookmark(tags=tags)
rendered_template = self.render_template()
self.assertTagGroups(
rendered_template,
[
[
"Aardvark",
"Alpaca",
"Armadillo",
"Badger",
"Buffalo",
"Bumblebee",
"Chihuahua",
"Cockatoo",
"Coyote",
],
],
)
def test_no_duplicate_tag_names(self): def test_no_duplicate_tag_names(self):
tags = [ tags = [
self.setup_tag(name="shared", user=self.setup_user(enable_sharing=True)), self.setup_tag(name="shared", user=self.setup_user(enable_sharing=True)),

View File

@@ -29,7 +29,9 @@ class WebsiteLoaderTestCase(TestCase):
# clear cached metadata before test run # clear cached metadata before test run
website_loader.load_website_metadata.cache_clear() website_loader.load_website_metadata.cache_clear()
def render_html_document(self, title, description="", og_description=""): def render_html_document(
self, title, description="", og_description="", og_image=""
):
meta_description = ( meta_description = (
f'<meta name="description" content="{description}">' if description else "" f'<meta name="description" content="{description}">' if description else ""
) )
@@ -38,6 +40,9 @@ class WebsiteLoaderTestCase(TestCase):
if og_description if og_description
else "" else ""
) )
meta_og_image = (
f'<meta property="og:image" content="{og_image}">' if og_image else ""
)
return f""" return f"""
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -46,6 +51,7 @@ class WebsiteLoaderTestCase(TestCase):
<title>{title}</title> <title>{title}</title>
{meta_description} {meta_description}
{meta_og_description} {meta_og_description}
{meta_og_image}
</head> </head>
<body></body> <body></body>
</html> </html>
@@ -105,6 +111,7 @@ class WebsiteLoaderTestCase(TestCase):
metadata = website_loader.load_website_metadata("https://example.com") metadata = website_loader.load_website_metadata("https://example.com")
self.assertEqual("test title", metadata.title) self.assertEqual("test title", metadata.title)
self.assertEqual("test description", metadata.description) self.assertEqual("test description", metadata.description)
self.assertIsNone(metadata.preview_image)
def test_load_website_metadata_trims_title_and_description(self): def test_load_website_metadata_trims_title_and_description(self):
with mock.patch( with mock.patch(
@@ -128,6 +135,44 @@ class WebsiteLoaderTestCase(TestCase):
self.assertEqual("test title", metadata.title) self.assertEqual("test title", metadata.title)
self.assertEqual("test og description", metadata.description) self.assertEqual("test og description", metadata.description)
def test_load_website_metadata_using_og_image(self):
with mock.patch(
"bookmarks.services.website_loader.load_page"
) as mock_load_page:
mock_load_page.return_value = self.render_html_document(
"test title", og_image="http://example.com/image.jpg"
)
metadata = website_loader.load_website_metadata("https://example.com")
self.assertEqual("http://example.com/image.jpg", metadata.preview_image)
def test_load_website_metadata_gets_absolute_og_image_path_when_path_starts_with_dots(
self,
):
with mock.patch(
"bookmarks.services.website_loader.load_page"
) as mock_load_page:
mock_load_page.return_value = self.render_html_document(
"test title", og_image="../image.jpg"
)
metadata = website_loader.load_website_metadata(
"https://example.com/a/b/page.html"
)
self.assertEqual("https://example.com/a/image.jpg", metadata.preview_image)
def test_load_website_metadata_gets_absolute_og_image_path_when_path_starts_with_slash(
self,
):
with mock.patch(
"bookmarks.services.website_loader.load_page"
) as mock_load_page:
mock_load_page.return_value = self.render_html_document(
"test title", og_image="/image.jpg"
)
metadata = website_loader.load_website_metadata(
"https://example.com/a/b/page.html"
)
self.assertEqual("https://example.com/image.jpg", metadata.preview_image)
def test_load_website_metadata_prefers_description_over_og_description(self): def test_load_website_metadata_prefers_description_over_og_description(self):
with mock.patch( with mock.patch(
"bookmarks.services.website_loader.load_page" "bookmarks.services.website_loader.load_page"

View File

@@ -44,12 +44,22 @@ urlpatterns = [
views.bookmarks.details_modal, views.bookmarks.details_modal,
name="details_modal", name="details_modal",
), ),
path(
"bookmarks/<int:bookmark_id>/details_assets",
views.bookmarks.details_assets,
name="details_assets",
),
# Assets # Assets
path( path(
"assets/<int:asset_id>", "assets/<int:asset_id>",
views.assets.view, views.assets.view,
name="assets.view", name="assets.view",
), ),
path(
"assets/<int:asset_id>/read",
views.assets.read,
name="assets.read",
),
# Partials # Partials
path( path(
"bookmarks/partials/bookmark-list/active", "bookmarks/partials/bookmark-list/active",
@@ -61,6 +71,11 @@ urlpatterns = [
partials.active_tag_cloud, partials.active_tag_cloud,
name="partials.tag_cloud.active", name="partials.tag_cloud.active",
), ),
path(
"bookmarks/partials/tag-modal/active",
partials.active_tag_modal,
name="partials.tag_modal.active",
),
path( path(
"bookmarks/partials/bookmark-list/archived", "bookmarks/partials/bookmark-list/archived",
partials.archived_bookmark_list, partials.archived_bookmark_list,
@@ -71,6 +86,11 @@ urlpatterns = [
partials.archived_tag_cloud, partials.archived_tag_cloud,
name="partials.tag_cloud.archived", name="partials.tag_cloud.archived",
), ),
path(
"bookmarks/partials/tag-modal/archived",
partials.archived_tag_modal,
name="partials.tag_modal.archived",
),
path( path(
"bookmarks/partials/bookmark-list/shared", "bookmarks/partials/bookmark-list/shared",
partials.shared_bookmark_list, partials.shared_bookmark_list,
@@ -82,9 +102,9 @@ urlpatterns = [
name="partials.tag_cloud.shared", name="partials.tag_cloud.shared",
), ),
path( path(
"bookmarks/partials/details-form/<int:bookmark_id>", "bookmarks/partials/tag-modal/shared",
partials.details_form, partials.shared_tag_modal,
name="partials.details_form", name="partials.tag_modal.shared",
), ),
# Settings # Settings
path("settings", views.settings.general, name="settings.index"), path("settings", views.settings.general, name="settings.index"),

View File

@@ -6,11 +6,12 @@ from django.http import (
HttpResponse, HttpResponse,
Http404, Http404,
) )
from django.shortcuts import render
from bookmarks.models import BookmarkAsset from bookmarks.models import BookmarkAsset
def view(request, asset_id: int): def _access_asset(request, asset_id: int):
try: try:
asset = BookmarkAsset.objects.get(pk=asset_id) asset = BookmarkAsset.objects.get(pk=asset_id)
except BookmarkAsset.DoesNotExist: except BookmarkAsset.DoesNotExist:
@@ -28,6 +29,10 @@ def view(request, asset_id: int):
if not is_owner and not is_shared and not is_public_shared: if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist") raise Http404("Bookmark does not exist")
return asset
def _get_asset_content(asset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if not os.path.exists(filepath): if not os.path.exists(filepath):
@@ -40,4 +45,25 @@ def view(request, asset_id: int):
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
content = f.read() content = f.read()
return content
def view(request, asset_id: int):
asset = _access_asset(request, asset_id)
content = _get_asset_content(asset)
return HttpResponse(content, content_type=asset.content_type) return HttpResponse(content, content_type=asset.content_type)
def read(request, asset_id: int):
asset = _access_asset(request, asset_id)
content = _get_asset_content(asset)
content = content.decode("utf-8")
return render(
request,
"bookmarks/read.html",
{
"content": content,
},
)

View File

@@ -34,7 +34,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
) )
from bookmarks.services import tasks from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -145,6 +145,11 @@ def _details(request, bookmark_id: int, template: str):
asset.delete() asset.delete()
if "create_snapshot" in request.POST: if "create_snapshot" in request.POST:
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
if "upload_asset" in request.POST:
file = request.FILES.get("upload_asset_file")
if not file:
return HttpResponseBadRequest("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
else: else:
bookmark.is_archived = request.POST.get("is_archived") == "on" bookmark.is_archived = request.POST.get("is_archived") == "on"
bookmark.unread = request.POST.get("unread") == "on" bookmark.unread = request.POST.get("unread") == "on"
@@ -172,6 +177,10 @@ def details_modal(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details_modal.html") return _details(request, bookmark_id, "bookmarks/details_modal.html")
def details_assets(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details/assets.html")
def convert_tag_string(tag_string: str): def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings # strings
@@ -184,6 +193,7 @@ def new(request):
initial_title = request.GET.get("title") initial_title = request.GET.get("title")
initial_description = request.GET.get("description") initial_description = request.GET.get("description")
initial_auto_close = "auto_close" in request.GET initial_auto_close = "auto_close" in request.GET
initial_mark_unread = request.user.profile.default_mark_unread
if request.method == "POST": if request.method == "POST":
form = BookmarkForm(request.POST) form = BookmarkForm(request.POST)
@@ -206,6 +216,8 @@ def new(request):
form.initial["description"] = initial_description form.initial["description"] = initial_description
if initial_auto_close: if initial_auto_close:
form.initial["auto_close"] = "true" form.initial["auto_close"] = "true"
if initial_mark_unread:
form.initial["unread"] = "true"
context = { context = {
"form": form, "form": form,

View File

@@ -1,8 +1,6 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.shortcuts import render from django.shortcuts import render
from bookmarks.models import Bookmark
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -24,6 +22,13 @@ def active_tag_cloud(request):
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context}) return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required
def active_tag_modal(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
@login_required @login_required
def archived_bookmark_list(request): def archived_bookmark_list(request):
bookmark_list_context = contexts.ArchivedBookmarkListContext(request) bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
@@ -43,6 +48,12 @@ def archived_tag_cloud(request):
@login_required @login_required
def archived_tag_modal(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
def shared_bookmark_list(request): def shared_bookmark_list(request):
bookmark_list_context = contexts.SharedBookmarkListContext(request) bookmark_list_context = contexts.SharedBookmarkListContext(request)
@@ -53,20 +64,13 @@ def shared_bookmark_list(request):
) )
@login_required
def shared_tag_cloud(request): def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request) tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context}) return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required def shared_tag_modal(request):
def details_form(request, bookmark_id: int): tag_cloud_context = contexts.SharedTagCloudContext(request)
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
details_context = contexts.BookmarkDetailsContext(request, bookmark) return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
return render(request, "bookmarks/details/form.html", {"details": details_context})

View File

@@ -23,6 +23,113 @@ DEFAULT_PAGE_SIZE = 30
CJK_RE = re.compile(r"[\u4e00-\u9fff]+") CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
class RequestContext:
index_view = "bookmarks:index"
action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def __init__(self, request: WSGIRequest):
self.request = request
self.index_url = reverse(self.index_view)
self.action_url = reverse(self.action_view)
self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
self.query_params = request.GET.copy()
self.query_params.pop("details", None)
def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:
query_params = self.query_params.copy()
if add:
query_params.update(add)
if remove:
for key in remove:
query_params.pop(key, None)
encoded_params = query_params.urlencode()
return view_url + "?" + encoded_params if encoded_params else view_url
def index(self) -> str:
return self.get_url(self.index_url)
def action(self, return_url: str) -> str:
return self.get_url(self.action_url, add={"return_url": return_url})
def bookmark_list_partial(self) -> str:
return self.get_url(self.bookmark_list_partial_url)
def tag_cloud_partial(self) -> str:
return self.get_url(self.tag_cloud_partial_url)
def tag_modal_partial(self) -> str:
return self.get_url(self.tag_modal_partial_url)
def get_bookmark_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass")
def get_tag_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass")
class ActiveBookmarksContext(RequestContext):
index_view = "bookmarks:index"
action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_bookmarks(
self.request.user, self.request.user_profile, search
)
def get_tag_query_set(self, search: BookmarkSearch):
return queries.query_bookmark_tags(
self.request.user, self.request.user_profile, search
)
class ArchivedBookmarksContext(RequestContext):
index_view = "bookmarks:archived"
action_view = "bookmarks:archived.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmarks(
self.request.user, self.request.user_profile, search
)
def get_tag_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmark_tags(
self.request.user, self.request.user_profile, search
)
class SharedBookmarksContext(RequestContext):
index_view = "bookmarks:shared"
action_view = "bookmarks:shared.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
def get_bookmark_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, search, public_only
)
def get_tag_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, search, public_only
)
class BookmarkItem: class BookmarkItem:
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None: def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
self.bookmark = bookmark self.bookmark = bookmark
@@ -38,6 +145,7 @@ class BookmarkItem:
self.tag_names = bookmark.tag_names self.tag_names = bookmark.tag_names
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
self.favicon_file = bookmark.favicon_file self.favicon_file = bookmark.favicon_file
self.preview_image_file = bookmark.preview_image_file
self.is_archived = bookmark.is_archived self.is_archived = bookmark.is_archived
self.unread = bookmark.unread self.unread = bookmark.unread
self.owner = bookmark.owner self.owner = bookmark.owner
@@ -67,7 +175,10 @@ class BookmarkItem:
class BookmarkListContext: class BookmarkListContext:
request_context = RequestContext
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
request_context = self.request_context(request)
user = request.user user = request.user
user_profile = request.user_profile user_profile = request.user_profile
@@ -76,7 +187,7 @@ class BookmarkListContext:
self.request.GET, user_profile.search_preferences self.request.GET, user_profile.search_preferences
) )
query_set = self.get_bookmark_query_set() query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page") page_number = request.GET.get("page")
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE) paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
bookmarks_page = paginator.get_page(page_number) bookmarks_page = paginator.get_page(page_number)
@@ -86,16 +197,15 @@ class BookmarkListContext:
self.items = [ self.items = [
BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page
] ]
self.is_empty = paginator.count == 0 self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count self.bookmarks_total = paginator.count
self.return_url = self.generate_return_url(
self.search, self.get_base_url(), page_number self.return_url = request_context.index()
) self.action_url = request_context.action(return_url=self.return_url)
self.action_url = self.generate_action_url( self.refresh_url = request_context.bookmark_list_partial()
self.search, self.get_base_action_url(), self.return_url self.tag_modal_url = request_context.tag_modal_partial()
)
self.link_target = user_profile.bookmark_link_target self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display self.date_display = user_profile.bookmark_date_display
self.description_display = user_profile.bookmark_description_display self.description_display = user_profile.bookmark_description_display
@@ -106,6 +216,7 @@ class BookmarkListContext:
self.show_archive_action = user_profile.display_archive_bookmark_action self.show_archive_action = user_profile.display_archive_bookmark_action
self.show_remove_action = user_profile.display_remove_bookmark_action self.show_remove_action = user_profile.display_remove_bookmark_action
self.show_favicons = user_profile.enable_favicons self.show_favicons = user_profile.enable_favicons
self.show_preview_images = user_profile.enable_preview_images
self.show_notes = user_profile.permanent_notes self.show_notes = user_profile.permanent_notes
@staticmethod @staticmethod
@@ -131,55 +242,17 @@ class BookmarkListContext:
else base_action_url + "?" + query_string else base_action_url + "?" + query_string
) )
def get_base_url(self):
raise Exception("Must be implemented by subclass")
def get_base_action_url(self):
raise Exception("Must be implemented by subclass")
def get_bookmark_query_set(self):
raise Exception("Must be implemented by subclass")
class ActiveBookmarkListContext(BookmarkListContext): class ActiveBookmarkListContext(BookmarkListContext):
def get_base_url(self): request_context = ActiveBookmarksContext
return reverse("bookmarks:index")
def get_base_action_url(self):
return reverse("bookmarks:index.action")
def get_bookmark_query_set(self):
return queries.query_bookmarks(
self.request.user, self.request.user_profile, self.search
)
class ArchivedBookmarkListContext(BookmarkListContext): class ArchivedBookmarkListContext(BookmarkListContext):
def get_base_url(self): request_context = ArchivedBookmarksContext
return reverse("bookmarks:archived")
def get_base_action_url(self):
return reverse("bookmarks:archived.action")
def get_bookmark_query_set(self):
return queries.query_archived_bookmarks(
self.request.user, self.request.user_profile, self.search
)
class SharedBookmarkListContext(BookmarkListContext): class SharedBookmarkListContext(BookmarkListContext):
def get_base_url(self): request_context = SharedBookmarksContext
return reverse("bookmarks:shared")
def get_base_action_url(self):
return reverse("bookmarks:shared.action")
def get_bookmark_query_set(self):
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, self.search, public_only
)
class TagGroup: class TagGroup:
@@ -191,7 +264,16 @@ class TagGroup:
return f"<{self.char} TagGroup>" return f"<{self.char} TagGroup>"
@staticmethod @staticmethod
def create_tag_groups(tags: Set[Tag]): def create_tag_groups(mode: str, tags: Set[Tag]):
if mode == UserProfile.TAG_GROUPING_ALPHABETICAL:
return TagGroup._create_tag_groups_alphabetical(tags)
elif mode == UserProfile.TAG_GROUPING_DISABLED:
return TagGroup._create_tag_groups_disabled(tags)
else:
raise ValueError(f"{mode} is not a valid tag grouping mode")
@staticmethod
def _create_tag_groups_alphabetical(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically # Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name)) sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None group = None
@@ -216,9 +298,24 @@ class TagGroup:
groups.append(cjk_group) groups.append(cjk_group)
return groups return groups
@staticmethod
def _create_tag_groups_disabled(tags: Set[Tag]):
if len(tags) == 0:
return []
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = TagGroup("Ungrouped")
for tag in sorted_tags:
group.tags.append(tag)
return [group]
class TagCloudContext: class TagCloudContext:
request_context = RequestContext
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
request_context = self.request_context(request)
user_profile = request.user_profile user_profile = request.user_profile
self.request = request self.request = request
@@ -226,7 +323,7 @@ class TagCloudContext:
self.request.GET, user_profile.search_preferences self.request.GET, user_profile.search_preferences
) )
query_set = self.get_tag_query_set() query_set = request_context.get_tag_query_set(self.search)
tags = list(query_set) tags = list(query_set)
selected_tags = self.get_selected_tags(tags) selected_tags = self.get_selected_tags(tags)
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name)) unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
@@ -235,15 +332,14 @@ class TagCloudContext:
) )
has_selected_tags = len(unique_selected_tags) > 0 has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags) unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(unselected_tags) groups = TagGroup.create_tag_groups(user_profile.tag_grouping, unselected_tags)
self.tags = unique_tags self.tags = unique_tags
self.groups = groups self.groups = groups
self.selected_tags = unique_selected_tags self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags self.has_selected_tags = has_selected_tags
def get_tag_query_set(self): self.refresh_url = request_context.tag_cloud_partial()
raise Exception("Must be implemented by subclass")
def get_selected_tags(self, tags: List[Tag]): def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.search.q) parsed_query = queries.parse_query_string(self.search.q)
@@ -256,26 +352,15 @@ class TagCloudContext:
class ActiveTagCloudContext(TagCloudContext): class ActiveTagCloudContext(TagCloudContext):
def get_tag_query_set(self): request_context = ActiveBookmarksContext
return queries.query_bookmark_tags(
self.request.user, self.request.user_profile, self.search
)
class ArchivedTagCloudContext(TagCloudContext): class ArchivedTagCloudContext(TagCloudContext):
def get_tag_query_set(self): request_context = ArchivedBookmarksContext
return queries.query_archived_bookmark_tags(
self.request.user, self.request.user_profile, self.search
)
class SharedTagCloudContext(TagCloudContext): class SharedTagCloudContext(TagCloudContext):
def get_tag_query_set(self): request_context = SharedBookmarksContext
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, self.search, public_only
)
class BookmarkAssetItem: class BookmarkAssetItem:
@@ -284,6 +369,7 @@ class BookmarkAssetItem:
self.id = asset.id self.id = asset.id
self.display_name = asset.display_name self.display_name = asset.display_name
self.asset_type = asset.asset_type
self.content_type = asset.content_type self.content_type = asset.content_type
self.file = asset.file self.file = asset.file
self.file_size = asset.file_size self.file_size = asset.file_size
@@ -321,6 +407,7 @@ class BookmarkDetailsContext:
self.profile = request.user_profile self.profile = request.user_profile
self.is_editable = bookmark.owner == user self.is_editable = bookmark.owner == user
self.sharing_enabled = user_profile.enable_sharing self.sharing_enabled = user_profile.enable_sharing
self.preview_image_enabled = user_profile.enable_preview_images
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
# For now hide files section if snapshots are not supported # For now hide files section if snapshots are not supported
self.show_files = settings.LD_ENABLE_SNAPSHOTS self.show_files = settings.LD_ENABLE_SNAPSHOTS
@@ -328,3 +415,15 @@ class BookmarkDetailsContext:
self.assets = [ self.assets = [
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all() BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
] ]
self.has_pending_assets = any(
asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets
)
self.latest_snapshot = next(
(
asset
for asset in self.assets
if asset.asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
and asset.status == BookmarkAsset.STATUS_COMPLETE
),
None,
)

View File

@@ -25,12 +25,10 @@ def general(request):
profile_form = None profile_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
update_profile_success_message = None success_message = _find_message_with_tag(
refresh_favicons_success_message = None
import_success_message = _find_message_with_tag(
messages.get_messages(request), "bookmark_import_success" messages.get_messages(request), "bookmark_import_success"
) )
import_errors_message = _find_message_with_tag( error_message = _find_message_with_tag(
messages.get_messages(request), "bookmark_import_errors" messages.get_messages(request), "bookmark_import_errors"
) )
version_info = get_version_info(get_ttl_hash()) version_info = get_version_info(get_ttl_hash())
@@ -38,12 +36,18 @@ def general(request):
if request.method == "POST": if request.method == "POST":
if "update_profile" in request.POST: if "update_profile" in request.POST:
profile_form = update_profile(request) profile_form = update_profile(request)
update_profile_success_message = "Profile updated" success_message = "Profile updated"
if "refresh_favicons" in request.POST: if "refresh_favicons" in request.POST:
tasks.schedule_refresh_favicons(request.user) tasks.schedule_refresh_favicons(request.user)
refresh_favicons_success_message = ( success_message = "Scheduled favicon update. This may take a while..."
"Scheduled favicon update. This may take a while..." if "create_missing_html_snapshots" in request.POST:
) count = tasks.create_missing_html_snapshots(request.user)
if count > 0:
success_message = (
f"Queued {count} missing snapshots. This may take a while..."
)
else:
success_message = "No missing snapshots found."
if not profile_form: if not profile_form:
profile_form = UserProfileForm(instance=request.user_profile) profile_form = UserProfileForm(instance=request.user_profile)
@@ -55,10 +59,8 @@ def general(request):
"form": profile_form, "form": profile_form,
"enable_refresh_favicons": enable_refresh_favicons, "enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support, "has_snapshot_support": has_snapshot_support,
"update_profile_success_message": update_profile_success_message, "success_message": success_message,
"refresh_favicons_success_message": refresh_favicons_success_message, "error_message": error_message,
"import_success_message": import_success_message,
"import_errors_message": import_errors_message,
"version_info": version_info, "version_info": version_info,
}, },
) )
@@ -68,11 +70,16 @@ def update_profile(request):
user = request.user user = request.user
profile = user.profile profile = user.profile
favicons_were_enabled = profile.enable_favicons favicons_were_enabled = profile.enable_favicons
previews_were_enabled = profile.enable_preview_images
form = UserProfileForm(request.POST, instance=profile) form = UserProfileForm(request.POST, instance=profile)
if form.is_valid(): if form.is_valid():
form.save() form.save()
# Load missing favicons if the feature was just enabled
if profile.enable_favicons and not favicons_were_enabled: if profile.enable_favicons and not favicons_were_enabled:
tasks.schedule_bookmarks_without_favicons(request.user) tasks.schedule_bookmarks_without_favicons(request.user)
# Load missing preview images if the feature was just enabled
if profile.enable_preview_images and not previews_were_enabled:
tasks.schedule_bookmarks_without_previews(request.user)
return form return form

View File

@@ -7,6 +7,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
mkdir -p data mkdir -p data
# Create favicon folder if it does not exist # Create favicon folder if it does not exist
mkdir -p data/favicons mkdir -p data/favicons
# Create previews folder if it does not exist
mkdir -p data/previews
# Create assets folder if it does not exist # Create assets folder if it does not exist
mkdir -p data/assets mkdir -p data/assets
@@ -21,7 +23,7 @@ python manage.py create_initial_superuser
# Migrate legacy background tasks to Huey # Migrate legacy background tasks to Huey
python manage.py migrate_tasks python manage.py migrate_tasks
# Ensure the DB folder is owned by the right user # Ensure folders are owned by the right user
chown -R www-data: /etc/linkding/data chown -R www-data: /etc/linkding/data
# Start background task processor using supervisord, unless explicitly disabled # Start background task processor using supervisord, unless explicitly disabled

View File

@@ -99,10 +99,31 @@ CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health ||
CMD ["./bootstrap.sh"] CMD ["./bootstrap.sh"]
FROM node:18-alpine AS ublock-build
WORKDIR /etc/linkding
# Install necessary tools
RUN apk add --no-cache curl jq unzip
# Fetch the latest release tag
# Download the library
# Unzip the library
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json
FROM linkding AS linkding-plus FROM linkding AS linkding-plus
# install node, chromium # install node, chromium
RUN apk update && apk add nodejs npm chromium RUN apk update && apk add nodejs npm chromium
# install single-file from fork for now, which contains several hotfixes # install single-file from fork for now, which contains several hotfixes
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/f3730995a52f27d5041a1ad9e7528af4b6b4cf4b RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
# copy uBlock0
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
# create chromium profile folder for user running background tasks
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
# enable snapshot support # enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -96,6 +96,24 @@ CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health ||
CMD ["./bootstrap.sh"] CMD ["./bootstrap.sh"]
FROM node:18-alpine AS ublock-build
WORKDIR /etc/linkding
# Install necessary tools
RUN apk add --no-cache curl jq unzip
# Fetch the latest release tag
# Download the library
# Unzip the library
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json
FROM linkding AS linkding-plus FROM linkding AS linkding-plus
# install chromium # install chromium
RUN apt-get update && apt-get -y install chromium RUN apt-get update && apt-get -y install chromium
@@ -106,6 +124,10 @@ RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
apt-get update && apt-get install -y nodejs apt-get update && apt-get install -y nodejs
# install single-file from fork for now, which contains several hotfixes # install single-file from fork for now, which contains several hotfixes
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/f3730995a52f27d5041a1ad9e7528af4b6b4cf4b RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
# create chromium profile folder for user running background tasks
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
# copy uBlock0
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
# enable snapshot support # enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -8,12 +8,13 @@ The data folder contains the following contents that are relevant for backups:
- `db.sqlite3` - the SQLite database - `db.sqlite3` - the SQLite database
- `assets` - folder that contains HTML snapshots of bookmarks - `assets` - folder that contains HTML snapshots of bookmarks
- `favicons` - folder that contains downloaded favicons - `favicons` - folder that contains downloaded favicons
- `previews` - folder that contains downloaded preview images
The following sections explain how to back up the individual contents. The following sections explain how to back up the individual contents.
## Full backup ## Full backup
linkding provides a CLI command to create a full backup of the data folder. This creates a zip file that contains backups of the database, assets, and favicons. linkding provides a CLI command to create a full backup of the data folder. This creates a zip file that contains backups of the database, assets, favicons, and preview images.
> [!NOTE] > [!NOTE]
> This method assumes that you are using the default SQLite database. > This method assumes that you are using the default SQLite database.
@@ -90,7 +91,7 @@ This is the least technical option to back up bookmarks, but has several limitat
- It only exports your own bookmarks, not those of other users. - It only exports your own bookmarks, not those of other users.
- It does not export URLs of snapshots on the Internet Archive Wayback machine. - It does not export URLs of snapshots on the Internet Archive Wayback machine.
- It does not export HTML snapshots of bookmarks. Even if you backup and restore the assets folder, the bookmarks will not be linked to the snapshots anymore. - It does not export HTML snapshots of bookmarks. Even if you backup and restore the assets folder, the bookmarks will not be linked to the snapshots anymore.
- It does not export favicons. - It does not export favicons or preview images.
Only use this method if you are fine with the above limitations. Only use this method if you are fine with the above limitations.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.25.0", "version": "1.30.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "linkding", "name": "linkding",
"version": "1.25.0", "version": "1.30.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.28.0", "version": "1.31.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -28,5 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.0.2" "prettier": "^3.0.2"
} },
"web-types": "./web-types.json"
} }

View File

@@ -54,7 +54,7 @@ rcssmin==1.1.1
# via django-compressor # via django-compressor
rjsmin==1.2.1 rjsmin==1.2.1
# via django-compressor # via django-compressor
sqlparse==0.4.4 sqlparse==0.5.0
# via # via
# django # django
# django-debug-toolbar # django-debug-toolbar

View File

@@ -43,7 +43,7 @@ djangorestframework==3.14.0
# via -r requirements.in # via -r requirements.in
huey==2.5.0 huey==2.5.0
# via -r requirements.in # via -r requirements.in
idna==3.6 idna==3.7
# via requests # via requests
josepy==1.14.0 josepy==1.14.0
# via mozilla-django-oidc # via mozilla-django-oidc
@@ -61,7 +61,7 @@ python-dateutil==2.8.2
# via -r requirements.in # via -r requirements.in
pytz==2023.3.post1 pytz==2023.3.post1
# via djangorestframework # via djangorestframework
requests==2.31.0 requests==2.32.0
# via # via
# -r requirements.in # -r requirements.in
# mozilla-django-oidc # mozilla-django-oidc
@@ -72,7 +72,7 @@ six==1.16.0
# python-dateutil # python-dateutil
soupsieve==2.5 soupsieve==2.5
# via beautifulsoup4 # via beautifulsoup4
sqlparse==0.4.4 sqlparse==0.5.0
# via django # via django
supervisor==4.2.5 supervisor==4.2.5
# via -r requirements.in # via -r requirements.in

13
scripts/setup-ublock.sh Executable file
View File

@@ -0,0 +1,13 @@
rm -rf ublock0.chromium
TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name')
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip
curl -L -o uBlock0.zip $DOWNLOAD_URL
unzip uBlock0.zip
rm uBlock0.zip
curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json
mv temp.json ./uBlock0.chromium/assets/assets.json
mkdir -p chromium-profile

View File

@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import json import json
import os import os
import shlex
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -143,6 +144,7 @@ STATICFILES_FINDERS = [
STATICFILES_DIRS = [ STATICFILES_DIRS = [
os.path.join(BASE_DIR, "bookmarks", "styles"), os.path.join(BASE_DIR, "bookmarks", "styles"),
os.path.join(BASE_DIR, "data", "favicons"), os.path.join(BASE_DIR, "data", "favicons"),
os.path.join(BASE_DIR, "data", "previews"),
] ]
# REST framework # REST framework
@@ -285,6 +287,18 @@ LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
"1", "1",
) )
# Previews settings
LD_PREVIEW_FOLDER = os.path.join(BASE_DIR, "data", "previews")
LD_PREVIEW_MAX_SIZE = int(os.getenv("LD_PREVIEW_MAX_SIZE", 5242880))
LD_PREVIEW_ALLOWED_EXTENSIONS = [
".jpg",
".jpeg",
".png",
".gif",
".svg",
".webp",
]
# Asset / snapshot settings # Asset / snapshot settings
LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets") LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
@@ -294,8 +308,19 @@ LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
"1", "1",
) )
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file") LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
"LD_SINGLEFILE_UBLOCK_OPTIONS",
shlex.join(
[
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
]
),
)
LD_SINGLEFILE_OPTIONS = os.getenv("LD_SINGLEFILE_OPTIONS", "") LD_SINGLEFILE_OPTIONS = os.getenv("LD_SINGLEFILE_OPTIONS", "")
LD_SINGLEFILE_TIMEOUT_SEC = float(os.getenv("LD_SINGLEFILE_TIMEOUT_SEC", 60)) LD_SINGLEFILE_TIMEOUT_SEC = float(os.getenv("LD_SINGLEFILE_TIMEOUT_SEC", 120))
# Monolith isn't used at the moment, as the local snapshot implementation # Monolith isn't used at the moment, as the local snapshot implementation
# switched to single-file after the prototype. Keeping this around in case # switched to single-file after the prototype. Keeping this around in case

View File

@@ -4,6 +4,8 @@ loglevel=info
[program:jobs] [program:jobs]
user=www-data user=www-data
# setup a temp home folder for the job, required by chromium
environment=HOME=/tmp/home
command=python manage.py run_huey -f command=python manage.py run_huey -f
stdout_logfile=background_tasks.log stdout_logfile=background_tasks.log
stdout_logfile_maxbytes=10MB stdout_logfile_maxbytes=10MB

View File

@@ -3,6 +3,7 @@ module = siteroot.wsgi:application
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
static-map = /static=static static-map = /static=static
static-map = /static=data/favicons static-map = /static=data/favicons
static-map = /static=data/previews
processes = 2 processes = 2
threads = 2 threads = 2
pidfile = /tmp/linkding.pid pidfile = /tmp/linkding.pid
@@ -16,6 +17,7 @@ die-on-term = true
if-env = LD_CONTEXT_PATH if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static static-map = /%(_)static=static
static-map = /%(_)static=data/favicons static-map = /%(_)static=data/favicons
static-map = /%(_)static=data/previews
endif = endif =
if-env = LD_REQUEST_TIMEOUT if-env = LD_REQUEST_TIMEOUT

View File

@@ -1 +1 @@
1.28.0 1.31.0

137
web-types.json Normal file
View File

@@ -0,0 +1,137 @@
{
"$schema": "https://raw.githubusercontent.com/JetBrains/web-types/master/schema/web-types.json",
"name": "linkding",
"version": "1.0.0",
"contributions": {
"html": {
"attributes": [
{
"name": "ld-fetch",
"description": "Fetches the HTML content of the given URL and replaces the content of an element with it. Fires events afterwards to notify other behaviors.",
"value": {
"required": false
}
},
{
"name": "ld-on",
"description": "The event that triggers a fetch, such as `click` or a custom event name fired by another behavior",
"value": {
"required": false
}
},
{
"name": "ld-target",
"description": "The target element to replace the content of and the replacement strategy, for example `body|append`",
"value": {
"required": false
}
},
{
"name": "ld-select",
"description": "The content element(s) to select from the fetched content, for example `#main-content`",
"value": {
"required": false
}
},
{
"name": "ld-interval",
"description": "Automatically fetches the content of the given URL at the given interval, in seconds",
"value": {
"required": false
}
},
{
"name": "ld-fire",
"description": "Fires one or more events once a behavior, such as ld-fetch or ld-form, is finished",
"value": {
"required": false
}
},
{
"name": "ld-form",
"description": "Converts a form into a fetch request. Fires events afterwards to notify other behaviors.",
"value": {
"required": false
}
},
{
"name": "ld-auto-submit",
"description": "Automatically submits the nearest form when the value of the input changes",
"value": {
"required": false
}
},
{
"name": "ld-upload-button",
"description": "Opens the related file input when clicked, and submits the form when a file is selected",
"value": {
"required": false
}
},
{
"name": "ld-modal",
"description": "Adds Javascript behavior to a modal HTML component",
"value": {
"required": false
}
},
{
"name": "ld-dropdown",
"description": "Adds Javascript behavior to a dropdown HTML component",
"value": {
"required": false
}
},
{
"name": "ld-confirm-button",
"description": "Converts a button into a confirmation button that shows confirm / cancel buttons when clicked",
"value": {
"required": false
}
},
{
"name": "ld-confirm-icon",
"description": "Icon to show when the confirm button is clicked",
"value": {
"required": false
}
},
{
"name": "ld-confirm-question",
"description": "Question to show when the confirm button is clicked",
"value": {
"required": false
}
},
{
"name": "ld-bookmark-item",
"description": "Adds Javascript behavior to a bookmark list item",
"value": {
"required": false
}
},
{
"name": "ld-bulk-edit",
"description": "Adds Javascript behavior for bulk editing the bookmark list",
"value": {
"required": false
}
},
{
"name": "ld-global-shortcuts",
"description": "Adds Javascript behavior for global shortcuts",
"value": {
"required": false
}
},
{
"name": "ld-tag-autocomplete",
"description": "Adds Javascript behavior for converting a plain input into a tag autocomplete Svelte component",
"value": {
"required": false
}
}
]
}
}
}