diff --git a/bookmarks/templates/bookmarks/details/asset_icon.html b/bookmarks/templates/bookmarks/details/asset_icon.html
new file mode 100644
index 0000000..6853fba
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/asset_icon.html
@@ -0,0 +1,42 @@
+{% if asset.content_type == 'text/html' %}
+
+{% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %}
+
+{% endif %}
\ No newline at end of file
diff --git a/bookmarks/templates/bookmarks/details/assets.html b/bookmarks/templates/bookmarks/details/assets.html
new file mode 100644
index 0000000..fde3c4f
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/assets.html
@@ -0,0 +1,37 @@
+{% if details.assets %}
+
+{% endif %}
\ No newline at end of file
diff --git a/bookmarks/templates/bookmarks/details/content.html b/bookmarks/templates/bookmarks/details/content.html
deleted file mode 100644
index ea4377e..0000000
--- a/bookmarks/templates/bookmarks/details/content.html
+++ /dev/null
@@ -1,85 +0,0 @@
-{% load static %}
-{% load shared %}
-
-
diff --git a/bookmarks/templates/bookmarks/details/form.html b/bookmarks/templates/bookmarks/details/form.html
new file mode 100644
index 0000000..f9c3006
--- /dev/null
+++ b/bookmarks/templates/bookmarks/details/form.html
@@ -0,0 +1,99 @@
+{% load static %}
+{% load shared %}
+
+
diff --git a/bookmarks/templates/bookmarks/details/title.html b/bookmarks/templates/bookmarks/details/title.html
index a5982a7..552e3c8 100644
--- a/bookmarks/templates/bookmarks/details/title.html
+++ b/bookmarks/templates/bookmarks/details/title.html
@@ -1,3 +1,3 @@
diff --git a/bookmarks/templates/bookmarks/details_modal.html b/bookmarks/templates/bookmarks/details_modal.html
index ec24bc5..844f318 100644
--- a/bookmarks/templates/bookmarks/details_modal.html
+++ b/bookmarks/templates/bookmarks/details_modal.html
@@ -1,4 +1,4 @@
-
+
- {% include 'bookmarks/details/content.html' %}
+ {% include 'bookmarks/details/form.html' %}
- {% if request.user == bookmark.owner %}
+ {% if details.is_editable %}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index c71696b..f495869 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -163,6 +163,18 @@
href="{% url 'bookmarks:shared' %}">shared bookmarks page.
+ {% if has_snapshot_support %}
+
+ {% endif %}
Custom CSS
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index b0db26e..d881fb9 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -1,6 +1,6 @@
import random
import logging
-import datetime
+from datetime import datetime
from typing import List
from bs4 import BeautifulSoup
@@ -10,7 +10,7 @@ from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.test import APITestCase
-from bookmarks.models import Bookmark, Tag
+from bookmarks.models import Bookmark, BookmarkAsset, Tag
class BookmarkFactoryMixin:
@@ -133,6 +133,38 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
+ def setup_asset(
+ self,
+ bookmark: Bookmark,
+ date_created: datetime = None,
+ file: str = None,
+ file_size: int = None,
+ asset_type: str = BookmarkAsset.TYPE_SNAPSHOT,
+ content_type: str = "image/html",
+ display_name: str = None,
+ status: str = BookmarkAsset.STATUS_COMPLETE,
+ gzip: bool = False,
+ ):
+ if date_created is None:
+ date_created = timezone.now()
+ if not file:
+ file = get_random_string(length=32)
+ if not display_name:
+ display_name = file
+ asset = BookmarkAsset(
+ bookmark=bookmark,
+ date_created=date_created,
+ file=file,
+ file_size=file_size,
+ asset_type=asset_type,
+ content_type=content_type,
+ display_name=display_name,
+ status=status,
+ gzip=gzip,
+ )
+ asset.save()
+ return asset
+
def setup_tag(self, user: User = None, name: str = ""):
if user is None:
user = self.get_or_create_test_user()
diff --git a/bookmarks/tests/test_bookmark_asset_view.py b/bookmarks/tests/test_bookmark_asset_view.py
new file mode 100644
index 0000000..56f6801
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_asset_view.py
@@ -0,0 +1,125 @@
+import os
+
+from django.conf import settings
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import (
+ BookmarkFactoryMixin,
+)
+
+
+class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def tearDown(self):
+ temp_files = [
+ f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
+ ]
+ for temp_file in temp_files:
+ os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
+
+ def setup_asset_file(self, filename):
+ if not os.path.exists(settings.LD_ASSET_FOLDER):
+ os.makedirs(settings.LD_ASSET_FOLDER)
+ filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
+ with open(filepath, "w") as f:
+ f.write("test")
+
+ def setup_asset_with_file(self, bookmark):
+ filename = f"temp_{bookmark.id}.html.gzip"
+ self.setup_asset_file(filename)
+ asset = self.setup_asset(bookmark=bookmark, file=filename)
+ return asset
+
+ def test_view_access(self):
+ # own bookmark
+ bookmark = self.setup_bookmark()
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 200)
+
+ # other user's bookmark
+ other_user = self.setup_user()
+ bookmark = self.setup_bookmark(user=other_user)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # shared, sharing disabled
+ bookmark = self.setup_bookmark(user=other_user, shared=True)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # unshared, sharing enabled
+ profile = other_user.profile
+ profile.enable_sharing = True
+ profile.save()
+ bookmark = self.setup_bookmark(user=other_user, shared=False)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # shared, sharing enabled
+ bookmark = self.setup_bookmark(user=other_user, shared=True)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 200)
+
+ def test_view_access_guest_user(self):
+ self.client.logout()
+
+ # unshared, sharing disabled
+ bookmark = self.setup_bookmark()
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # shared, sharing disabled
+ bookmark = self.setup_bookmark(shared=True)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # unshared, sharing enabled
+ profile = self.get_or_create_test_user().profile
+ profile.enable_sharing = True
+ profile.save()
+ bookmark = self.setup_bookmark(shared=False)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # shared, sharing enabled
+ bookmark = self.setup_bookmark(shared=True)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # unshared, public sharing enabled
+ profile.enable_public_sharing = True
+ profile.save()
+ bookmark = self.setup_bookmark(shared=False)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 404)
+
+ # shared, public sharing enabled
+ bookmark = self.setup_bookmark(shared=True)
+ asset = self.setup_asset_with_file(bookmark)
+
+ response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
+ self.assertEqual(response.status_code, 200)
diff --git a/bookmarks/tests/test_bookmark_assets.py b/bookmarks/tests/test_bookmark_assets.py
new file mode 100644
index 0000000..8e1bca4
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_assets.py
@@ -0,0 +1,89 @@
+import os
+
+from django.conf import settings
+from django.test import TestCase
+
+from bookmarks.tests.helpers import (
+ BookmarkFactoryMixin,
+)
+from bookmarks.services import bookmarks
+
+
+class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
+ def tearDown(self):
+ temp_files = [
+ f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
+ ]
+ for temp_file in temp_files:
+ os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
+
+ def setup_asset_file(self, filename):
+ if not os.path.exists(settings.LD_ASSET_FOLDER):
+ os.makedirs(settings.LD_ASSET_FOLDER)
+ filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
+ with open(filepath, "w") as f:
+ f.write("test")
+
+ def setup_asset_with_file(self, bookmark):
+ filename = f"temp_{bookmark.id}.html.gzip"
+ self.setup_asset_file(filename)
+ asset = self.setup_asset(bookmark=bookmark, file=filename)
+ return asset
+
+ def test_delete_bookmark_deletes_asset_file(self):
+ bookmark = self.setup_bookmark()
+ asset = self.setup_asset_with_file(bookmark)
+ self.assertTrue(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
+ )
+
+ bookmark.delete()
+ self.assertFalse(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
+ )
+
+ def test_bulk_delete_bookmarks_deletes_asset_files(self):
+ bookmark1 = self.setup_bookmark()
+ asset1 = self.setup_asset_with_file(bookmark1)
+ bookmark2 = self.setup_bookmark()
+ asset2 = self.setup_asset_with_file(bookmark2)
+ bookmark3 = self.setup_bookmark()
+ asset3 = self.setup_asset_with_file(bookmark3)
+ self.assertTrue(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
+ )
+ self.assertTrue(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
+ )
+ self.assertTrue(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
+ )
+
+ bookmarks.delete_bookmarks(
+ [bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
+ )
+
+ self.assertFalse(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
+ )
+
+ def test_save_updates_file_size(self):
+ # File does not exist initially
+ bookmark = self.setup_bookmark()
+ asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
+ self.assertIsNone(asset.file_size)
+
+ # Add file, save again
+ self.setup_asset_file(asset.file)
+ asset.save()
+ self.assertEqual(asset.file_size, 4)
+
+ # Create asset with initial file
+ asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
+ self.assertEqual(asset.file_size, 4)
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index 8f1b6bc..9704591 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -1,8 +1,11 @@
-from django.test import TestCase
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import formats
-from bookmarks.models import UserProfile
+from bookmarks.models import BookmarkAsset, UserProfile
+from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@@ -11,8 +14,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
user = self.get_or_create_test_user()
self.client.force_login(user)
+ def get_view_name(self):
+ return "bookmarks:details_modal"
+
def get_base_url(self, bookmark):
- return reverse("bookmarks:details_modal", args=[bookmark.id])
+ return reverse(self.get_view_name(), args=[bookmark.id])
+
+ def get_details_form(self, soup, bookmark):
+ expected_url = reverse("bookmarks:details", args=[bookmark.id])
+ return soup.find("form", {"action": expected_url})
def get_details(self, bookmark, return_url=""):
url = self.get_base_url(bookmark)
@@ -35,43 +45,38 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url})
- def test_access(self):
+ def find_asset(self, soup, asset):
+ return soup.find("div", {"data-asset-id": asset.id})
+
+ def details_route_access_test(self, view_name: str, shareable: bool):
# own bookmark
bookmark = self.setup_bookmark()
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404)
# non-existent bookmark
- response = self.client.get(reverse("bookmarks:details_modal", args=[9999]))
+ response = self.client.get(reverse(view_name, args=[9999]))
self.assertEqual(response.status_code, 404)
# guest user
self.client.logout()
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
- self.assertEqual(response.status_code, 404)
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
+ self.assertEqual(response.status_code, 404 if shareable else 302)
- def test_access_with_sharing(self):
+ def details_route_sharing_access_test(self, view_name: str, shareable: bool):
# shared bookmark, sharing disabled
other_user = self.setup_user()
bookmark = self.setup_bookmark(shared=True, user=other_user)
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404)
# shared bookmark, sharing enabled
@@ -79,26 +84,38 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.enable_sharing = True
profile.save()
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
- self.assertEqual(response.status_code, 200)
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
+ self.assertEqual(response.status_code, 200 if shareable else 404)
# shared bookmark, guest user, no public sharing
self.client.logout()
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
- self.assertEqual(response.status_code, 404)
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
+ self.assertEqual(response.status_code, 404 if shareable else 302)
# shared bookmark, guest user, public sharing
profile.enable_public_sharing = True
profile.save()
- response = self.client.get(
- reverse("bookmarks:details_modal", args=[bookmark.id])
- )
- self.assertEqual(response.status_code, 200)
+ response = self.client.get(reverse(view_name, args=[bookmark.id]))
+ self.assertEqual(response.status_code, 200 if shareable else 302)
+
+ def test_access(self):
+ self.details_route_access_test(self.get_view_name(), True)
+
+ def test_access_with_sharing(self):
+ self.details_route_sharing_access_test(self.get_view_name(), True)
+
+ def test_form_partial_access(self):
+ # form partial is only used when submitting forms, which should be only
+ # 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):
+ # form partial is only used when submitting forms, which should be only
+ # 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):
# with title
@@ -246,9 +263,8 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# renders form
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
- section = self.get_section(soup, "Status")
- form = section.find("form")
+ form = self.get_details_form(soup, bookmark)
self.assertIsNotNone(form)
self.assertEqual(
form["action"], reverse("bookmarks:details", args=[bookmark.id])
@@ -312,30 +328,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
- form_action = reverse("bookmarks:details", args=[bookmark.id])
- form = soup.find("form", {"action": form_action})
self.assertIsNotNone(section)
- self.assertIsNotNone(form)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
- form_action = reverse("bookmarks:details", args=[bookmark.id])
- form = soup.find("form", {"action": form_action})
self.assertIsNone(section)
- self.assertIsNone(form)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
- form_action = reverse("bookmarks:details", args=[bookmark.id])
- form = soup.find("form", {"action": form_action})
self.assertIsNone(section)
- self.assertIsNone(form)
def test_status_update(self):
bookmark = self.setup_bookmark()
@@ -560,3 +567,215 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
+
+ def test_assets_visibility_no_snapshot_support(self):
+ bookmark = self.setup_bookmark()
+
+ soup = self.get_details(bookmark)
+ section = self.find_section(soup, "Files")
+ self.assertIsNone(section)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_assets_visibility_with_snapshot_support(self):
+ bookmark = self.setup_bookmark()
+
+ soup = self.get_details(bookmark)
+ section = self.find_section(soup, "Files")
+ self.assertIsNotNone(section)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_asset_list_visibility(self):
+ # no assets
+ bookmark = self.setup_bookmark()
+
+ soup = self.get_details(bookmark)
+ section = self.get_section(soup, "Files")
+ asset_list = section.find("div", {"class": "assets"})
+ self.assertIsNone(asset_list)
+
+ # with assets
+ bookmark = self.setup_bookmark()
+ self.setup_asset(bookmark)
+
+ soup = self.get_details(bookmark)
+ section = self.get_section(soup, "Files")
+ asset_list = section.find("div", {"class": "assets"})
+ self.assertIsNotNone(asset_list)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_asset_list(self):
+ bookmark = self.setup_bookmark()
+ assets = [
+ self.setup_asset(bookmark),
+ self.setup_asset(bookmark),
+ self.setup_asset(bookmark),
+ ]
+
+ soup = self.get_details(bookmark)
+ section = self.get_section(soup, "Files")
+ asset_list = section.find("div", {"class": "assets"})
+
+ for asset in assets:
+ asset_item = self.find_asset(asset_list, asset)
+ self.assertIsNotNone(asset_item)
+
+ asset_icon = asset_item.select_one(".asset-icon svg")
+ self.assertIsNotNone(asset_icon)
+
+ asset_text = asset_item.select_one(".asset-text span")
+ self.assertIsNotNone(asset_text)
+ self.assertIn(asset.display_name, asset_text.text)
+
+ view_url = reverse("bookmarks:assets.view", args=[asset.id])
+ view_link = asset_item.find("a", {"href": view_url})
+ self.assertIsNotNone(view_link)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_asset_without_file(self):
+ bookmark = self.setup_bookmark()
+ asset = self.setup_asset(bookmark)
+ asset.file = ""
+ asset.save()
+
+ soup = self.get_details(bookmark)
+ asset_item = self.find_asset(soup, asset)
+ view_url = reverse("bookmarks:assets.view", args=[asset.id])
+ view_link = asset_item.find("a", {"href": view_url})
+ self.assertIsNone(view_link)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_asset_status(self):
+ bookmark = self.setup_bookmark()
+ pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
+ failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
+
+ soup = self.get_details(bookmark)
+
+ asset_item = self.find_asset(soup, pending_asset)
+ asset_text = asset_item.select_one(".asset-text span")
+ self.assertIn("(queued)", asset_text.text)
+
+ asset_item = self.find_asset(soup, failed_asset)
+ asset_text = asset_item.select_one(".asset-text span")
+ self.assertIn("(failed)", asset_text.text)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_asset_file_size(self):
+ bookmark = self.setup_bookmark()
+ asset1 = self.setup_asset(bookmark, file_size=None)
+ asset2 = self.setup_asset(bookmark, file_size=54639)
+ asset3 = self.setup_asset(bookmark, file_size=11492020)
+
+ soup = self.get_details(bookmark)
+
+ asset_item = self.find_asset(soup, asset1)
+ asset_text = asset_item.select_one(".asset-text")
+ self.assertEqual(asset_text.text.strip(), asset1.display_name)
+
+ asset_item = self.find_asset(soup, asset2)
+ asset_text = asset_item.select_one(".asset-text")
+ self.assertIn("53.4\xa0KB", asset_text.text)
+
+ asset_item = self.find_asset(soup, asset3)
+ asset_text = asset_item.select_one(".asset-text")
+ self.assertIn("11.0\xa0MB", asset_text.text)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_asset_actions_visibility(self):
+ bookmark = self.setup_bookmark()
+
+ # with file
+ asset = self.setup_asset(bookmark)
+ soup = self.get_details(bookmark)
+
+ asset_item = self.find_asset(soup, asset)
+ view_link = asset_item.find("a", string="View")
+ delete_button = asset_item.find(
+ "button", {"type": "submit", "name": "remove_asset"}
+ )
+ self.assertIsNotNone(view_link)
+ self.assertIsNotNone(delete_button)
+
+ # without file
+ asset.file = ""
+ asset.save()
+ soup = self.get_details(bookmark)
+
+ asset_item = self.find_asset(soup, asset)
+ view_link = asset_item.find("a", string="View")
+ delete_button = asset_item.find(
+ "button", {"type": "submit", "name": "remove_asset"}
+ )
+ self.assertIsNone(view_link)
+ self.assertIsNotNone(delete_button)
+
+ # shared bookmark
+ other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
+ bookmark = self.setup_bookmark(shared=True, user=other_user)
+ asset = self.setup_asset(bookmark)
+ soup = self.get_details(bookmark)
+
+ asset_item = self.find_asset(soup, asset)
+ view_link = asset_item.find("a", string="View")
+ delete_button = asset_item.find(
+ "button", {"type": "submit", "name": "remove_asset"}
+ )
+ self.assertIsNotNone(view_link)
+ self.assertIsNone(delete_button)
+
+ # shared bookmark, guest user
+ self.client.logout()
+ soup = self.get_details(bookmark)
+
+ asset_item = self.find_asset(soup, asset)
+ view_link = asset_item.find("a", string="View")
+ delete_button = asset_item.find(
+ "button", {"type": "submit", "name": "remove_asset"}
+ )
+ self.assertIsNotNone(view_link)
+ self.assertIsNone(delete_button)
+
+ def test_remove_asset(self):
+ # remove asset
+ bookmark = self.setup_bookmark()
+ asset = self.setup_asset(bookmark)
+
+ response = self.client.post(
+ self.get_base_url(bookmark), {"remove_asset": asset.id}
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
+
+ # non-existent asset
+ response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
+ self.assertEqual(response.status_code, 404)
+
+ # post without asset ID does not remove
+ asset = self.setup_asset(bookmark)
+ response = self.client.post(self.get_base_url(bookmark))
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
+
+ # guest user
+ asset = self.setup_asset(bookmark)
+ self.client.logout()
+ response = self.client.post(
+ self.get_base_url(bookmark), {"remove_asset": asset.id}
+ )
+ self.assertEqual(response.status_code, 404)
+ self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_create_snapshot(self):
+ with patch.object(
+ tasks, "_create_html_snapshot_task"
+ ) as mock_create_html_snapshot_task:
+ bookmark = self.setup_bookmark()
+ response = self.client.post(
+ self.get_base_url(bookmark), {"create_snapshot": ""}
+ )
+ self.assertEqual(response.status_code, 302)
+
+ mock_create_html_snapshot_task.assert_called_with(bookmark.id)
+
+ self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
diff --git a/bookmarks/tests/test_bookmark_details_view.py b/bookmarks/tests/test_bookmark_details_view.py
index d509bc3..36824de 100644
--- a/bookmarks/tests/test_bookmark_details_view.py
+++ b/bookmarks/tests/test_bookmark_details_view.py
@@ -1,8 +1,6 @@
-from django.urls import reverse
-
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
- def get_base_url(self, bookmark):
- return reverse("bookmarks:details", args=[bookmark.id])
+ def get_view_name(self):
+ return "bookmarks:details"
diff --git a/bookmarks/tests/test_bookmarks_service.py b/bookmarks/tests/test_bookmarks_service.py
index c6d121f..55abc60 100644
--- a/bookmarks/tests/test_bookmarks_service.py
+++ b/bookmarks/tests/test_bookmarks_service.py
@@ -105,6 +105,24 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_favicon.assert_called_once_with(self.user, bookmark)
+ def test_create_should_load_html_snapshot(self):
+ with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
+ bookmark_data = Bookmark(url="https://example.com")
+ bookmark = create_bookmark(bookmark_data, "tag1,tag2", self.user)
+
+ mock_create_html_snapshot.assert_called_once_with(bookmark)
+
+ def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
+ profile = self.get_or_create_test_user().profile
+ profile.enable_automatic_html_snapshots = False
+ profile.save()
+
+ with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
+ bookmark_data = Bookmark(url="https://example.com")
+ create_bookmark(bookmark_data, "tag1,tag2", self.user)
+
+ mock_create_html_snapshot.assert_not_called()
+
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(
tasks, "create_web_archive_snapshot"
@@ -167,6 +185,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_favicon.assert_called_once_with(self.user, bookmark)
+ def test_update_should_not_create_html_snapshot(self):
+ with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
+ bookmark = self.setup_bookmark()
+ bookmark.title = "updated title"
+ update_bookmark(bookmark, "tag1,tag2", self.user)
+
+ mock_create_html_snapshot.assert_not_called()
+
def test_archive_bookmark(self):
bookmark = Bookmark(
url="https://example.com",
diff --git a/bookmarks/tests/test_bookmarks_tasks.py b/bookmarks/tests/test_bookmarks_tasks.py
index f286911..b1ace6b 100644
--- a/bookmarks/tests/test_bookmarks_tasks.py
+++ b/bookmarks/tests/test_bookmarks_tasks.py
@@ -1,18 +1,20 @@
import datetime
+import os.path
from dataclasses import dataclass
from typing import Any
from unittest import mock
import waybackpy
from background_task.models import Task
+from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback
-from bookmarks.models import UserProfile
-from bookmarks.services import tasks
+from bookmarks.models import BookmarkAsset, UserProfile
+from bookmarks.services import tasks, singlefile
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
@@ -626,3 +628,86 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_create_html_snapshot_should_create_pending_asset(self):
+ bookmark = self.setup_bookmark()
+
+ with mock.patch("bookmarks.services.monolith.create_snapshot"):
+ tasks.create_html_snapshot(bookmark)
+ self.assertEqual(BookmarkAsset.objects.count(), 1)
+
+ tasks.create_html_snapshot(bookmark)
+ self.assertEqual(BookmarkAsset.objects.count(), 2)
+
+ assets = BookmarkAsset.objects.filter(bookmark=bookmark)
+ for asset in assets:
+ self.assertEqual(asset.bookmark, bookmark)
+ self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
+ self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
+ self.assertIn("HTML snapshot", asset.display_name)
+ self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_create_html_snapshot_should_update_file_info(self):
+ bookmark = self.setup_bookmark(url="https://example.com")
+
+ with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
+ tasks.create_html_snapshot(bookmark)
+ asset = BookmarkAsset.objects.get(bookmark=bookmark)
+ asset.date_created = datetime.datetime(2021, 1, 2, 3, 44, 55)
+ asset.save()
+ expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
+
+ self.run_pending_task(tasks._create_html_snapshot_task)
+
+ mock_create.assert_called_once_with(
+ "https://example.com",
+ os.path.join(settings.LD_ASSET_FOLDER, expected_filename),
+ )
+
+ asset = BookmarkAsset.objects.get(bookmark=bookmark)
+ self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
+ self.assertEqual(asset.file, expected_filename)
+ self.assertTrue(asset.gzip)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_create_html_snapshot_should_handle_error(self):
+ bookmark = self.setup_bookmark(url="https://example.com")
+
+ with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
+ mock_create.side_effect = singlefile.SingeFileError("Error")
+
+ tasks.create_html_snapshot(bookmark)
+ self.run_pending_task(tasks._create_html_snapshot_task)
+
+ asset = BookmarkAsset.objects.get(bookmark=bookmark)
+ self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
+ self.assertEqual(asset.file, "")
+ self.assertFalse(asset.gzip)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_create_html_snapshot_should_handle_missing_bookmark(self):
+ with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
+ tasks._create_html_snapshot_task(123)
+ self.run_pending_task(tasks._create_html_snapshot_task)
+
+ mock_create.assert_not_called()
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=False)
+ def test_create_html_snapshot_should_not_run_when_single_file_is_disabled(
+ self,
+ ):
+ bookmark = self.setup_bookmark()
+ tasks.create_html_snapshot(bookmark)
+
+ self.assertEqual(Task.objects.count(), 0)
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True, LD_DISABLE_BACKGROUND_TASKS=True)
+ def test_create_html_snapshot_should_not_run_when_background_tasks_are_disabled(
+ self,
+ ):
+ bookmark = self.setup_bookmark()
+ tasks.create_html_snapshot(bookmark)
+
+ self.assertEqual(Task.objects.count(), 0)
diff --git a/bookmarks/tests/test_monolith_service.py b/bookmarks/tests/test_monolith_service.py
new file mode 100644
index 0000000..e9ed1cc
--- /dev/null
+++ b/bookmarks/tests/test_monolith_service.py
@@ -0,0 +1,44 @@
+import gzip
+import os
+from unittest import mock
+import subprocess
+
+from django.test import TestCase
+
+from bookmarks.services import monolith
+
+
+class MonolithServiceTestCase(TestCase):
+ html_content = "Hello, World! "
+ html_filepath = "temp.html.gz"
+ temp_html_filepath = "temp.html.gz.tmp"
+
+ def tearDown(self):
+ if os.path.exists(self.html_filepath):
+ os.remove(self.html_filepath)
+ if os.path.exists(self.temp_html_filepath):
+ os.remove(self.temp_html_filepath)
+
+ def create_test_file(self, *args, **kwargs):
+ with open(self.temp_html_filepath, "w") as file:
+ file.write(self.html_content)
+
+ def test_create_snapshot(self):
+ with mock.patch("subprocess.run") as mock_run:
+ mock_run.side_effect = self.create_test_file
+
+ monolith.create_snapshot("http://example.com", self.html_filepath)
+
+ self.assertTrue(os.path.exists(self.html_filepath))
+ self.assertFalse(os.path.exists(self.temp_html_filepath))
+
+ with gzip.open(self.html_filepath, "rt") as file:
+ content = file.read()
+ self.assertEqual(content, self.html_content)
+
+ def test_create_snapshot_failure(self):
+ with mock.patch("subprocess.run") as mock_run:
+ mock_run.side_effect = subprocess.CalledProcessError(1, "command")
+
+ with self.assertRaises(monolith.MonolithError):
+ monolith.create_snapshot("http://example.com", self.html_filepath)
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 7f8e7c8..f2768ad 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -31,6 +31,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_sharing": False,
"enable_public_sharing": False,
"enable_favicons": False,
+ "enable_automatic_html_snapshots": True,
"tag_search": UserProfile.TAG_SEARCH_STRICT,
"display_url": False,
"display_view_bookmark_action": True,
@@ -69,6 +70,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_sharing": True,
"enable_public_sharing": True,
"enable_favicons": True,
+ "enable_automatic_html_snapshots": False,
"tag_search": UserProfile.TAG_SEARCH_LAX,
"display_url": True,
"display_view_bookmark_action": False,
@@ -110,6 +112,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.enable_favicons, form_data["enable_favicons"]
)
+ self.assertEqual(
+ self.user.profile.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.display_url, form_data["display_url"])
self.assertEqual(
@@ -285,6 +291,35 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
count=0,
)
+ def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(
+ self,
+ ):
+ response = self.client.get(reverse("bookmarks:settings.general"))
+ html = response.content.decode()
+
+ self.assertInHTML(
+ """
+
+ """,
+ html,
+ count=0,
+ )
+
+ @override_settings(LD_ENABLE_SNAPSHOTS=True)
+ def test_automatic_html_snapshots_should_be_visible_when_snapshots_supported(
+ self,
+ ):
+ response = self.client.get(reverse("bookmarks:settings.general"))
+ html = response.content.decode()
+
+ self.assertInHTML(
+ """
+
+ """,
+ html,
+ count=1,
+ )
+
def test_about_shows_version_info(self):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
diff --git a/bookmarks/tests/test_singlefile_service.py b/bookmarks/tests/test_singlefile_service.py
new file mode 100644
index 0000000..e6a3f2e
--- /dev/null
+++ b/bookmarks/tests/test_singlefile_service.py
@@ -0,0 +1,50 @@
+import gzip
+import os
+from unittest import mock
+import subprocess
+
+from django.test import TestCase
+
+from bookmarks.services import singlefile
+
+
+class SingleFileServiceTestCase(TestCase):
+ html_content = "Hello, World! "
+ html_filepath = "temp.html.gz"
+ temp_html_filepath = "temp.html.gz.tmp"
+
+ def tearDown(self):
+ if os.path.exists(self.html_filepath):
+ os.remove(self.html_filepath)
+ if os.path.exists(self.temp_html_filepath):
+ os.remove(self.temp_html_filepath)
+
+ def create_test_file(self, *args, **kwargs):
+ with open(self.temp_html_filepath, "w") as file:
+ file.write(self.html_content)
+
+ def test_create_snapshot(self):
+ with mock.patch("subprocess.run") as mock_run:
+ mock_run.side_effect = self.create_test_file
+
+ singlefile.create_snapshot("http://example.com", self.html_filepath)
+
+ self.assertTrue(os.path.exists(self.html_filepath))
+ self.assertFalse(os.path.exists(self.temp_html_filepath))
+
+ with gzip.open(self.html_filepath, "rt") as file:
+ content = file.read()
+ self.assertEqual(content, self.html_content)
+
+ def test_create_snapshot_failure(self):
+ # subprocess fails - which it probably doesn't as single-file doesn't return exit codes
+ with mock.patch("subprocess.run") as mock_run:
+ mock_run.side_effect = subprocess.CalledProcessError(1, "command")
+
+ with self.assertRaises(singlefile.SingeFileError):
+ singlefile.create_snapshot("http://example.com", self.html_filepath)
+
+ # so also check that it raises error if output file isn't created
+ with mock.patch("subprocess.run") as mock_run:
+ with self.assertRaises(singlefile.SingeFileError):
+ singlefile.create_snapshot("http://example.com", self.html_filepath)
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 4a254bb..36e0b11 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -44,6 +44,12 @@ urlpatterns = [
views.bookmarks.details_modal,
name="details_modal",
),
+ # Assets
+ path(
+ "assets/",
+ views.assets.view,
+ name="assets.view",
+ ),
# Partials
path(
"bookmarks/partials/bookmark-list/active",
@@ -75,6 +81,11 @@ urlpatterns = [
partials.shared_tag_cloud,
name="partials.tag_cloud.shared",
),
+ path(
+ "bookmarks/partials/details-form/",
+ partials.details_form,
+ name="partials.details_form",
+ ),
# Settings
path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"),
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index 894b61d..3f26b62 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -1,3 +1,4 @@
+from .assets import *
from .bookmarks import *
from .settings import *
from .toasts import *
diff --git a/bookmarks/views/assets.py b/bookmarks/views/assets.py
new file mode 100644
index 0000000..9fed7e7
--- /dev/null
+++ b/bookmarks/views/assets.py
@@ -0,0 +1,43 @@
+import gzip
+import os
+
+from django.conf import settings
+from django.http import (
+ HttpResponse,
+ Http404,
+)
+
+from bookmarks.models import BookmarkAsset
+
+
+def view(request, asset_id: int):
+ try:
+ asset = BookmarkAsset.objects.get(pk=asset_id)
+ except BookmarkAsset.DoesNotExist:
+ raise Http404("Asset does not exist")
+
+ bookmark = asset.bookmark
+ is_owner = bookmark.owner == request.user
+ is_shared = (
+ request.user.is_authenticated
+ and bookmark.shared
+ and bookmark.owner.profile.enable_sharing
+ )
+ is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
+
+ if not is_owner and not is_shared and not is_public_shared:
+ raise Http404("Bookmark does not exist")
+
+ filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
+
+ if not os.path.exists(filepath):
+ raise Http404("Asset file does not exist")
+
+ if asset.gzip:
+ with gzip.open(filepath, "rb") as f:
+ content = f.read()
+ else:
+ with open(filepath, "rb") as f:
+ content = f.read()
+
+ return HttpResponse(content, content_type=asset.content_type)
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 969df3a..d79aeff 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -12,7 +12,13 @@ from django.shortcuts import render
from django.urls import reverse
from bookmarks import queries
-from bookmarks.models import Bookmark, BookmarkForm, BookmarkSearch, build_tag_string
+from bookmarks.models import (
+ Bookmark,
+ BookmarkAsset,
+ BookmarkForm,
+ BookmarkSearch,
+ build_tag_string,
+)
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
@@ -28,6 +34,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks,
unshare_bookmarks,
)
+from bookmarks.services import tasks
from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts
@@ -120,31 +127,39 @@ def _details(request, bookmark_id: int, template: str):
if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist")
- edit_return_url = get_safe_return_url(
- request.GET.get("return_url"), reverse("bookmarks:details", args=[bookmark_id])
- )
- delete_return_url = get_safe_return_url(
- request.GET.get("return_url"), reverse("bookmarks:index")
- )
-
- # handles status actions form
if request.method == "POST":
if not is_owner:
raise Http404("Bookmark does not exist")
- bookmark.is_archived = request.POST.get("is_archived") == "on"
- bookmark.unread = request.POST.get("unread") == "on"
- bookmark.shared = request.POST.get("shared") == "on"
- bookmark.save()
- return HttpResponseRedirect(edit_return_url)
+ return_url = get_safe_return_url(
+ request.GET.get("return_url"),
+ reverse("bookmarks:details", args=[bookmark.id]),
+ )
+
+ if "remove_asset" in request.POST:
+ asset_id = request.POST["remove_asset"]
+ try:
+ asset = bookmark.bookmarkasset_set.get(pk=asset_id)
+ except BookmarkAsset.DoesNotExist:
+ raise Http404("Asset does not exist")
+ asset.delete()
+ if "create_snapshot" in request.POST:
+ tasks.create_html_snapshot(bookmark)
+ else:
+ bookmark.is_archived = request.POST.get("is_archived") == "on"
+ bookmark.unread = request.POST.get("unread") == "on"
+ bookmark.shared = request.POST.get("shared") == "on"
+ bookmark.save()
+
+ return HttpResponseRedirect(return_url)
+
+ details_context = contexts.BookmarkDetailsContext(request, bookmark)
return render(
request,
template,
{
- "bookmark": bookmark,
- "edit_return_url": edit_return_url,
- "delete_return_url": delete_return_url,
+ "details": details_context,
},
)
diff --git a/bookmarks/views/partials/__init__.py b/bookmarks/views/partials/__init__.py
index dda77ff..7df6162 100644
--- a/bookmarks/views/partials/__init__.py
+++ b/bookmarks/views/partials/__init__.py
@@ -1,6 +1,8 @@
from django.contrib.auth.decorators import login_required
+from django.http import Http404
from django.shortcuts import render
+from bookmarks.models import Bookmark
from bookmarks.views.partials import contexts
@@ -56,3 +58,15 @@ def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
+
+
+@login_required
+def details_form(request, bookmark_id: int):
+ 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/details/form.html", {"details": details_context})
diff --git a/bookmarks/views/partials/contexts.py b/bookmarks/views/partials/contexts.py
index 2320268..eac5bae 100644
--- a/bookmarks/views/partials/contexts.py
+++ b/bookmarks/views/partials/contexts.py
@@ -6,11 +6,13 @@ from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import models
from django.urls import reverse
+from django.conf import settings
from bookmarks import queries
from bookmarks import utils
from bookmarks.models import (
Bookmark,
+ BookmarkAsset,
BookmarkSearch,
User,
UserProfile,
@@ -274,3 +276,55 @@ class SharedTagCloudContext(TagCloudContext):
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, self.search, public_only
)
+
+
+class BookmarkAssetItem:
+ def __init__(self, asset: BookmarkAsset):
+ self.asset = asset
+
+ self.id = asset.id
+ self.display_name = asset.display_name
+ self.content_type = asset.content_type
+ self.file = asset.file
+ self.file_size = asset.file_size
+ self.status = asset.status
+
+ icon_classes = []
+ text_classes = []
+ if asset.status == BookmarkAsset.STATUS_PENDING:
+ icon_classes.append("text-gray")
+ text_classes.append("text-gray")
+ elif asset.status == BookmarkAsset.STATUS_FAILURE:
+ icon_classes.append("text-error")
+ text_classes.append("text-error")
+ else:
+ icon_classes.append("text-primary")
+
+ self.icon_classes = " ".join(icon_classes)
+ self.text_classes = " ".join(text_classes)
+
+
+class BookmarkDetailsContext:
+ def __init__(self, request: WSGIRequest, bookmark: Bookmark):
+ user = request.user
+ user_profile = request.user_profile
+
+ self.edit_return_url = utils.get_safe_return_url(
+ request.GET.get("return_url"),
+ reverse("bookmarks:details", args=[bookmark.id]),
+ )
+ self.delete_return_url = utils.get_safe_return_url(
+ request.GET.get("return_url"), reverse("bookmarks:index")
+ )
+
+ self.bookmark = bookmark
+ self.profile = request.user_profile
+ self.is_editable = bookmark.owner == user
+ self.sharing_enabled = user_profile.enable_sharing
+ self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
+ # For now hide files section if snapshots are not supported
+ self.show_files = settings.LD_ENABLE_SNAPSHOTS
+
+ self.assets = [
+ BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
+ ]
diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py
index 33432a4..a75615a 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -12,7 +12,7 @@ from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
-from bookmarks.models import Bookmark, BookmarkSearch, UserProfileForm, FeedToken
+from bookmarks.models import Bookmark, UserProfileForm, FeedToken
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
from bookmarks.utils import app_version
@@ -24,6 +24,7 @@ logger = logging.getLogger(__name__)
def general(request):
profile_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
+ has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
update_profile_success_message = None
refresh_favicons_success_message = None
import_success_message = _find_message_with_tag(
@@ -53,6 +54,7 @@ def general(request):
{
"form": profile_form,
"enable_refresh_favicons": enable_refresh_favicons,
+ "has_snapshot_support": has_snapshot_support,
"update_profile_success_message": update_profile_success_message,
"refresh_favicons_success_message": refresh_favicons_success_message,
"import_success_message": import_success_message,
diff --git a/bootstrap.sh b/bootstrap.sh
index 66c6101..e52eca3 100755
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -7,6 +7,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
mkdir -p data
# Create favicon folder if it does not exist
mkdir -p data/favicons
+# Create assets folder if it does not exist
+mkdir -p data/assets
# Generate secret key file if it does not exist
python manage.py generate_secret_key
diff --git a/docker/alpine.Dockerfile b/docker/alpine.Dockerfile
index e0bcb75..1a613a2 100644
--- a/docker/alpine.Dockerfile
+++ b/docker/alpine.Dockerfile
@@ -67,7 +67,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
-FROM python:3.11.8-alpine3.19 AS final
+FROM python:3.11.8-alpine3.19 AS linkding
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap libssl3
# create www-data user and group
@@ -96,3 +96,10 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
CMD ["./bootstrap.sh"]
+
+
+FROM linkding AS linkding-plus
+# install node, chromium and single-file
+RUN apk update && apk add nodejs npm chromium && npm install -g single-file-cli
+# enable snapshot support
+ENV LD_ENABLE_SNAPSHOTS=True
diff --git a/docker/default.Dockerfile b/docker/default.Dockerfile
index 05daab6..fe5fc6c 100644
--- a/docker/default.Dockerfile
+++ b/docker/default.Dockerfile
@@ -69,7 +69,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
-FROM python:3.11.8-slim-bookworm as final
+FROM python:3.11.8-slim-bookworm as linkding
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
WORKDIR /etc/linkding
# copy prod dependencies
@@ -94,3 +94,9 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
CMD ["./bootstrap.sh"]
+
+FROM linkding AS linkding-plus
+# install node, chromium and single-file
+RUN apt-get update && apt-get -y install nodejs npm chromium && npm install -g single-file-cli
+# enable snapshot support
+ENV LD_ENABLE_SNAPSHOTS=True
diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh
index 9311dac..0d9a0e2 100755
--- a/scripts/build-docker.sh
+++ b/scripts/build-docker.sh
@@ -2,14 +2,29 @@
version=$(