Compare commits

..

7 Commits

Author SHA1 Message Date
Sascha Ißbrücker
344420ec4a Bump version 2025-10-11 11:14:37 +02:00
Sascha Ißbrücker
eb99ece360 Attempt to fix botched normalized URL migration from 1.43.0 (#1205) 2025-10-11 11:12:27 +02:00
Sascha Ißbrücker
95529eccd4 Check for dupes by exact URL if normalized URL is missing (#1204) 2025-10-11 10:45:23 +02:00
Sascha Ißbrücker
a6b36750da Fix missing tags causing errors in import with Postgres (#1203)
* Handle missing tags in importer

* Make all tests run with Postgres again
2025-10-11 10:32:31 +02:00
Sascha Ißbrücker
8b98a335d4 Fix normalized URL not being generated in bookmark import (#1202) 2025-10-11 09:57:14 +02:00
Sascha Ißbrücker
6ac8ce6a7b Publish search guide 2025-10-05 20:16:07 +02:00
Sascha Ißbrücker
a9f135552a Update CHANGELOG.md 2025-10-05 15:43:13 +02:00
17 changed files with 209 additions and 31 deletions

View File

@@ -1,5 +1,77 @@
# Changelog
## v1.44.0 (05/10/2025)
### What's Changed
* Add new search engine that supports logical expressions (and, or, not) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1198
* Fix pagination links to use relative URLs by @dunlor in https://github.com/sissbruecker/linkding/pull/1186
* Fix queued tasks link when context path is used by @dunlor in https://github.com/sissbruecker/linkding/pull/1187
* Fix bundle preview pagination resetting to first page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1194
### New Contributors
* @dunlor made their first contribution in https://github.com/sissbruecker/linkding/pull/1186
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.43.0...v1.44.0
---
## v1.43.0 (28/09/2025)
### What's Changed
* Add basic tag management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1175
* Normalize URLs when checking for duplicates by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1169
* Add option to mark bookmarks as shared by default by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1170
* Use modal dialog for confirming actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1168
* Fix error when filtering bookmark assets in the admin UI by @proog in https://github.com/sissbruecker/linkding/pull/1162
* Document API bundle filter by @proog in https://github.com/sissbruecker/linkding/pull/1161
* Add alfred-linkding-bookmarks to community.md by @FireFingers21 in https://github.com/sissbruecker/linkding/pull/1160
* Switch to uv by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1172
* Replace Svelte components with Lit elements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1174
* Bump versions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1173
* Bump astro from 5.12.8 to 5.13.2 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1166
* Bump vite from 6.3.5 to 6.3.6 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1184
### New Contributors
* @FireFingers21 made their first contribution in https://github.com/sissbruecker/linkding/pull/1160
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.42.0...v1.43.0
---
## v1.42.0 (16/08/2025)
### What's Changed
* Bulk create HTML snapshots by @Tql-ws1 in https://github.com/sissbruecker/linkding/pull/1132
* Create bundle from current search query by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1154
* Add alternative bookmarklet that uses browser metadata by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1159
* Add date and time to HTML export filename by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1101
* Automatically compress uploads with gzip by @hkclark in https://github.com/sissbruecker/linkding/pull/1087
* Show bookmark bundles in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1110
* Allow filtering feeds by bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1152
* Submit bookmark form with Ctrl/Cmd + Enter by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1158
* Improve bookmark form accessibility by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1116
* Fix custom CSS not being used in reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1102
* Use filename when downloading asset through UI by @proog in https://github.com/sissbruecker/linkding/pull/1146
* Update order when deleting bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1114
* Wrap long titles in bookmark details modal by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1150
* Ignore tags with just whitespace by @pvl in https://github.com/sissbruecker/linkding/pull/1125
* Ignore tags that exceed length limit during import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1153
* Add CloudBreak on Managed Hosting by @benjaminoakes in https://github.com/sissbruecker/linkding/pull/1079
* Add Pocket migration to to community page by @hkclark in https://github.com/sissbruecker/linkding/pull/1112
* Add linkding-media-archiver to community.md by @proog in https://github.com/sissbruecker/linkding/pull/1144
* Bump astro from 5.7.13 to 5.12.8 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1147
### New Contributors
* @hkclark made their first contribution in https://github.com/sissbruecker/linkding/pull/1087
* @benjaminoakes made their first contribution in https://github.com/sissbruecker/linkding/pull/1079
* @proog made their first contribution in https://github.com/sissbruecker/linkding/pull/1146
* @pvl made their first contribution in https://github.com/sissbruecker/linkding/pull/1125
* @Tql-ws1 made their first contribution in https://github.com/sissbruecker/linkding/pull/1132
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.41.0...v1.42.0
---
## v1.41.0 (19/06/2025)
### What's Changed

View File

@@ -27,7 +27,6 @@ from bookmarks.models import (
BookmarkBundle,
)
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.utils import normalize_url
from bookmarks.type_defs import HttpRequest
from bookmarks.views import access
@@ -108,10 +107,7 @@ class BookmarkViewSet(
def check(self, request: HttpRequest):
url = request.GET.get("url")
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
normalized_url = normalize_url(url)
bookmark = Bookmark.objects.filter(
owner=request.user, url_normalized=normalized_url
).first()
bookmark = Bookmark.query_existing(request.user, url).first()
existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None
)
@@ -155,10 +151,7 @@ class BookmarkViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
normalized_url = normalize_url(url)
bookmark = Bookmark.objects.filter(
owner=request.user, url_normalized=normalized_url
).first()
bookmark = Bookmark.query_existing(request.user, url).first()
if not bookmark:
bookmark = Bookmark(url=url)

View File

@@ -11,7 +11,6 @@ from bookmarks.models import (
)
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.type_defs import HttpRequest
from bookmarks.utils import normalize_url
from bookmarks.validators import BookmarkURLValidator
@@ -94,11 +93,8 @@ class BookmarkForm(forms.ModelForm):
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
normalized_url = normalize_url(url)
is_duplicate = (
Bookmark.objects.filter(
owner=self.instance.owner, url_normalized=normalized_url
)
Bookmark.query_existing(self.instance.owner, url)
.exclude(pk=self.instance.pk)
.exists()
)

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.5 on 2025-10-11 08:46
from django.db import migrations
from bookmarks.utils import normalize_url
def fix_url_normalized(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
batch_size = 200
qs = Bookmark.objects.filter(url_normalized="").all()
for start in range(0, qs.count(), batch_size):
batch = list(qs[start : start + batch_size])
for bookmark in batch:
bookmark.url_normalized = normalize_url(bookmark.url)
Bookmark.objects.bulk_update(batch, ["url_normalized"])
def reverse_fix_url_normalized(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0050_new_search_toast"),
]
operations = [
migrations.RunPython(
fix_url_normalized,
reverse_fix_url_normalized,
),
]

View File

@@ -1,14 +1,15 @@
import binascii
import hashlib
import logging
import os
from typing import List
import binascii
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.http import QueryDict
@@ -103,6 +104,16 @@ class Bookmark(models.Model):
def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)"
@staticmethod
def query_existing(owner: User, url: str) -> models.QuerySet:
# Find existing bookmark by normalized URL, or fall back to exact URL if
# normalized URL was not generated for whatever reason
normalized_url = normalize_url(url)
q = Q(owner=owner) & (
Q(url_normalized=normalized_url) | Q(url_normalized="", url=url)
)
return Bookmark.objects.filter(q)
@receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs):

View File

@@ -4,7 +4,6 @@ from typing import Union
from django.utils import timezone
from bookmarks.models import Bookmark, User, parse_tag_string
from bookmarks.utils import normalize_url
from bookmarks.services import auto_tagging
from bookmarks.services import tasks
from bookmarks.services import website_loader
@@ -20,9 +19,8 @@ def create_bookmark(
disable_html_snapshot: bool = False,
):
# If URL is already bookmarked, then update it
normalized_url = normalize_url(bookmark.url)
existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url_normalized=normalized_url
existing_bookmark: Bookmark = Bookmark.query_existing(
current_user, bookmark.url
).first()
if existing_bookmark is not None:

View File

@@ -8,7 +8,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services.parser import parse, NetscapeBookmark
from bookmarks.utils import parse_timestamp
from bookmarks.utils import normalize_url, parse_timestamp
logger = logging.getLogger(__name__)
@@ -45,8 +45,9 @@ class TagCache:
result = []
for tag_name in tag_names:
tag = self.get(tag_name)
# Tag may not have been created if tag name exceeded maximum length
# Prevent returning duplicates
if not (tag in result):
if tag and not (tag in result):
result.append(tag)
return result
@@ -181,6 +182,7 @@ def _import_batch(
bookmarks_to_update,
[
"url",
"url_normalized",
"date_added",
"date_modified",
"unread",
@@ -234,6 +236,7 @@ def _copy_bookmark_data(
netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions
):
bookmark.url = netscape_bookmark.href
bookmark.url_normalized = normalize_url(bookmark.url)
if netscape_bookmark.date_added:
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else:

View File

@@ -114,6 +114,47 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title)
def test_create_should_update_existing_bookmark_when_normalized_url_is_empty(
self,
):
# Test behavior when url_normalized is empty for whatever reason
# In this case should at least match the URL directly
original_bookmark = self.setup_bookmark(url="https://example.com")
Bookmark.objects.update(url_normalized="")
bookmark_data = Bookmark(
url="https://example.com",
title="Updated Title",
description="Updated description",
)
updated_bookmark = create_bookmark(
bookmark_data, "", self.get_or_create_test_user()
)
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title)
def test_create_should_update_first_existing_bookmark_for_multiple_duplicates(
self,
):
first_dupe = self.setup_bookmark(url="https://example.com")
second_dupe = self.setup_bookmark(url="https://example.com/")
bookmark_data = Bookmark(
url="https://example.com",
title="Updated Title",
description="Updated description",
)
create_bookmark(bookmark_data, "", self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 2)
first_dupe.refresh_from_db()
self.assertEqual(first_dupe.title, bookmark_data.title)
second_dupe.refresh_from_db()
self.assertNotEqual(second_dupe.title, bookmark_data.title)
def test_create_should_populate_url_normalized_field(self):
bookmark_data = Bookmark(
url="https://EXAMPLE.COM/path/?z=1&a=2",

View File

@@ -1,13 +1,15 @@
import datetime
import email
import unittest
import urllib.parse
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import FeedToken, User
from bookmarks.feeds import sanitize
from bookmarks.models import FeedToken, User
from bookmarks.tests.helpers import BookmarkFactoryMixin
def rfc2822_date(date):
@@ -343,6 +345,10 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=5)
@unittest.skipIf(
settings.LD_DB_ENGINE == "postgres",
"Postgres does not allow NUL in text columns",
)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"

View File

@@ -409,6 +409,21 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(import_result.success, 0)
self.assertEqual(import_result.failed, 2)
def test_generate_normalized_url(self):
html_tags = [
BookmarkHtmlTag(href="https://example.com/?z=1&a=2#"),
BookmarkHtmlTag(
href="foo.bar"
), # invalid URL, should be skipped without error
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(
Bookmark.objects.all()[0].url_normalized, "https://example.com?a=2&z=1"
)
def test_private_flag(self):
# does not map private flag if not enabled in options
test_html = self.render_html(

View File

@@ -1199,7 +1199,11 @@ class QueriesBasicTestCase(TestCase, BookmarkFactoryMixin):
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower())
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
# Use resolved title for comparison as Postgres returns bookmarks with same resolved title in random order
expected_effective_titles = [b.resolved_title for b in sorted_bookmarks]
actual_effective_titles = [b.resolved_title for b in query]
self.assertEqual(expected_effective_titles, actual_effective_titles)
def test_sort_by_title_desc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC)
@@ -1210,7 +1214,11 @@ class QueriesBasicTestCase(TestCase, BookmarkFactoryMixin):
)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
# Use resolved title for comparison as Postgres returns bookmarks with same resolved title in random order
expected_effective_titles = [b.resolved_title for b in sorted_bookmarks]
actual_effective_titles = [b.resolved_title for b in query]
self.assertEqual(expected_effective_titles, actual_effective_titles)
def test_query_bookmarks_filter_modified_since(self):
# Create bookmarks with different modification dates

View File

@@ -31,7 +31,7 @@ export default defineConfig({
label: "Guides",
items: [
{ label: "Backups", slug: "backups" },
//{ label: "Bookmark Search", slug: "search" },
{ label: "Search", slug: "search" },
{ label: "Archiving", slug: "archiving" },
{ label: "Auto Tagging", slug: "auto-tagging" },
{ label: "Keyboard Shortcuts", slug: "shortcuts" },

View File

@@ -1,5 +1,5 @@
---
title: Bookmark Search
title: Search
---
linkding provides a comprehensive search function for finding bookmarks. This guide gives on overview of the search capabilities and provides some examples.
@@ -12,7 +12,7 @@ Every search query is made up of one or more expressions. An expression can be a
|--------------|------------------------------------|------------------------------------------------------------|
| Word | `history` | Search for a single word in title, description, notes, URL |
| Phrase | `"history of rome"` | Search for an exact phrase by enclosing it in quotes |
| Tag | `#book` | Search for tag |
| Tag | `#book` | Search for a tag |
| AND operator | `#history and #book` | Both expressions must match |
| OR operator | `#book or #article` | Either expression must match |
| NOT operator | `not #article` | Expression must not match |

View File

@@ -1,6 +1,6 @@
[project]
name = "linkding"
version = "1.44.0"
version = "1.44.1"
description = "Self-hosted bookmark manager that is designed be to be minimal, fast, and easy to set up using Docker."
readme = "README.md"
requires-python = ">=3.13"

View File

@@ -9,6 +9,7 @@ docker run -d \
-e POSTGRES_USER=linkding \
-e POSTGRES_PASSWORD=linkding \
-p 5432:5432 \
-v $(pwd)/tmp/postgres-data:/var/lib/postgresql/data \
--name linkding-postgres-test \
postgres

2
uv.lock generated
View File

@@ -377,7 +377,7 @@ wheels = [
[[package]]
name = "linkding"
version = "1.42.0"
version = "1.44.1"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },

View File

@@ -1 +1 @@
1.44.0
1.44.1