Repository: sissbruecker/linkding
Pull request #1021 (2025-03-22 13:53:17)

Prefer local snapshot over web archive link in bookmark list links
Make the timestamps in the bookmark list link to local snapshots instead of the Internet Archive, if a snapshot is availabe. The Internet Archive link is still availabe in the bookmark details if necessary.

Closes https://github.com/sissbruecker/linkding/issues/867


Patch:
diff --git a/Makefile b/Makefile
index 9a400d9..80681ac 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ serve:
 	python manage.py runserver
 
 tasks:
-	python manage.py process_tasks
+	python manage.py run_huey
 
 test:
 	pytest -n auto
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index aa49f4f..4fd6a30 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -237,6 +237,9 @@ class BookmarkAssetViewSet(
                 status=status.HTTP_500_INTERNAL_SERVER_ERROR,
             )
 
+    def perform_destroy(self, instance):
+        assets.remove_asset(instance)
+
 
 class TagViewSet(
     viewsets.GenericViewSet,
diff --git a/bookmarks/models.py b/bookmarks/models.py
index b11e5af..f92475f 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -71,6 +71,13 @@ class Bookmark(models.Model):
     date_accessed = models.DateTimeField(blank=True, null=True)
     owner = models.ForeignKey(User, on_delete=models.CASCADE)
     tags = models.ManyToManyField(Tag)
+    latest_snapshot = models.ForeignKey(
+        "BookmarkAsset",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+        related_name="latest_snapshot",
+    )
 
     @property
     def resolved_title(self):
diff --git a/bookmarks/services/assets.py b/bookmarks/services/assets.py
index 543248b..4409c61 100644
--- a/bookmarks/services/assets.py
+++ b/bookmarks/services/assets.py
@@ -51,6 +51,9 @@ def create_snapshot(asset: BookmarkAsset):
         asset.file = filename
         asset.gzip = True
         asset.save()
+
+        asset.bookmark.latest_snapshot = asset
+        asset.bookmark.save()
     except Exception as error:
         asset.status = BookmarkAsset.STATUS_FAILURE
         asset.save()
@@ -71,6 +74,9 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
     asset.gzip = True
     asset.save()
 
+    asset.bookmark.latest_snapshot = asset
+    asset.bookmark.save()
+
     return asset
 
 
@@ -106,6 +112,27 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
         raise e
 
 
+def remove_asset(asset: BookmarkAsset):
+    # If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot
+    bookmark = asset.bookmark
+    if bookmark and bookmark.latest_snapshot == asset:
+        latest = (
+            BookmarkAsset.objects.filter(
+                bookmark=bookmark,
+                asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+                status=BookmarkAsset.STATUS_COMPLETE,
+            )
+            .exclude(pk=asset.pk)
+            .order_by("-date_created")
+            .first()
+        )
+
+        bookmark.latest_snapshot = latest
+        bookmark.save()
+
+    asset.delete()
+
+
 def _generate_asset_filename(
     asset: BookmarkAsset, filename: str, extension: str
 ) -> str:
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index 39a2cbb..d23e649 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -67,9 +67,9 @@
             {% endif %}
             <div class="actions">
               {% 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"
+                {% if bookmark_item.snapshot_url %}
+                  <a href="{{ bookmark_item.snapshot_url }}"
+                     title="{{ bookmark_item.snapshot_title }}"
                      target="{{ bookmark_list.link_target }}"
                      rel="noopener">
                     {{ bookmark_item.display_date }}
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 7d27ac5..b19411f 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -235,7 +235,7 @@ def upload_asset(request: HttpRequest, bookmark_id: int | str):
 
 def remove_asset(request: HttpRequest, asset_id: int | str):
     asset = access.asset_write(request, asset_id)
-    asset.delete()
+    asset_actions.remove_asset(asset)
 
 
 def update_state(request: HttpRequest, bookmark_id: int | str):
diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py
index d046b81..86aec9e 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -130,11 +130,20 @@ class BookmarkItem:
         self.description = bookmark.resolved_description
         self.notes = bookmark.notes
         self.tag_names = bookmark.tag_names
-        self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
-        if not self.web_archive_snapshot_url:
-            self.web_archive_snapshot_url = generate_fallback_webarchive_url(
-                bookmark.url, bookmark.date_added
+        if bookmark.latest_snapshot_id:
+            self.snapshot_url = reverse(
+                "linkding:assets.view", args=[bookmark.latest_snapshot_id]
+            )
+            self.snapshot_title = "View latest snapshot"
+        else:
+            self.snapshot_url = bookmark.web_archive_snapshot_url
+            self.snapshot_title = (
+                "View snapshot on the Internet Archive Wayback Machine"
             )
+            if not self.snapshot_url:
+                self.snapshot_url = generate_fallback_webarchive_url(
+                    bookmark.url, bookmark.date_added
+                )
         self.favicon_file = bookmark.favicon_file
         self.preview_image_file = bookmark.preview_image_file
         self.is_archived = bookmark.is_archived


Test patch:
diff --git a/bookmarks/migrations/0044_bookmark_latest_snapshot.py b/bookmarks/migrations/0044_bookmark_latest_snapshot.py
new file mode 100644
index 0000000..06f4b86
--- /dev/null
+++ b/bookmarks/migrations/0044_bookmark_latest_snapshot.py
@@ -0,0 +1,46 @@
+# Generated by Django 5.1.7 on 2025-03-22 12:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+from django.db.models import OuterRef, Subquery
+
+
+def forwards(apps, schema_editor):
+    # Update the latest snapshot for each bookmark
+    Bookmark = apps.get_model("bookmarks", "bookmark")
+    BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset")
+
+    latest_snapshots = (
+        BookmarkAsset.objects.filter(
+            bookmark=OuterRef("pk"), asset_type="snapshot", status="complete"
+        )
+        .order_by("-date_created")
+        .values("id")[:1]
+    )
+    Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))
+
+
+def reverse(apps, schema_editor):
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0043_userprofile_collapse_side_panel"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="bookmark",
+            name="latest_snapshot",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="latest_snapshot",
+                to="bookmarks.bookmarkasset",
+            ),
+        ),
+        migrations.RunPython(forwards, reverse),
+    ]
diff --git a/bookmarks/tests/test_assets_service.py b/bookmarks/tests/test_assets_service.py
index 52faa02..31acd12 100644
--- a/bookmarks/tests/test_assets_service.py
+++ b/bookmarks/tests/test_assets_service.py
@@ -1,6 +1,7 @@
 import datetime
 import gzip
 import os
+from datetime import timedelta
 from unittest import mock
 
 from django.core.files.uploadedfile import SimpleUploadedFile
@@ -236,3 +237,175 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
         # asset is not saved to the database
         self.assertIsNone(BookmarkAsset.objects.first())
+
+    def test_create_snapshot_updates_bookmark_latest_snapshot(self):
+        bookmark = self.setup_bookmark(url="https://example.com")
+        first_asset = assets.create_snapshot_asset(bookmark)
+        first_asset.save()
+
+        assets.create_snapshot(first_asset)
+        bookmark.refresh_from_db()
+
+        self.assertEqual(bookmark.latest_snapshot, first_asset)
+
+        second_asset = assets.create_snapshot_asset(bookmark)
+        second_asset.save()
+
+        assets.create_snapshot(second_asset)
+        bookmark.refresh_from_db()
+
+        self.assertEqual(bookmark.latest_snapshot, second_asset)
+
+    def test_upload_snapshot_updates_bookmark_latest_snapshot(self):
+        bookmark = self.setup_bookmark(url="https://example.com")
+
+        first_asset = assets.upload_snapshot(bookmark, self.html_content.encode())
+
+        bookmark.refresh_from_db()
+        self.assertEqual(bookmark.latest_snapshot, first_asset)
+
+        second_asset = assets.upload_snapshot(bookmark, self.html_content.encode())
+
+        bookmark.refresh_from_db()
+        self.assertEqual(bookmark.latest_snapshot, second_asset)
+        self.assertNotEqual(bookmark.latest_snapshot, first_asset)
+
+    def test_create_snapshot_failure_does_not_update_latest_snapshot(self):
+        # Create a bookmark with an existing latest_snapshot
+        bookmark = self.setup_bookmark(url="https://example.com")
+        initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())
+        bookmark.refresh_from_db()
+        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
+
+        # Create a new snapshot asset that will fail
+        failing_asset = assets.create_snapshot_asset(bookmark)
+        failing_asset.save()
+
+        # Make the snapshot creation fail
+        self.mock_singlefile_create_snapshot.side_effect = Exception(
+            "Snapshot creation failed"
+        )
+
+        # Attempt to create a snapshot (which will fail)
+        with self.assertRaises(Exception):
+            assets.create_snapshot(failing_asset)
+
+        # Verify that the bookmark's latest_snapshot is still the initial snapshot
+        bookmark.refresh_from_db()
+        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
+
+    def test_upload_snapshot_failure_does_not_update_latest_snapshot(self):
+        # Create a bookmark with an existing latest_snapshot
+        bookmark = self.setup_bookmark(url="https://example.com")
+        initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())
+        bookmark.refresh_from_db()
+        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
+
+        # Make the gzip.open function fail
+        with mock.patch("gzip.open") as mock_gzip_open:
+            mock_gzip_open.side_effect = Exception("Upload failed")
+
+            # Attempt to upload a snapshot (which will fail)
+            with self.assertRaises(Exception):
+                assets.upload_snapshot(bookmark, b"New content")
+
+        # Verify that the bookmark's latest_snapshot is still the initial snapshot
+        bookmark.refresh_from_db()
+        self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
+
+    def test_remove_latest_snapshot_updates_bookmark(self):
+        # Create a bookmark with multiple snapshots
+        bookmark = self.setup_bookmark()
+
+        # Create base time (1 hour ago)
+        base_time = timezone.now() - timedelta(hours=1)
+
+        # Create three snapshots with explicitly different dates
+        old_asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+            status=BookmarkAsset.STATUS_COMPLETE,
+            file="old_snapshot.html.gz",
+            date_created=base_time,
+        )
+        self.setup_asset_file(old_asset)
+
+        middle_asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+            status=BookmarkAsset.STATUS_COMPLETE,
+            file="middle_snapshot.html.gz",
+            date_created=base_time + timedelta(minutes=30),
+        )
+        self.setup_asset_file(middle_asset)
+
+        latest_asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+            status=BookmarkAsset.STATUS_COMPLETE,
+            file="latest_snapshot.html.gz",
+            date_created=base_time + timedelta(minutes=60),
+        )
+        self.setup_asset_file(latest_asset)
+
+        # Set the latest asset as the bookmark's latest_snapshot
+        bookmark.latest_snapshot = latest_asset
+        bookmark.save()
+
+        # Delete the latest snapshot
+        assets.remove_asset(latest_asset)
+        bookmark.refresh_from_db()
+
+        # Verify that middle_asset is now the latest_snapshot
+        self.assertEqual(bookmark.latest_snapshot, middle_asset)
+
+        # Delete the middle snapshot
+        assets.remove_asset(middle_asset)
+        bookmark.refresh_from_db()
+
+        # Verify that old_asset is now the latest_snapshot
+        self.assertEqual(bookmark.latest_snapshot, old_asset)
+
+        # Delete the last snapshot
+        assets.remove_asset(old_asset)
+        bookmark.refresh_from_db()
+
+        # Verify that latest_snapshot is now None
+        self.assertIsNone(bookmark.latest_snapshot)
+
+    def test_remove_non_latest_snapshot_does_not_affect_bookmark(self):
+        # Create a bookmark with multiple snapshots
+        bookmark = self.setup_bookmark()
+
+        # Create base time (1 hour ago)
+        base_time = timezone.now() - timedelta(hours=1)
+
+        # Create two snapshots with explicitly different dates
+        old_asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+            status=BookmarkAsset.STATUS_COMPLETE,
+            file="old_snapshot.html.gz",
+            date_created=base_time,
+        )
+        self.setup_asset_file(old_asset)
+
+        latest_asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+            status=BookmarkAsset.STATUS_COMPLETE,
+            file="latest_snapshot.html.gz",
+            date_created=base_time + timedelta(minutes=30),
+        )
+        self.setup_asset_file(latest_asset)
+
+        # Set the latest asset as the bookmark's latest_snapshot
+        bookmark.latest_snapshot = latest_asset
+        bookmark.save()
+
+        # Delete the old snapshot (not the latest)
+        assets.remove_asset(old_asset)
+        bookmark.refresh_from_db()
+
+        # Verify that latest_snapshot hasn't changed
+        self.assertEqual(bookmark.latest_snapshot, latest_asset)
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 599aa97..46ed290 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -43,7 +43,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         self.assertInHTML(
             f"""
         <a href="{url}"
-           title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
+           title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
             {label_content}
         </a>
         <span>|</span>
@@ -559,6 +559,31 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_self"
         )
 
+    def test_should_render_latest_snapshot_link_if_one_exists(self):
+        bookmark = self.setup_date_format_test(
+            UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
+        )
+        bookmark.latest_snapshot = self.setup_asset(bookmark)
+        bookmark.save()
+
+        html = self.render_template()
+        formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
+        snapshot_url = reverse(
+            "linkding:assets.view", args=[bookmark.latest_snapshot.id]
+        )
+
+        # Check that the snapshot link is rendered with the correct URL and title
+        self.assertInHTML(
+            f"""
+            <a href="{snapshot_url}"
+               title="View latest snapshot" target="_blank" rel="noopener">
+                {formatted_date}
+            </a>
+            <span>|</span>
+            """,
+            html,
+        )
+
     def test_should_reflect_unread_state_as_css_class(self):
         self.setup_bookmark(unread=True)
         html = self.render_template()