Compare commits

...

17 Commits

Author SHA1 Message Date
Sascha Ißbrücker
492de5618c Bump version 2025-12-13 10:33:32 +01:00
Sascha Ißbrücker
c349ad7670 Use sandbox CSP for viewing assets (#1245) 2025-12-13 10:32:06 +01:00
Simon
1c17e16655 Bump supervisor to 4.3.0 to fix warning (#1216)
Co-authored-by: simonhammes <simonhammes@users.noreply.github.com>
2025-12-13 10:07:32 +01:00
Devinside
9b70bc3b55 Add Komrade project to community resources (#1236) 2025-12-13 09:54:00 +01:00
vbsampath
beba4f8b93 Add Javascript API to community resources (#1195)
* Added Javascript client and library for Linkding REST API

* Cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-12-13 09:53:16 +01:00
Sascha Ißbrücker
bb7af56dc1 Bump docs dependencies 2025-12-13 09:37:51 +01:00
dependabot[bot]
e89fecbd10 Bump astro from 5.13.2 to 5.14.4 in /docs (#1201)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.13.2 to 5.14.4.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.14.4/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.14.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 07:08:12 +02:00
Sascha Ißbrücker
70734ed273 Fix tag cloud highlighting first char when tags are not grouped (#1209)
* Fix tag cloud highlighting first char when tags are not grouped

* update test
2025-10-18 07:05:15 +02:00
m3e
dcb15f1942 Fix devcontainer (#1208)
* Update Python version to 3.13 in devcontainer

* Update `postCreateCommand` to install and use uv

* Update DevContainers paragraph in README with uv commands

* Update commands

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-10-18 06:24:31 +02:00
Sascha Ißbrücker
3b6cdbdd84 Update CHANGELOG.md 2025-10-11 13:37:40 +02:00
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
27 changed files with 1604 additions and 2734 deletions

View File

@@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"image": "mcr.microsoft.com/devcontainers/python:3.13",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},
@@ -14,7 +14,7 @@
"forwardPorts": [8000],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
"postCreateCommand": "pip install uv && uv sync --group dev && npm install && mkdir -p data && uv run manage.py migrate",
// Configure tool-specific properties.
"customizations": {

View File

@@ -1,5 +1,90 @@
# Changelog
## v1.44.1 (11/10/2025)
### What's Changed
* Fix normalized URL not being generated in bookmark import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1202
* Fix missing tags causing errors in import with Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1203
* Check for dupes by exact URL if normalized URL is missing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1204
* Attempt to fix botched normalized URL migration from 1.43.0 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1205
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.0...v1.44.1
---
## 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

@@ -105,25 +105,20 @@ make format
### DevContainers
> [!WARNING]
> The dev container setup is currently broken after switching to uv.
> Feel free to contribute a PR if you want to fix it.
> The instructions below are outdated until then.
This repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
Once checked out, only the following commands are required to get started:
Create a user for the frontend:
```
python3 manage.py createsuperuser --username=joe --email=joe@example.com
uv run manage.py createsuperuser --username=joe --email=joe@example.com
```
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
```
npm run dev
make frontend
```
Start the Django development server with:
```
python3 manage.py runserver
make serve
```
The frontend is now available under http://localhost:8000

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

@@ -15,15 +15,15 @@
{% for group in tag_cloud.groups %}
<p class="group">
{% for tag in group.tags %}
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
{# Highlight first char of first tag in group if grouping is enabled #}
{% if group.highlight_first_char and forloop.counter == 1 %}
<a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item>
<span
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
{# Render tags normally #}
<a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>

View File

@@ -141,7 +141,7 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def test_reader_view_access_guest_user(self):
self.view_access_guest_user_test("linkding:assets.read")
def test_snapshot_download_name(self):
def test_snapshot_download_headers(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
@@ -151,8 +151,9 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
response["Content-Disposition"],
f'inline; filename="{asset.display_name}.html"',
)
self.assertEqual(response["Content-Security-Policy"], "sandbox")
def test_uploaded_file_download_name(self):
def test_uploaded_file_download_headers(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_uploaded_file(bookmark)
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
@@ -162,3 +163,4 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
response["Content-Disposition"],
f'inline; filename="{asset.display_name}"',
)
self.assertEqual(response["Content-Security-Policy"], "sandbox")

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

@@ -32,7 +32,12 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
return template_to_render.render(context)
def assertTagGroups(self, rendered_template: str, groups: List[List[str]]):
def assertTagGroups(
self,
rendered_template: str,
groups: List[List[str]],
highlight_first_char: bool = True,
):
soup = self.make_soup(rendered_template)
group_elements = soup.select("p.group")
@@ -48,6 +53,18 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
link_element = link_elements[tag_index]
self.assertEqual(link_element.text.strip(), tag)
if tag_index == 0:
if highlight_first_char:
self.assertIn(
f'<span class="highlight-char">{tag[0]}</span>',
str(link_element),
)
else:
self.assertNotIn(
f'<span class="highlight-char">{tag[0]}</span>',
str(link_element),
)
def assertNumSelectedTags(self, rendered_template: str, count: int):
soup = self.make_soup(rendered_template)
link_elements = soup.select("p.selected-tags a")
@@ -178,6 +195,7 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
"Coyote",
],
],
False,
)
def test_no_duplicate_tag_names(self):

View File

@@ -33,6 +33,7 @@ def view(request, asset_id: int):
response = HttpResponse(content, content_type=asset.content_type)
response["Content-Disposition"] = f'inline; filename="{asset.download_name}"'
response["Content-Security-Policy"] = "sandbox"
return response

View File

@@ -383,10 +383,13 @@ class RemoveTagItem:
class TagGroup:
def __init__(self, context: RequestContext, char: str):
def __init__(
self, context: RequestContext, char: str, highlight_first_char: bool = True
):
self.context = context
self.tags = []
self.char = char
self.highlight_first_char = highlight_first_char
def __repr__(self):
return f"<{self.char} TagGroup>"
@@ -436,7 +439,7 @@ class TagGroup:
return []
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = TagGroup(context, "Ungrouped")
group = TagGroup(context, "Ungrouped", highlight_first_char=False)
for tag in sorted_tags:
group.add_tag(tag)

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" },

3994
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,8 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.34.3",
"astro": "^5.13.2",
"sharp": "^0.32.5",
"typescript": "^5.6.2"
"@astrojs/starlight": "^0.37.1",
"astro": "^5.6.1",
"sharp": "^0.34.2"
}
}

View File

@@ -15,8 +15,10 @@ This section lists community projects around using linkding, in alphabetical ord
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
- [Komrade](https://codeberg.org/kodemonaut/komrade) - A simple docker based startpage/dashboard which syncs with Linkding, Linkwarden or Nextcloud Bookmarks.
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [LinkBuddy](https://github.com/peterto/LinkBuddy) An open-source Android and iOS client for linkding, written in React Native. Android apk available on [github](https://github.com/peterto/LinkBuddy/releases) and iOS version on [Apple AppStore](https://apps.apple.com/us/app/linkbuddy-for-linkding/id6740408952). By [peterto](https://github.com/peterto).
- [linkding-api](https://github.com/vbsampath/linkding-api) A Javascript library implementing linkding REST API by [vbsampath](https://github.com/vbsampath)
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)

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.2"
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"
@@ -17,7 +17,7 @@ dependencies = [
"mozilla-django-oidc>=4.0.1",
"python-dateutil>=2.9.0.post0",
"requests>=2.32.4",
"supervisor>=4.2.5",
"supervisor>=4.3.0",
"uwsgi>=2.0.28",
"waybackpy>=3.0.6",
]

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

26
uv.lock generated
View File

@@ -329,6 +329,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -336,6 +338,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
@@ -377,7 +381,7 @@ wheels = [
[[package]]
name = "linkding"
version = "1.42.0"
version = "1.44.2"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
@@ -426,7 +430,7 @@ requires-dist = [
{ name = "mozilla-django-oidc", specifier = ">=4.0.1" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "requests", specifier = ">=2.32.4" },
{ name = "supervisor", specifier = ">=4.2.5" },
{ name = "supervisor", specifier = ">=4.3.0" },
{ name = "uwsgi", specifier = ">=2.0.28" },
{ name = "waybackpy", specifier = ">=3.0.6" },
]
@@ -674,15 +678,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -712,14 +707,11 @@ wheels = [
[[package]]
name = "supervisor"
version = "4.2.5"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/37/517989b05849dd6eaa76c148f24517544704895830a50289cbbf53c7efb9/supervisor-4.2.5.tar.gz", hash = "sha256:34761bae1a23c58192281a5115fb07fbf22c9b0133c08166beffc70fed3ebc12", size = 466073, upload-time = "2022-12-24T01:02:43.705Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/b5/37e7a3706de436a8a2d75334711dad1afb4ddffab09f25e31d89e467542f/supervisor-4.3.0.tar.gz", hash = "sha256:4a2bf149adf42997e1bb44b70c43b613275ec9852c3edacca86a9166b27e945e", size = 468912, upload-time = "2025-08-23T18:25:02.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/7a/0ad3973941590c040475046fef37a2b08a76691e61aa59540828ee235a6e/supervisor-4.2.5-py2.py3-none-any.whl", hash = "sha256:2ecaede32fc25af814696374b79e42644ecaba5c09494c51016ffda9602d0f08", size = 319561, upload-time = "2022-12-24T01:02:40.814Z" },
{ url = "https://files.pythonhosted.org/packages/0e/65/5e726c372da8a5e35022a94388b12252710aad0c2351699c3d76ae8dba78/supervisor-4.3.0-py2.py3-none-any.whl", hash = "sha256:0bcb763fddafba410f35cbde226aa7f8514b9fb82eb05a0c85f6588d1c13f8db", size = 320736, upload-time = "2025-08-23T18:25:00.767Z" },
]
[[package]]

View File

@@ -1 +1 @@
1.44.0
1.44.2