Repository: sissbruecker/linkding
Pull request #1080 (2025-05-30 07:52:12)

Add date filters for REST API
Closes https://github.com/sissbruecker/linkding/issues/940


Patch:
diff --git a/bookmarks/models.py b/bookmarks/models.py
index f92475f..d411c9a 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -171,7 +171,7 @@ class BookmarkSearch:
     FILTER_UNREAD_YES = "yes"
     FILTER_UNREAD_NO = "no"
 
-    params = ["q", "user", "sort", "shared", "unread"]
+    params = ["q", "user", "sort", "shared", "unread", "modified_since", "added_since"]
     preferences = ["sort", "shared", "unread"]
     defaults = {
         "q": "",
@@ -179,6 +179,8 @@ class BookmarkSearch:
         "sort": SORT_ADDED_DESC,
         "shared": FILTER_SHARED_OFF,
         "unread": FILTER_UNREAD_OFF,
+        "modified_since": None,
+        "added_since": None,
     }
 
     def __init__(
@@ -188,6 +190,8 @@ class BookmarkSearch:
         sort: str = None,
         shared: str = None,
         unread: str = None,
+        modified_since: str = None,
+        added_since: str = None,
         preferences: dict = None,
     ):
         if not preferences:
@@ -199,6 +203,8 @@ class BookmarkSearch:
         self.sort = sort or self.defaults["sort"]
         self.shared = shared or self.defaults["shared"]
         self.unread = unread or self.defaults["unread"]
+        self.modified_since = modified_since or self.defaults["modified_since"]
+        self.added_since = added_since or self.defaults["added_since"]
 
     def is_modified(self, param):
         value = self.__dict__[param]
@@ -268,6 +274,8 @@ class BookmarkSearchForm(forms.Form):
     sort = forms.ChoiceField(choices=SORT_CHOICES)
     shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
     unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
+    modified_since = forms.CharField(required=False)
+    added_since = forms.CharField(required=False)
 
     def __init__(
         self,
diff --git a/bookmarks/queries.py b/bookmarks/queries.py
index 8fd6a08..f661f9f 100644
--- a/bookmarks/queries.py
+++ b/bookmarks/queries.py
@@ -2,6 +2,7 @@ from typing import Optional
 
 from django.conf import settings
 from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
 from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
 from django.db.models.expressions import RawSQL
 from django.db.models.functions import Lower
@@ -44,6 +45,22 @@ def _base_bookmarks_query(
     if user:
         query_set = query_set.filter(owner=user)
 
+    # Filter by modified_since if provided
+    if search.modified_since:
+        try:
+            query_set = query_set.filter(date_modified__gt=search.modified_since)
+        except ValidationError:
+            # If the date format is invalid, ignore the filter
+            pass
+
+    # Filter by added_since if provided
+    if search.added_since:
+        try:
+            query_set = query_set.filter(date_added__gt=search.added_since)
+        except ValidationError:
+            # If the date format is invalid, ignore the filter
+            pass
+
     # Split query into search terms and tags
     query = parse_query_string(search.q)
 
diff --git a/bookmarks/services/assets.py b/bookmarks/services/assets.py
index 4409c61..a6e745d 100644
--- a/bookmarks/services/assets.py
+++ b/bookmarks/services/assets.py
@@ -53,6 +53,7 @@ def create_snapshot(asset: BookmarkAsset):
         asset.save()
 
         asset.bookmark.latest_snapshot = asset
+        asset.bookmark.date_modified = timezone.now()
         asset.bookmark.save()
     except Exception as error:
         asset.status = BookmarkAsset.STATUS_FAILURE
@@ -75,6 +76,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
     asset.save()
 
     asset.bookmark.latest_snapshot = asset
+    asset.bookmark.date_modified = timezone.now()
     asset.bookmark.save()
 
     return asset
@@ -100,6 +102,10 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
         asset.file = filename
         asset.file_size = upload_file.size
         asset.save()
+
+        asset.bookmark.date_modified = timezone.now()
+        asset.bookmark.save()
+
         logger.info(
             f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
         )
@@ -128,9 +134,10 @@ def remove_asset(asset: BookmarkAsset):
         )
 
         bookmark.latest_snapshot = latest
-        bookmark.save()
 
     asset.delete()
+    bookmark.date_modified = timezone.now()
+    bookmark.save()
 
 
 def _generate_asset_filename(
diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md
index 309204b..5b41b16 100644
--- a/docs/src/content/docs/api.md
+++ b/docs/src/content/docs/api.md
@@ -35,6 +35,8 @@ Parameters:
 - `q` - Filters results using a search phrase using the same logic as through the UI
 - `limit` - Limits the max. number of results. Default is `100`.
 - `offset` - Index from which to start returning results
+- `modified_since` - Filter results to only include bookmarks modified after the specified date (format: ISO 8601, e.g. "2025-01-01T00:00:00Z")
+- `added_since` - Filter results to only include bookmarks added after the specified date (format: ISO 8601, e.g. "2025-05-29T00:00:00Z")
 
 Example response:
 


Test patch:
diff --git a/bookmarks/tests/test_assets_service.py b/bookmarks/tests/test_assets_service.py
index 31acd12..97029fa 100644
--- a/bookmarks/tests/test_assets_service.py
+++ b/bookmarks/tests/test_assets_service.py
@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertIsNone(asset.id)
 
     def test_create_snapshot(self):
-        bookmark = self.setup_bookmark(url="https://example.com")
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(
+            url="https://example.com", modified=initial_modified
+        )
         asset = assets.create_snapshot_asset(bookmark)
         asset.save()
         asset.date_created = timezone.datetime(
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.file, expected_filename)
         self.assertTrue(asset.gzip)
 
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+
     def test_create_snapshot_failure(self):
         bookmark = self.setup_bookmark(url="https://example.com")
         asset = assets.create_snapshot_asset(bookmark)
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertTrue(saved_file.endswith("aaaa.html.gz"))
 
     def test_upload_snapshot(self):
-        bookmark = self.setup_bookmark(url="https://example.com")
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(
+            url="https://example.com", modified=initial_modified
+        )
         asset = assets.upload_snapshot(bookmark, self.html_content.encode())
 
         # should create gzip file in asset folder
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.file, saved_file_name)
         self.assertTrue(asset.gzip)
 
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
+
     def test_upload_snapshot_failure(self):
         bookmark = self.setup_bookmark(url="https://example.com")
 
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
     @disable_logging
     def test_upload_asset(self):
-        bookmark = self.setup_bookmark()
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(modified=initial_modified)
         file_content = b"test content"
         upload_file = SimpleUploadedFile(
             "test_file.txt", file_content, content_type="text/plain"
@@ -204,6 +224,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(asset.file_size, len(file_content))
         self.assertFalse(asset.gzip)
 
+        # should update bookmark modified date
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
+
     @disable_logging
     def test_upload_asset_truncates_asset_file_name(self):
         # Create a bookmark with a very long URL
@@ -409,3 +433,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
         # Verify that latest_snapshot hasn't changed
         self.assertEqual(bookmark.latest_snapshot, latest_asset)
+
+    @disable_logging
+    def test_remove_asset(self):
+        initial_modified = timezone.datetime(
+            2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+        )
+        bookmark = self.setup_bookmark(modified=initial_modified)
+        file_content = b"test content for removal"
+        upload_file = SimpleUploadedFile(
+            "test_remove_file.txt", file_content, content_type="text/plain"
+        )
+
+        asset = assets.upload_asset(bookmark, upload_file)
+        asset_filepath = os.path.join(self.assets_dir, asset.file)
+
+        # Verify asset and file exist
+        self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
+        self.assertTrue(os.path.exists(asset_filepath))
+
+        bookmark.date_modified = initial_modified
+        bookmark.save()
+
+        # Remove the asset
+        assets.remove_asset(asset)
+
+        # Verify asset is removed from DB
+        self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
+        # Verify file is removed from disk
+        self.assertFalse(os.path.exists(asset_filepath))
+
+        # Verify bookmark modified date is updated
+        bookmark.refresh_from_db()
+        self.assertGreater(bookmark.date_modified, initial_modified)
diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py
index e1b1908..01e2d0c 100644
--- a/bookmarks/tests/test_queries.py
+++ b/bookmarks/tests/test_queries.py
@@ -1211,3 +1211,79 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
 
         query = queries.query_bookmarks(self.user, self.profile, search)
         self.assertEqual(list(query), sorted_bookmarks)
+
+    def test_query_bookmarks_filter_modified_since(self):
+        # Create bookmarks with different modification dates
+        older_bookmark = self.setup_bookmark(title="old bookmark")
+        recent_bookmark = self.setup_bookmark(title="recent bookmark")
+
+        # Modify date field on bookmark directly to test modified_since
+        older_bookmark.date_modified = timezone.datetime(
+            2025, 1, 1, tzinfo=datetime.timezone.utc
+        )
+        older_bookmark.save()
+        recent_bookmark.date_modified = timezone.datetime(
+            2025, 5, 15, tzinfo=datetime.timezone.utc
+        )
+        recent_bookmark.save()
+
+        # Test with date between the two bookmarks
+        search = BookmarkSearch(modified_since="2025-03-01T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [recent_bookmark])
+
+        # Test with date before both bookmarks
+        search = BookmarkSearch(modified_since="2024-12-31T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with date after both bookmarks
+        search = BookmarkSearch(modified_since="2025-05-16T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [])
+
+        # Test with no modified_since - should return all bookmarks
+        search = BookmarkSearch()
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with invalid date format - should be ignored
+        search = BookmarkSearch(modified_since="invalid-date")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+    def test_query_bookmarks_filter_added_since(self):
+        # Create bookmarks with different dates
+        older_bookmark = self.setup_bookmark(
+            title="old bookmark",
+            added=timezone.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
+        )
+        recent_bookmark = self.setup_bookmark(
+            title="recent bookmark",
+            added=timezone.datetime(2025, 5, 15, tzinfo=datetime.timezone.utc),
+        )
+
+        # Test with date between the two bookmarks
+        search = BookmarkSearch(added_since="2025-03-01T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [recent_bookmark])
+
+        # Test with date before both bookmarks
+        search = BookmarkSearch(added_since="2024-12-31T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with date after both bookmarks
+        search = BookmarkSearch(added_since="2025-05-16T00:00:00Z")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [])
+
+        # Test with no added_since - should return all bookmarks
+        search = BookmarkSearch()
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+        # Test with invalid date format - should be ignored
+        search = BookmarkSearch(added_since="invalid-date")
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])