mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-01 23:43:14 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492de5618c | ||
|
|
c349ad7670 | ||
|
|
1c17e16655 | ||
|
|
9b70bc3b55 | ||
|
|
beba4f8b93 | ||
|
|
bb7af56dc1 | ||
|
|
e89fecbd10 | ||
|
|
70734ed273 | ||
|
|
dcb15f1942 | ||
|
|
3b6cdbdd84 | ||
|
|
344420ec4a | ||
|
|
eb99ece360 | ||
|
|
95529eccd4 | ||
|
|
a6b36750da | ||
|
|
8b98a335d4 | ||
|
|
6ac8ce6a7b | ||
|
|
a9f135552a |
@@ -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": {
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -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
|
||||
|
||||
11
README.md
11
README.md
@@ -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: [](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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
3994
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.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",
|
||||
]
|
||||
|
||||
@@ -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
26
uv.lock
generated
@@ -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]]
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.44.0
|
||||
1.44.2
|
||||
|
||||
Reference in New Issue
Block a user