mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 15:03:12 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344420ec4a | ||
|
|
eb99ece360 | ||
|
|
95529eccd4 | ||
|
|
a6b36750da | ||
|
|
8b98a335d4 | ||
|
|
6ac8ce6a7b | ||
|
|
a9f135552a |
72
CHANGELOG.md
72
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
34
bookmarks/migrations/0051_fix_normalized_url.py
Normal file
34
bookmarks/migrations/0051_fix_normalized_url.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
2
uv.lock
generated
@@ -377,7 +377,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "linkding"
|
||||
version = "1.42.0"
|
||||
version = "1.44.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.44.0
|
||||
1.44.1
|
||||
|
||||
Reference in New Issue
Block a user