Compare commits

..

22 Commits

Author SHA1 Message Date
Sascha Ißbrücker
04248a7fba Bump version 2025-08-16 07:31:30 +02:00
Sascha Ißbrücker
0ff36a94fe Add alternative bookmarklet that uses browser metadata (#1159) 2025-08-16 07:29:53 +02:00
Sascha Ißbrücker
f83eb25569 Submit bookmark form with Ctrl/Cmd + Enter (#1158) 2025-08-16 06:20:07 +02:00
thR CIrcU5
c746afcf76 Bulk create HTML snapshots (#1132)
* Add option to create HTML snapshot for bulk edit

* Add the prerequisite for displaying the "Create HTML Snapshot" bulk action option

* Add test case

This test case covers the scenario where the bulk actions panel displays the corresponding options when the HTML snapshot feature is enabled.

* Use the existing `tasks.create_html_snapshots()` instead of the for loop

* Fix the exposure of `settings.LD_ENABLE_SNAPSHOTS` within `BookmarkListContext`

* add service tests

* cleanup context

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-08-12 23:06:23 +02:00
Sascha Ißbrücker
aaa0f6e119 Run formatter 2025-08-11 08:05:50 +02:00
Sascha Ißbrücker
cd215a9237 Create bundle from current search query (#1154) 2025-08-10 22:45:28 +02:00
Sascha Ißbrücker
1e56b0e6f3 Ignore tags that exceed length limit during import (#1153) 2025-08-10 15:05:10 +02:00
Sascha Ißbrücker
5cc8c9c010 Allow filtering feeds by bundle (#1152) 2025-08-10 12:59:55 +02:00
Pedro Lima
846808d870 Ignore tags with just whitespace (#1125) 2025-08-10 10:20:03 +02:00
Sascha Ißbrücker
6d9a694756 Wrap long titles in bookmark details modal (#1150) 2025-08-10 10:05:46 +02:00
Per Mortensen
de38e56b3f Add linkding-media-archiver to community.md (#1144)
Adds a new project link to the community page
2025-08-10 09:11:42 +02:00
dependabot[bot]
c6fb695af2 Bump astro from 5.7.13 to 5.12.8 in /docs (#1147)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.7.13 to 5.12.8.
- [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.12.8/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.12.8
  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-08-10 09:09:09 +02:00
Per Mortensen
93faf70b37 Use filename when downloading asset through UI (#1146) 2025-08-10 08:38:18 +02:00
hkclark
5330252db9 Add Pocket migration to to community page (#1112)
* Add Pocket migration to to community page

* Fix order

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-07-23 03:17:45 +02:00
Ben Oakes
ef00d289f5 Add CloudBreak on Managed Hosting (#1079)
* Add CloudBreak on Managed Hosting

* Use new path
2025-07-23 03:15:26 +02:00
Sascha Ißbrücker
4e8318d0ae Improve bookmark form accessibility (#1116)
* Bump Django

* Render error messages in English

* Remove unused USE_L10N option

* Associate errors and help texts with form fields

* Make checkbox inputs clickable

* Change cancel button text

* Fix tests
2025-07-03 08:44:41 +02:00
Sascha Ißbrücker
a8623d11ef Update order when deleting bundle (#1114) 2025-07-01 07:09:02 +02:00
Sascha Ißbrücker
8cd992ca30 Show bookmark bundles in admin (#1110) 2025-06-25 19:37:34 +02:00
Sascha Ißbrücker
68c104ba54 Fix custom CSS not being used in reader mode (#1102) 2025-06-20 06:22:08 +02:00
hkclark
7a4236d179 Automatically compress uploads with gzip (#1087)
* Gzip .html upload, tests for .html & .gz uploads

* Gzip all file types that aren't already gzips

* Show filename of what user uploaded before compression

* Remove line I thought we need but we don't

* cleanup and fix tests

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-06-20 06:15:25 +02:00
Sascha Ißbrücker
e87304501f Add date and time to HTML export filename (#1101) 2025-06-20 06:01:15 +02:00
Sascha Ißbrücker
809e9e02f3 Update CHANGELOG.md 2025-06-20 00:38:18 +02:00
64 changed files with 1068 additions and 203 deletions

View File

@@ -1,5 +1,26 @@
# Changelog # Changelog
## v1.41.0 (19/06/2025)
### What's Changed
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
### New Contributors
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
---
## v1.40.0 (17/05/2025) ## v1.40.0 (17/05/2025)
### What's Changed ### What's Changed

View File

@@ -11,7 +11,15 @@ from huey.contrib.djhuey import HUEY as huey
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
Tag,
UserProfile,
Toast,
FeedToken,
)
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
) )
class AdminBookmarkBundle(admin.ModelAdmin):
list_display = (
"name",
"owner",
"order",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"date_created",
)
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
list_filter = ("owner__username",)
class AdminUserProfileInline(admin.StackedInline): class AdminUserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
can_delete = False can_delete = False
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset) linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(Toast, AdminToast)

View File

@@ -26,7 +26,7 @@ from bookmarks.models import (
User, User,
BookmarkBundle, BookmarkBundle,
) )
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.type_defs import HttpRequest from bookmarks.type_defs import HttpRequest
from bookmarks.views import access from bookmarks.views import access
@@ -199,13 +199,10 @@ class BookmarkAssetViewSet(
if asset.gzip if asset.gzip
else open(file_path, "rb") else open(file_path, "rb")
) )
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
)
response = StreamingHttpResponse(file_stream, content_type=content_type) response = StreamingHttpResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{file_name}"' response["Content-Disposition"] = (
f'attachment; filename="{asset.download_name}"'
)
return response return response
except FileNotFoundError: except FileNotFoundError:
raise Http404("Asset file does not exist") raise Http404("Asset file does not exist")
@@ -290,6 +287,9 @@ class BookmarkBundleViewSet(
def get_serializer_context(self): def get_serializer_context(self):
return {"user": self.request.user} return {"user": self.request.user}
def perform_destroy(self, instance):
bundles.delete_bundle(instance)
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/ # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
# Instead create separate routers for each view set and manually register them in urls.py # Instead create separate routers for each view set and manually register them in urls.py

View File

@@ -11,7 +11,7 @@ from bookmarks.models import (
UserProfile, UserProfile,
BookmarkBundle, BookmarkBundle,
) )
from bookmarks.services import bookmarks from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version from bookmarks.utils import app_version
@@ -55,17 +55,9 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
] ]
def create(self, validated_data): def create(self, validated_data):
# Set owner to the authenticated user bundle = BookmarkBundle(**validated_data)
validated_data["owner"] = self.context["user"] bundle.order = validated_data["order"] if "order" in validated_data else None
return bundles.create_bundle(bundle, self.context["user"])
# Set order to the next available position if not provided
if "order" not in validated_data:
max_order = BookmarkBundle.objects.filter(
owner=self.context["user"]
).aggregate(Max("order", default=-1))["order__max"]
validated_data["order"] = max_order + 1
return super().create(validated_data)
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):

View File

@@ -8,6 +8,7 @@ from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
from bookmarks.views import access
@dataclass @dataclass
@@ -30,10 +31,16 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed): class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str | None): def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
bundle = None
bundle_id = request.GET.get("bundle")
if bundle_id:
bundle = access.bundle_read(request, bundle_id)
search = BookmarkSearch( search = BookmarkSearch(
q=request.GET.get("q", ""), q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""), unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""), shared=request.GET.get("shared", ""),
bundle=bundle,
) )
query_set = self.get_query_set(feed_token, search) query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set) return FeedContext(request, feed_token, query_set)

View File

@@ -1,4 +1,5 @@
from django import forms from django import forms
from django.forms.utils import ErrorList
from bookmarks.models import Bookmark, build_tag_string from bookmarks.models import Bookmark, build_tag_string
from bookmarks.validators import BookmarkURLValidator from bookmarks.validators import BookmarkURLValidator
@@ -6,6 +7,10 @@ from bookmarks.type_defs import HttpRequest
from bookmarks.services.bookmarks import create_bookmark, update_bookmark from bookmarks.services.bookmarks import create_bookmark, update_bookmark
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
class BookmarkForm(forms.ModelForm): class BookmarkForm(forms.ModelForm):
# Use URLField for URL # Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()]) url = forms.CharField(validators=[BookmarkURLValidator()])
@@ -48,7 +53,9 @@ class BookmarkForm(forms.ModelForm):
if instance is not None and request.method == "GET": if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")} initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None data = request.POST if request.method == "POST" else None
super().__init__(data, instance=instance, initial=initial) super().__init__(
data, instance=instance, initial=initial, error_class=CustomErrorList
)
@property @property
def is_auto_close(self): def is_auto_close(self):

View File

@@ -1,5 +1,27 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class FormSubmit extends Behavior {
constructor(element) {
super(element);
this.onKeyDown = this.onKeyDown.bind(this);
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Check for Ctrl/Cmd + Enter combination
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
this.element.requestSubmit();
}
}
}
class AutoSubmitBehavior extends Behavior { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
@@ -51,5 +73,6 @@ class UploadButton extends Behavior {
} }
} }
registerBehavior("ld-form-submit", FormSubmit);
registerBehavior("ld-auto-submit", AutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton); registerBehavior("ld-upload-button", UploadButton);

View File

@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
name: input.name, name: input.name,
value: input.value, value: input.value,
placeholder: input.getAttribute("placeholder") || "", placeholder: input.getAttribute("placeholder") || "",
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
variant: input.getAttribute("variant"), variant: input.getAttribute("variant"),
}, },
}); });

View File

@@ -6,6 +6,7 @@
export let name; export let name;
export let value; export let value;
export let placeholder; export let placeholder;
export let ariaDescribedBy;
export let variant = 'default'; export let variant = 'default';
let isFocus = false; let isFocus = false;
@@ -110,6 +111,7 @@
<!-- autocomplete real input box --> <!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}" <input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off" class="form-input" type="text" autocomplete="off" autocapitalize="off"
aria-describedby="{ariaDescribedBy}"
on:input={handleInput} on:keydown={handleKeyDown} on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}> on:focus={handleFocus} on:blur={handleBlur}>
</div> </div>

View File

@@ -40,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
return [] return []
names = tag_string.strip().split(delimiter) names = tag_string.strip().split(delimiter)
# remove empty names, sanitize remaining names # remove empty names, sanitize remaining names
names = [sanitize_tag_name(name) for name in names if name] names = [sanitize_tag_name(name) for name in names if name.strip()]
# remove duplicates # remove duplicates
names = unique(names, str.lower) names = unique(names, str.lower)
names.sort(key=str.lower) names.sort(key=str.lower)
@@ -133,6 +133,14 @@ class BookmarkAsset(models.Model):
status = models.CharField(max_length=64, blank=False, null=False) status = models.CharField(max_length=64, blank=False, null=False)
gzip = models.BooleanField(default=False, null=False) gzip = models.BooleanField(default=False, null=False)
@property
def download_name(self):
return (
f"{self.display_name}.html"
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else self.display_name
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.file: if self.file:
try: try:

View File

@@ -94,13 +94,28 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
gzip=False, gzip=False,
) )
name, extension = os.path.splitext(upload_file.name) name, extension = os.path.splitext(upload_file.name)
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) # automatically gzip the file if it is not already gzipped
with open(filepath, "wb") as f: if upload_file.content_type != "application/gzip":
for chunk in upload_file.chunks(): filename = _generate_asset_filename(
f.write(chunk) asset, name, extension.lstrip(".") + ".gz"
asset.file = filename )
asset.file_size = upload_file.size filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb", compresslevel=9) as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.gzip = True
asset.file = filename
asset.file_size = os.path.getsize(filepath)
else:
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save() asset.save()
asset.bookmark.date_modified = timezone.now() asset.bookmark.date_modified = timezone.now()

View File

@@ -208,6 +208,15 @@ def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: Us
tasks.load_preview_image(current_user, bookmark) tasks.load_preview_image(current_user, bookmark)
def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
owned_bookmarks = Bookmark.objects.filter(
owner=current_user, id__in=sanitized_bookmark_ids
)
tasks.create_html_snapshots(owned_bookmarks)
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

View File

@@ -0,0 +1,37 @@
from django.db.models import Max
from bookmarks.models import BookmarkBundle, User
def create_bundle(bundle: BookmarkBundle, current_user: User):
bundle.owner = current_user
if bundle.order is None:
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
Max("order", default=-1)
)
bundle.order = max_order_result["order__max"] + 1
bundle.save()
return bundle
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
user_bundles = list(
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
)
if new_order != user_bundles.index(bundle_to_move):
user_bundles.remove(bundle_to_move)
user_bundles.insert(new_order, bundle_to_move)
for bundle_index, bundle in enumerate(user_bundles):
bundle.order = bundle_index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
def delete_bundle(bundle: BookmarkBundle):
bundle.delete()
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
for index, user_bundle in enumerate(user_bundles):
user_bundle.order = index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])

View File

@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
for netscape_bookmark in netscape_bookmarks: for netscape_bookmark in netscape_bookmarks:
for tag_name in netscape_bookmark.tag_names: for tag_name in netscape_bookmark.tag_names:
# Skip tag names that exceed the maximum allowed length
if len(tag_name) > 64:
logger.warning(
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
)
continue
tag = tag_cache.get(tag_name) tag = tag_cache.get(tag_name)
if not tag: if not tag:
tag = Tag(name=tag_name, owner=user) tag = Tag(name=tag_name, owner=user)

View File

@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)

View File

@@ -1,5 +1,13 @@
/* Common styles */ /* Common styles */
.bookmark-details { .bookmark-details {
.title {
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
}
& .weblinks { & .weblinks {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -25,7 +25,7 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.section-header { .section-header:not(.no-wrap) {
flex-direction: column; flex-direction: column;
} }
} }

View File

@@ -224,12 +224,13 @@ textarea.form-input {
position: relative; position: relative;
input { input {
clip: rect(0, 0, 0, 0); opacity: 0;
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute; position: absolute;
width: 1px; top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
left: 0;
height: var(--control-icon-size);
width: var(--control-icon-size);
cursor: pointer;
&:focus-visible + .form-icon { &:focus-visible + .form-icon {
outline: var(--focus-outline); outline: var(--focus-outline);
@@ -243,9 +244,9 @@ textarea.form-input {
} }
.form-icon { .form-icon {
pointer-events: none;
border: var(--border-width) solid var(--checkbox-border-color); border: var(--border-width) solid var(--checkbox-border-color);
box-shadow: var(--input-box-shadow); box-shadow: var(--input-box-shadow);
cursor: pointer;
display: inline-block; display: inline-block;
position: absolute; position: absolute;
transition: transition:

View File

@@ -1,7 +1,7 @@
(function () { (function () {
var bookmarkUrl = window.location; const bookmarkUrl = window.location;
var applicationUrl = '{{ application_url }}';
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&auto_close'; applicationUrl += '&auto_close';

View File

@@ -0,0 +1,25 @@
(function () {
const bookmarkUrl = window.location;
const title =
document.querySelector('title')?.textContent ||
document
.querySelector(`meta[property='og:title']`)
?.getAttribute('content') ||
'';
const description =
document
.querySelector(`meta[name='description']`)
?.getAttribute('content') ||
document
.querySelector(`meta[property='og:description']`)
?.getAttribute(`content`) ||
'';
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&title=' + encodeURIComponent(title);
applicationUrl += '&description=' + encodeURIComponent(description);
applicationUrl += '&auto_close';
window.open(applicationUrl);
})();

View File

@@ -23,6 +23,9 @@
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
{% endif %} {% endif %}
<option value="bulk_refresh">Refresh from website</option> <option value="bulk_refresh">Refresh from website</option>
{% if bookmark_list.snapshot_feature_enabled %}
<option value="bulk_snapshot">Create HTML snapshot</option>
{% endif %}
</select> </select>
<div class="tag-autocomplete d-none" ld-tag-autocomplete> <div class="tag-autocomplete d-none" ld-tag-autocomplete>
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small"> <input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">

View File

@@ -1,16 +1,29 @@
{% if not request.user_profile.hide_bundles %} {% if not request.user_profile.hide_bundles %}
<section aria-labelledby="bundles-heading"> <section aria-labelledby="bundles-heading">
<div class="section-header"> <div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2> <h2 id="bundles-heading">Bundles</h2>
<a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles"> <div ld-dropdown class="dropdown dropdown-right ml-auto">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" <button class="btn dropdown-toggle" aria-label="Bundles menu">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/> <path d="M4 6l16 0"/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/> <path d="M4 12l16 0"/>
</svg> <path d="M4 18l16 0"/>
</a> </svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
</li>
{% if bookmark_list.search.q %}
<li class="menu-item">
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
bundle from search</a>
</li>
{% endif %}
</ul>
</div>
</div> </div>
<ul class="bundle-menu"> <ul class="bundle-menu">
{% for bundle in bundles.bundles %} {% for bundle in bundles.bundles %}

View File

@@ -3,7 +3,7 @@
<div class="modal-overlay"></div> <div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2> <h2 class="title">{{ details.bookmark.resolved_title }}</h2>
<button class="close" aria-label="Close dialog"> <button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">

View File

@@ -13,7 +13,7 @@
<h1 id="main-heading">Edit bookmark</h1> <h1 id="main-heading">Edit bookmark</h1>
</div> </div>
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
novalidate> novalidate ld-form-submit>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>
</main> </main>

View File

@@ -1,5 +1,6 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load static %} {% load static %}
{% load shared %}
<div class="bookmarks-form"> <div class="bookmarks-form">
{% csrf_token %} {% csrf_token %}
@@ -7,7 +8,7 @@
<div class="form-group {% if form.url.errors %}has-error{% endif %}"> <div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label> <label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
<div class="has-icon-right"> <div class="has-icon-right">
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }} {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
<i class="form-icon loading"></i> <i class="form-icon loading"></i>
</div> </div>
{% if form.url.errors %} {% if form.url.errors %}
@@ -22,8 +23,8 @@
</div> </div>
<div class="form-group" ld-tag-autocomplete> <div class="form-group" ld-tag-autocomplete>
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} {{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint"> <div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). Enter any number of tags separated by space and <strong>without</strong> the hash (#).
If a tag does not exist it will be automatically created. If a tag does not exist it will be automatically created.
</div> </div>
@@ -35,7 +36,8 @@
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label> <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="flex"> <div class="flex">
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button> <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button" <button ld-clear-button data-for="{{ form.title.id_for_label }}"
class="ml-2 btn btn-link suffix-button clear-button"
type="button">Clear type="button">Clear
</button> </button>
</div> </div>
@@ -60,31 +62,31 @@
<span class="form-label d-inline-block">Notes</span> <span class="form-label d-inline-block">Notes</span>
</summary> </summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label> <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }} {{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint"> <div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
Additional notes, supports Markdown. Additional notes, supports Markdown.
</div> </div>
</details> </details>
{{ form.notes.errors }} {{ form.notes.errors }}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox"> <div class="form-checkbox">
{{ form.unread }} {{ form.unread|form_field:"help" }}
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Mark as unread</span> <label for="{{ form.unread.id_for_label }}">Mark as unread</label>
</label> </div>
<div class="form-input-hint"> <div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them. Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div> </div>
</div> </div>
{% if request.user_profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox"> <div class="form-checkbox">
{{ form.shared }} {{ form.shared|form_field:"help" }}
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="{{ form.shared.id_for_label }}">Share</label>
</label> </div>
<div class="form-input-hint"> <div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
{% if request.user_profile.enable_public_sharing %} {% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users. Share this bookmark with other registered users and anonymous users.
{% else %} {% else %}
@@ -100,7 +102,7 @@
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide"> <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn">Nevermind</a> <a href="{{ return_url }}" class="btn">Cancel</a>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
/** /**
@@ -227,6 +229,7 @@
} }
}); });
} }
refreshButton.addEventListener('click', refreshMetadata); refreshButton.addEventListener('click', refreshMetadata);
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark

View File

@@ -12,7 +12,7 @@
<div class="section-header"> <div class="section-header">
<h1 id="main-heading">New bookmark</h1> <h1 id="main-heading">New bookmark</h1>
</div> </div>
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate> <form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>
</main> </main>

View File

@@ -21,6 +21,9 @@
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822"> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %} {% endif %}
{% if request.user_profile.custom_css %}
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %}
</head> </head>
<body> <body>
<template id="content">{{ content|safe }}</template> <template id="content">{{ content|safe }}</template>

View File

@@ -25,15 +25,33 @@
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
application first. Here's how it works:</p> application first. Here's how it works:</p>
<ul> <ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li> <li>Choose your preferred method for detecting website titles and descriptions below (<a href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect" target="_blank">Help</a>)</li>
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li> <li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li> <li>Click the bookmarklet in your browser's toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li> <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li> <li>After saving the bookmark, the linkding window closes, and you are back on your website</li>
</ul> </ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false" <div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
class="btn btn-primary">📎 Add bookmark</a> <p id="detection-method-label">Choose your preferred bookmarklet:</p>
<label for="detection-method-server" class="form-radio">
<input id="detection-method-server" type="radio" name="bookmarklet-type" value="server" checked>
<i class="form-icon"></i>
Detect title and description on the server
</label>
<label for="detection-method-client" class="form-radio">
<input id="detection-method-client" type="radio" name="bookmarklet-type" value="client">
<i class="form-icon"></i>
Detect title and description in the browser
</label>
</div>
<div class="bookmarklet-container">
<a id="bookmarklet-server" href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a>
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}" data-turbo="false"
class="btn btn-primary" style="display: none;">📎 Add bookmark</a>
</div>
</section> </section>
<section aria-labelledby="rest-api-heading"> <section aria-labelledby="rest-api-heading">
@@ -90,4 +108,28 @@
</p> </p>
</section> </section>
</main> </main>
<script>
(function init() {
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
const serverBookmarklet = document.getElementById('bookmarklet-server');
const clientBookmarklet = document.getElementById('bookmarklet-client');
function toggleBookmarklet() {
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
if (selectedValue === 'server') {
serverBookmarklet.style.display = 'inline-block';
clientBookmarklet.style.display = 'none';
} else {
serverBookmarklet.style.display = 'none';
clientBookmarklet.style.display = 'inline-block';
}
}
toggleBookmarklet();
radioButtons.forEach(function(radio) {
radio.addEventListener('change', toggleBookmarklet);
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,6 @@
{% load i18n %}
{# Force rendering validation errors in English language to align with the rest of the app #}
{% language 'en-us' %}
{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
{% endlanguage %}

View File

@@ -145,3 +145,30 @@ def render_markdown(context, markdown_text):
linkified_html = bleach.linkify(sanitized_html) linkified_html = bleach.linkify(sanitized_html)
return mark_safe(linkified_html) return mark_safe(linkified_html)
def append_attr(widget, attr, value):
attrs = widget.attrs
if attrs.get(attr):
attrs[attr] += " " + value
else:
attrs[attr] = value
@register.filter("form_field")
def form_field(field, modifier_string):
modifiers = modifier_string.split(",")
has_errors = hasattr(field, "errors") and field.errors
if "validation" in modifiers and has_errors:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
if "help" in modifiers:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
# Some assistive technologies announce a field as invalid when it has the
# required attribute, even if the user has not interacted with the field
# yet. Set aria-invalid false to prevent this behavior.
if field.field.required and not has_errors:
append_attr(field.field.widget, "aria-invalid", "false")
return field

View File

@@ -236,8 +236,17 @@ class BookmarkFactoryMixin:
def read_asset_file(self, asset: BookmarkAsset): def read_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
with open(filepath, "rb") as f:
return f.read() if asset.gzip:
with gzip.open(filepath, "rb") as f:
return f.read()
else:
with open(filepath, "rb") as f:
return f.read()
def get_asset_filesize(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
return os.path.getsize(filepath) if os.path.exists(filepath) else 0
def has_asset_file(self, asset: BookmarkAsset): def has_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)

View File

@@ -207,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
# verify file name # verify file name
self.assertTrue(saved_file_name.startswith("upload_")) self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.txt")) self.assertTrue(saved_file_name.endswith("_test_file.txt.gz"))
# file should contain the correct content # file should contain the correct content
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file: self.assertEqual(self.read_asset_file(asset), file_content)
self.assertEqual(file.read(), file_content)
# should create asset # should create asset
self.assertIsNotNone(asset.id) self.assertIsNotNone(asset.id)
@@ -221,6 +220,45 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.display_name, upload_file.name) self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE) self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name) self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
@disable_logging
def test_upload_gzip_asset(self):
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(modified=initial_modified)
file_content = gzip.compress(b"<html>test content</html>")
upload_file = SimpleUploadedFile(
"test_file.html.gz", file_content, content_type="application/gzip"
)
asset = assets.upload_asset(bookmark, upload_file)
# should create file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(upload_file)
# verify file name
self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.html.gz"))
# file should contain the correct content
self.assertEqual(self.read_asset_file(asset), file_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "application/gzip")
self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, len(file_content)) self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip) self.assertFalse(asset.gzip)
@@ -245,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(192, len(saved_file)) self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_")) self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt")) self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
@disable_logging @disable_logging
def test_upload_asset_failure(self): def test_upload_asset_failure(self):

View File

@@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from bookmarks.models import BookmarkSearch, UserProfile from bookmarks.models import BookmarkSearch, UserProfile
@@ -319,6 +319,28 @@ class BookmarkArchivedViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
url = reverse("linkding:bookmarks.archived")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_allowed_bulk_actions_with_sharing_enabled(self): def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile user_profile = self.user.profile
user_profile.enable_sharing = True user_profile.enable_sharing = True
@@ -345,6 +367,34 @@ class BookmarkArchivedViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse("linkding:bookmarks.archived")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("linkding:bookmarks.archived")) response = self.client.post(reverse("linkding:bookmarks.archived"))

View File

@@ -4,9 +4,8 @@ from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.tests.helpers import ( from bookmarks.models import BookmarkAsset
BookmarkFactoryMixin, from bookmarks.tests.helpers import BookmarkFactoryMixin
)
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -23,7 +22,21 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setup_asset_with_file(self, bookmark): def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip" filename = f"temp_{bookmark.id}.html.gzip"
self.setup_asset_file(filename) self.setup_asset_file(filename)
asset = self.setup_asset(bookmark=bookmark, file=filename) asset = self.setup_asset(
bookmark=bookmark, file=filename, display_name=f"Snapshot {bookmark.id}"
)
return asset
def setup_asset_with_uploaded_file(self, bookmark):
filename = f"temp_{bookmark.id}.png.gzip"
self.setup_asset_file(filename)
asset = self.setup_asset(
bookmark=bookmark,
file=filename,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type="image/png",
display_name=f"Uploaded file {bookmark.id}.png",
)
return asset return asset
def view_access_test(self, view_name: str): def view_access_test(self, view_name: str):
@@ -127,3 +140,25 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def test_reader_view_access_guest_user(self): def test_reader_view_access_guest_user(self):
self.view_access_guest_user_test("linkding:assets.read") self.view_access_guest_user_test("linkding:assets.read")
def test_snapshot_download_name(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
self.assertEqual(response["Content-Type"], asset.content_type)
self.assertEqual(
response["Content-Disposition"],
f'inline; filename="{asset.display_name}.html"',
)
def test_uploaded_file_download_name(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_uploaded_file(bookmark)
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
self.assertEqual(response["Content-Type"], asset.content_type)
self.assertEqual(
response["Content-Disposition"],
f'inline; filename="{asset.display_name}"',
)

View File

@@ -253,8 +253,8 @@ class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(asset.display_name, file_name) self.assertEqual(asset.display_name, file_name)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD) self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "text/plain") self.assertEqual(asset.content_type, "text/plain")
self.assertEqual(asset.file_size, len(file_content)) self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
self.assertFalse(asset.gzip) self.assertTrue(asset.gzip)
content = self.read_asset_file(asset) content = self.read_asset_file(asset)
self.assertEqual(content, file_content) self.assertEqual(content, file_content)

View File

@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<input type="text" name="url" value="{bookmark.url}" placeholder=" " <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
autofocus class="form-input" required id="id_url"> """,
""",
html, html,
) )
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<input type="text" name="tag_string" value="{tag_string}" <input type="text" name="tag_string" value="{tag_string}"
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string"> autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string" aria-describedby="id_tag_string_help">
""", """,
html, html,
) )
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
f""" f"""
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes"> <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">
{bookmark.notes} {bookmark.notes}
</textarea> </textarea>
""", """,
@@ -259,12 +258,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=0, count=0,
) )
@@ -278,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=1, count=1,
) )

View File

@@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from bookmarks.models import BookmarkSearch, UserProfile from bookmarks.models import BookmarkSearch, UserProfile
@@ -313,6 +313,28 @@ class BookmarkIndexViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
url = reverse("linkding:bookmarks.index")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_allowed_bulk_actions_with_sharing_enabled(self): def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile user_profile = self.user.profile
user_profile.enable_sharing = True user_profile.enable_sharing = True
@@ -339,6 +361,34 @@ class BookmarkIndexViewTestCase(
html, html,
) )
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse("linkding:bookmarks.index")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
<option value="bulk_snapshot">Create HTML snapshot</option>
</select>
""",
html,
)
def test_apply_search_preferences(self): def test_apply_search_preferences(self):
# no params # no params
response = self.client.post(reverse("linkding:bookmarks.index")) response = self.client.post(reverse("linkding:bookmarks.index"))

View File

@@ -78,9 +78,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="text" name="url" value="http://example.com" ' """
'placeholder=" " autofocus class="form-input" required ' <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
'id="id_url">', """,
html, html,
) )
@@ -117,9 +117,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="text" name="tag_string" value="tag1 tag2 tag3" ' """
'class="form-input" autocomplete="off" autocapitalize="off" ' <input type="text" name="tag_string" value="tag1 tag2 tag3"
'id="id_tag_string">', aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
""",
html, html,
) )
@@ -137,8 +138,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
<span class="form-label d-inline-block">Notes</span> <span class="form-label d-inline-block">Notes</span>
</summary> </summary>
<label for="id_notes" class="text-assistive">Notes</label> <label for="id_notes" class="text-assistive">Notes</label>
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea> <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">**Find** more info [here](http://example.com)</textarea>
<div class="form-input-hint"> <div id="id_notes_help" class="form-input-hint">
Additional notes, supports Markdown. Additional notes, supports Markdown.
</div> </div>
</details> </details>
@@ -196,12 +197,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=0, count=0,
) )
@@ -213,12 +214,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML( self.assertInHTML(
""" """
<label for="id_shared" class="form-checkbox"> <div class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared"> <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="id_shared">Share</label>
</label> </div>
""", """,
html, html,
count=1, count=1,
) )
@@ -231,10 +232,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
""" """
<div class="form-input-hint"> <div id="id_shared_help" class="form-input-hint">
Share this bookmark with other registered users. Share this bookmark with other registered users.
</div> </div>
""", """,
html, html,
) )
@@ -245,10 +246,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
""" """
<div class="form-input-hint"> <div id="id_shared_help" class="form-input-hint">
Share this bookmark with other registered users and anonymous users. Share this bookmark with other registered users and anonymous users.
</div> </div>
""", """,
html, html,
) )
@@ -265,7 +266,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="checkbox" name="unread" id="id_unread">', '<input type="checkbox" name="unread" id="id_unread" aria-describedby="id_unread_help">',
html, html,
) )
@@ -277,6 +278,6 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="checkbox" name="unread" id="id_unread" checked="">', '<input type="checkbox" name="unread" id="id_unread" checked="" aria-describedby="id_unread_help">',
html, html,
) )

View File

@@ -22,6 +22,7 @@ from bookmarks.services.bookmarks import (
unshare_bookmarks, unshare_bookmarks,
enhance_with_website_metadata, enhance_with_website_metadata,
refresh_bookmarks_metadata, refresh_bookmarks_metadata,
create_html_snapshots,
) )
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -974,3 +975,73 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 3) self.assertEqual(self.mock_schedule_refresh_metadata.call_count, 3)
self.assertEqual(self.mock_load_preview_image.call_count, 3) self.assertEqual(self.mock_load_preview_image.call_count, 3)
def test_create_html_snapshots(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
create_html_snapshots(
[bookmark1.id, bookmark2.id, bookmark3.id],
self.get_or_create_test_user(),
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(
bookmark_ids, [bookmark1.id, bookmark2.id, bookmark3.id]
)
def test_create_html_snapshots_should_only_create_for_specified_bookmarks(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
create_html_snapshots(
[bookmark1.id, bookmark3.id], self.get_or_create_test_user()
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(bookmark_ids, [bookmark1.id, bookmark3.id])
self.assertNotIn(bookmark2.id, bookmark_ids)
def test_create_html_snapshots_should_only_create_for_user_owned_bookmarks(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
other_user = self.setup_user()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
create_html_snapshots(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(bookmark_ids, [bookmark1.id, bookmark2.id])
self.assertNotIn(inaccessible_bookmark.id, bookmark_ids)
def test_create_html_snapshots_should_accept_mix_of_int_and_string_ids(self):
with patch.object(tasks, "create_html_snapshots") as mock_create_html_snapshots:
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
create_html_snapshots(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
mock_create_html_snapshots.assert_called_once()
call_args = mock_create_html_snapshots.call_args[0][0]
bookmark_ids = list(call_args.values_list("id", flat=True))
self.assertCountEqual(
bookmark_ids, [bookmark1.id, bookmark2.id, bookmark3.id]
)

View File

@@ -269,6 +269,24 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists()) self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
def test_delete_bundle_updates_order(self):
self.authenticate()
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
bundle2 = self.setup_bundle(name="Bundle 2", order=1)
bundle3 = self.setup_bundle(name="Bundle 3", order=2)
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle2.id})
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertFalse(BookmarkBundle.objects.filter(id=bundle2.id).exists())
# Check that the remaining bundles have updated orders
bundle1.refresh_from_db()
bundle3.refresh_from_db()
self.assertEqual(bundle1.order, 0)
self.assertEqual(bundle3.order, 1)
def test_delete_bundle_only_allows_own_bundles(self): def test_delete_bundle_only_allows_own_bundles(self):
self.authenticate() self.authenticate()

View File

@@ -120,3 +120,41 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_should_show_correct_preview(self):
bundle_tag = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[bundle_tag])
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
bundle = self.setup_bundle(name="Test Bundle", all_tags=bundle_tag.name)
response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)
def test_should_show_correct_preview_after_posting_invalid_data(self):
initial_tag = self.setup_tag(name="initial-tag")
updated_tag = self.setup_tag(name="updated-tag")
bookmark1 = self.setup_bookmark(tags=[initial_tag])
bookmark2 = self.setup_bookmark(tags=[updated_tag])
bookmark3 = self.setup_bookmark()
bundle = self.setup_bundle(name="Test Bundle", all_tags=initial_tag.name)
form_data = {
"name": "",
"search": "",
"any_tags": "",
"all_tags": updated_tag.name,
"excluded_tags": "",
}
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), form_data
)
self.assertIn(
"Found 1 bookmarks matching this bundle", response.content.decode()
)
self.assertNotIn(bookmark1.title, response.content.decode())
self.assertIn(bookmark2.title, response.content.decode())
self.assertNotIn(bookmark3.title, response.content.decode())

View File

@@ -100,6 +100,18 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists()) self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
def test_remove_bundle_updates_order(self):
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
bundle2 = self.setup_bundle(name="Bundle 2", order=1)
bundle3 = self.setup_bundle(name="Bundle 3", order=2)
self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(bundle2.id)},
)
self.assertBundleOrder([bundle1, bundle3])
def test_remove_other_user_bundle(self): def test_remove_other_user_bundle(self):
other_user = self.setup_user(name="otheruser") other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user) other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)

View File

@@ -1,11 +1,12 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from urllib.parse import urlencode
from bookmarks.models import BookmarkBundle from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin): class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -75,3 +76,72 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = self.create_form_data({"name": ""}) form_data = self.create_form_data({"name": ""})
response = self.client.post(reverse("linkding:bundles.new"), form_data) response = self.client.post(reverse("linkding:bundles.new"), form_data)
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
def test_should_prefill_form_from_search_query_parameters(self):
query = "machine learning #python #ai"
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "machine learning")
self.assertEqual(all_tags_field.get("value"), "python ai")
def test_should_ignore_special_search_commands(self):
query = "python tutorial !untagged !unread"
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "python tutorial")
self.assertIsNone(all_tags_field.get("value"))
def test_should_not_prefill_when_no_query_parameter(self):
response = self.client.get(reverse("linkding:bundles.new"))
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertIsNone(search_field.get("value"))
self.assertIsNone(all_tags_field.get("value"))
def test_should_not_prefill_when_editing_existing_bundle(self):
bundle = self.setup_bundle(
name="Existing Bundle", search="Tutorial", all_tags="java spring"
)
query = "machine learning #python #ai"
url = (
reverse("linkding:bundles.edit", args=[bundle.id])
+ "?"
+ urlencode({"q": query})
)
response = self.client.get(url)
soup = self.make_soup(response.content.decode())
search_field = soup.select_one('input[name="search"]')
all_tags_field = soup.select_one('input[name="all_tags"]')
self.assertEqual(search_field.get("value"), "Tutorial")
self.assertEqual(all_tags_field.get("value"), "java spring")
def test_should_show_correct_preview_with_prefilled_values(self):
bundle_tag = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[bundle_tag])
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
query = "#" + bundle_tag.name
url = reverse("linkding:bundles.new") + "?" + urlencode({"q": query})
response = self.client.get(url)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)

View File

@@ -357,3 +357,50 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
def test_sanitize_with_none_text(self): def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None)) self.assertEqual("", sanitize(None))
def test_with_bundle(self):
tag1 = self.setup_tag()
visible_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(tags=[tag1]),
]
self.setup_bookmark(),
self.setup_bookmark(),
bundle = self.setup_bundle(all_tags=tag1.name)
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key])
+ f"?bundle={bundle.id}"
)
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, visible_bookmarks)
def test_with_bundle_not_owned_by_user(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
other_bundle = self.setup_bundle(user=other_user, search="test")
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key])
+ f"?bundle={other_bundle.id}"
)
self.assertEqual(response.status_code, 404)
def test_with_invalid_bundle_id(self):
self.setup_bookmark(title="test bookmark")
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key]) + "?bundle=999999"
)
self.assertEqual(response.status_code, 404)
def test_with_non_numeric_bundle_id(self):
self.setup_bookmark(title="test bookmark")
response = self.client.get(
reverse("linkding:feeds.all", args=[self.token.key]) + "?bundle=invalid"
)
self.assertEqual(response.status_code, 404)

View File

@@ -366,6 +366,32 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertListEqual(tag_names, ["tag-1", "tag-2", "tag-3"]) self.assertListEqual(tag_names, ["tag-1", "tag-2", "tag-3"])
def test_ignore_long_tag_names(self):
long_tag = "a" * 65
valid_tag = "valid-tag"
test_html = self.render_html(
tags_html=f"""
<DT><A HREF="https://example.com" TAGS="{long_tag}, {valid_tag}">Example.com</A>
<DD>Example.com
"""
)
result = import_netscape_html(test_html, self.get_or_create_test_user())
# Import should succeed
self.assertEqual(result.success, 1)
self.assertEqual(result.failed, 0)
# Only the valid tag should be created
tags = Tag.objects.all()
self.assertEqual(len(tags), 1)
self.assertEqual(tags[0].name, valid_tag)
# Bookmark should only have the valid tag assigned
bookmark = Bookmark.objects.get(url="https://example.com")
bookmark_tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertEqual(bookmark_tag_names, [valid_tag])
@disable_logging @disable_logging
def test_validate_empty_or_missing_bookmark_url(self): def test_validate_empty_or_missing_bookmark_url(self):
test_html = self.render_html( test_html = self.render_html(

View File

@@ -1,3 +1,4 @@
import datetime
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
@@ -29,9 +30,6 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response["content-type"], "text/plain; charset=UTF-8") self.assertEqual(response["content-type"], "text/plain; charset=UTF-8")
self.assertEqual(
response["Content-Disposition"], 'attachment; filename="bookmarks.html"'
)
for bookmark in Bookmark.objects.all(): for bookmark in Bookmark.objects.all():
self.assertContains(response, bookmark.url) self.assertContains(response, bookmark.url)
@@ -78,3 +76,18 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertFormErrorHint( self.assertFormErrorHint(
response, "An error occurred during bookmark export." response, "An error occurred during bookmark export."
) )
def test_filename_includes_date_and_time(self):
self.setup_bookmark()
# Mock timezone.now to return a fixed datetime for predictable filename
fixed_time = datetime.datetime(
2023, 5, 15, 14, 30, 45, tzinfo=datetime.timezone.utc
)
with patch("bookmarks.views.settings.timezone.now", return_value=fixed_time):
response = self.client.get(reverse("linkding:settings.export"), follow=True)
self.assertEqual(response.status_code, 200)
expected_filename = 'attachment; filename="bookmarks_2023-05-15_14-30-45.html"'
self.assertEqual(response["Content-Disposition"], expected_filename)

View File

@@ -41,6 +41,11 @@ class TagTestCase(TestCase):
parse_tag_string("book,,movie,,,album"), ["album", "book", "movie"] parse_tag_string("book,,movie,,,album"), ["album", "book", "movie"]
) )
def test_parse_tag_string_handles_duplicate_separators_with_spaces(self):
self.assertCountEqual(
parse_tag_string("book, ,movie, , ,album"), ["album", "book", "movie"]
)
def test_parse_tag_string_replaces_whitespace_within_names(self): def test_parse_tag_string_replaces_whitespace_within_names(self):
self.assertCountEqual( self.assertCountEqual(
parse_tag_string("travel guide, book recommendations"), parse_tag_string("travel guide, book recommendations"),

View File

@@ -124,7 +124,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Cancel edit, verify return to details url # Cancel edit, verify return to details url
details_url = url + f"&details={bookmark.id}" details_url = url + f"&details={bookmark.id}"
with self.page.expect_navigation(url=self.live_server_url + details_url): with self.page.expect_navigation(url=self.live_server_url + details_url):
self.page.get_by_text("Nevermind").click() self.page.get_by_text("Cancel").click()
def test_delete(self): def test_delete(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()

View File

@@ -4,8 +4,9 @@ from urllib.parse import quote
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import sync_playwright, expect
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.models import Bookmark
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
mock_website_metadata = website_loader.WebsiteMetadata( mock_website_metadata = website_loader.WebsiteMetadata(
url="https://example.com", url="https://example.com",
@@ -311,3 +312,26 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
# Verify that the fields are NOT visually marked as modified # Verify that the fields are NOT visually marked as modified
expect(title_field).to_have_class("form-input") expect(title_field).to_have_class("form-input")
expect(description_field).to_have_class("form-input") expect(description_field).to_have_class("form-input")
def test_ctrl_enter_submits_form_from_description(self):
with sync_playwright() as p:
page = self.open(reverse("linkding:bookmarks.new"), p)
url_field = page.get_by_label("URL")
description_field = page.get_by_label("Description")
url_field.fill("https://example.com")
description_field.fill("Test description")
description_field.focus()
# Press Ctrl+Enter to submit form
description_field.press("Control+Enter")
# Should navigate away from new bookmark page after successful submission
expect(page).not_to_have_url(
self.live_server_url + reverse("linkding:bookmarks.new")
)
self.assertEqual(1, Bookmark.objects.count())
bookmark = Bookmark.objects.first()
self.assertEqual("https://example.com", bookmark.url)
self.assertEqual("Example Domain", bookmark.title)

View File

@@ -32,10 +32,14 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
raise Http404("Bookmark does not exist") raise Http404("Bookmark does not exist")
def bundle_read(request: HttpRequest, bundle_id: int | str):
return bundle_write(request, bundle_id)
def bundle_write(request: HttpRequest, bundle_id: int | str): def bundle_write(request: HttpRequest, bundle_id: int | str):
try: try:
return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user) return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
except BookmarkBundle.DoesNotExist: except (BookmarkBundle.DoesNotExist, ValueError):
raise Http404("Bundle does not exist") raise Http404("Bundle does not exist")

View File

@@ -31,7 +31,9 @@ def view(request, asset_id: int):
asset = access.asset_read(request, asset_id) asset = access.asset_read(request, asset_id)
content = _get_asset_content(asset) content = _get_asset_content(asset)
return HttpResponse(content, content_type=asset.content_type) response = HttpResponse(content, content_type=asset.content_type)
response["Content-Disposition"] = f'inline; filename="{asset.download_name}"'
return response
def read(request, asset_id: int): def read(request, asset_id: int):

View File

@@ -31,6 +31,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
refresh_bookmarks_metadata, refresh_bookmarks_metadata,
create_html_snapshots,
) )
from bookmarks.type_defs import HttpRequest from bookmarks.type_defs import HttpRequest
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
@@ -368,6 +369,8 @@ def handle_action(request: HttpRequest, query: QuerySet[Bookmark] = None):
return unshare_bookmarks(bookmark_ids, request.user) return unshare_bookmarks(bookmark_ids, request.user)
if "bulk_refresh" == bulk_action: if "bulk_refresh" == bulk_action:
return refresh_bookmarks_metadata(bookmark_ids, request.user) return refresh_bookmarks_metadata(bookmark_ids, request.user)
if "bulk_snapshot" == bulk_action:
return create_html_snapshots(bookmark_ids, request.user)
@login_required @login_required

View File

@@ -1,11 +1,12 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Max
from django.http import HttpRequest, HttpResponseRedirect from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
from bookmarks.queries import parse_query_string
from bookmarks.services import bundles
from bookmarks.views import access from bookmarks.views import access
from bookmarks.views.contexts import ActiveBookmarkListContext from bookmarks.views.contexts import ActiveBookmarkListContext
@@ -23,50 +24,53 @@ def action(request: HttpRequest):
remove_bundle_id = request.POST.get("remove_bundle") remove_bundle_id = request.POST.get("remove_bundle")
bundle = access.bundle_write(request, remove_bundle_id) bundle = access.bundle_write(request, remove_bundle_id)
bundle_name = bundle.name bundle_name = bundle.name
bundle.delete() bundles.delete_bundle(bundle)
messages.success(request, f"Bundle '{bundle_name}' removed successfully.") messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
elif "move_bundle" in request.POST: elif "move_bundle" in request.POST:
bundle_id = request.POST.get("move_bundle") bundle_id = request.POST.get("move_bundle")
move_position = int(request.POST.get("move_position"))
bundle_to_move = access.bundle_write(request, bundle_id) bundle_to_move = access.bundle_write(request, bundle_id)
user_bundles = list( move_position = int(request.POST.get("move_position"))
BookmarkBundle.objects.filter(owner=request.user).order_by("order") bundles.move_bundle(bundle_to_move, move_position)
)
if move_position != user_bundles.index(bundle_to_move):
user_bundles.remove(bundle_to_move)
user_bundles.insert(move_position, bundle_to_move)
for bundle_index, bundle in enumerate(user_bundles):
bundle.order = bundle_index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
return HttpResponseRedirect(reverse("linkding:bundles.index")) return HttpResponseRedirect(reverse("linkding:bundles.index"))
def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None): def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
form_data = request.POST if request.method == "POST" else None form_data = request.POST if request.method == "POST" else None
form = BookmarkBundleForm(form_data, instance=bundle) initial_data = {}
if bundle is None and request.method == "GET":
query_param = request.GET.get("q")
if query_param:
parsed = parse_query_string(query_param)
if parsed["search_terms"]:
initial_data["search"] = " ".join(parsed["search_terms"])
if parsed["tag_names"]:
initial_data["all_tags"] = " ".join(parsed["tag_names"])
form = BookmarkBundleForm(form_data, instance=bundle, initial=initial_data)
if request.method == "POST": if request.method == "POST":
if form.is_valid(): if form.is_valid():
instance = form.save(commit=False) instance = form.save(commit=False)
instance.owner = request.user
if bundle is None: # New bundle if bundle is None:
max_order_result = BookmarkBundle.objects.filter( instance.order = None
owner=request.user bundles.create_bundle(instance, request.user)
).aggregate(Max("order", default=-1)) else:
instance.order = max_order_result["order__max"] + 1 instance.save()
instance.save()
messages.success(request, "Bundle saved successfully.") messages.success(request, "Bundle saved successfully.")
return HttpResponseRedirect(reverse("linkding:bundles.index")) return HttpResponseRedirect(reverse("linkding:bundles.index"))
status = 422 if request.method == "POST" and not form.is_valid() else 200 status = 422 if request.method == "POST" and not form.is_valid() else 200
bookmark_list = _get_bookmark_list_preview(request, bundle) bookmark_list = _get_bookmark_list_preview(request, bundle, initial_data)
context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list} context = {
"form": form,
"bundle": bundle,
"bookmark_list": bookmark_list,
}
return render(request, template, context, status=status) return render(request, template, context, status=status)
@@ -91,7 +95,9 @@ def preview(request: HttpRequest):
def _get_bookmark_list_preview( def _get_bookmark_list_preview(
request: HttpRequest, bundle: BookmarkBundle | None = None request: HttpRequest,
bundle: BookmarkBundle | None = None,
initial_data: dict = None,
): ):
if request.method == "GET" and bundle: if request.method == "GET" and bundle:
preview_bundle = bundle preview_bundle = bundle
@@ -99,6 +105,10 @@ def _get_bookmark_list_preview(
form_data = ( form_data = (
request.POST.copy() if request.method == "POST" else request.GET.copy() request.POST.copy() if request.method == "POST" else request.GET.copy()
) )
if initial_data:
for key, value in initial_data.items():
form_data[key] = value
form_data["name"] = "Preview Bundle" # Set dummy name for form validation form_data["name"] = "Preview Bundle" # Set dummy name for form validation
form = BookmarkBundleForm(form_data) form = BookmarkBundleForm(form_data)
preview_bundle = form.save(commit=False) preview_bundle = form.save(commit=False)

View File

@@ -219,6 +219,7 @@ class BookmarkListContext:
self.show_notes = user_profile.permanent_notes self.show_notes = user_profile.permanent_notes
self.collapse_side_panel = user_profile.collapse_side_panel self.collapse_side_panel = user_profile.collapse_side_panel
self.is_preview = False self.is_preview = False
self.snapshot_feature_enabled = settings.LD_ENABLE_SNAPSHOTS
@staticmethod @staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None): def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):

View File

@@ -11,6 +11,7 @@ from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from bookmarks.models import ( from bookmarks.models import (
@@ -239,8 +240,12 @@ def bookmark_export(request: HttpRequest):
prefetch_related_objects(bookmarks, "tags") prefetch_related_objects(bookmarks, "tags")
file_content = exporter.export_netscape_html(list(bookmarks)) file_content = exporter.export_netscape_html(list(bookmarks))
# Generate filename with current date and time
current_time = timezone.now()
filename = current_time.strftime("bookmarks_%Y-%m-%d_%H-%M-%S.html")
response = HttpResponse(content_type="text/plain; charset=UTF-8") response = HttpResponse(content_type="text/plain; charset=UTF-8")
response["Content-Disposition"] = 'attachment; filename="bookmarks.html"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
response.write(file_content) response.write(file_content)
return response return response

89
docs/package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.3", "@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.34.3", "@astrojs/starlight": "^0.34.3",
"astro": "^5.7.13", "astro": "^5.12.8",
"sharp": "^0.32.5", "sharp": "^0.32.5",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }
@@ -34,9 +34,9 @@
} }
}, },
"node_modules/@astrojs/compiler": { "node_modules/@astrojs/compiler": {
"version": "2.12.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.0.tgz", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz",
"integrity": "sha512-7bCjW6tVDpUurQLeKBUN9tZ5kSv5qYrGmcn0sG0IwacL7isR2ZbyyA3AdZ4uxsuUFOS2SlgReTH7wkxO6zpqWA==", "integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@astrojs/internal-helpers": { "node_modules/@astrojs/internal-helpers": {
@@ -204,9 +204,9 @@
} }
}, },
"node_modules/@astrojs/telemetry": { "node_modules/@astrojs/telemetry": {
"version": "3.2.1", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.2.1.tgz", "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
"integrity": "sha512-SSVM820Jqc6wjsn7qYfV9qfeQvePtVc1nSofhyap7l0/iakUKywj3hfy3UJAOV4sGV4Q/u450RD4AaCaFvNPlg==", "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ci-info": "^4.2.0", "ci-info": "^4.2.0",
@@ -218,7 +218,7 @@
"which-pm-runs": "^1.1.0" "which-pm-runs": "^1.1.0"
}, },
"engines": { "engines": {
"node": "^18.17.1 || ^20.3.0 || >=22.0.0" "node": "18.20.8 || ^20.3.0 || >=22.0.0"
} }
}, },
"node_modules/@astrojs/yaml2ts": { "node_modules/@astrojs/yaml2ts": {
@@ -2047,15 +2047,15 @@
} }
}, },
"node_modules/astro": { "node_modules/astro": {
"version": "5.7.13", "version": "5.12.8",
"resolved": "https://registry.npmjs.org/astro/-/astro-5.7.13.tgz", "resolved": "https://registry.npmjs.org/astro/-/astro-5.12.8.tgz",
"integrity": "sha512-cRGq2llKOhV3XMcYwQpfBIUcssN6HEK5CRbcMxAfd9OcFhvWE7KUy50zLioAZVVl3AqgUTJoNTlmZfD2eG0G1w==", "integrity": "sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@astrojs/compiler": "^2.11.0", "@astrojs/compiler": "^2.12.2",
"@astrojs/internal-helpers": "0.6.1", "@astrojs/internal-helpers": "0.7.1",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.5",
"@astrojs/telemetry": "3.2.1", "@astrojs/telemetry": "3.3.0",
"@capsizecss/unpack": "^2.4.0", "@capsizecss/unpack": "^2.4.0",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@rollup/pluginutils": "^5.1.4", "@rollup/pluginutils": "^5.1.4",
@@ -2082,6 +2082,7 @@
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"html-escaper": "3.0.3", "html-escaper": "3.0.3",
"http-cache-semantics": "^4.1.1", "http-cache-semantics": "^4.1.1",
"import-meta-resolve": "^4.1.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
@@ -2096,6 +2097,7 @@
"rehype": "^13.0.2", "rehype": "^13.0.2",
"semver": "^7.7.1", "semver": "^7.7.1",
"shiki": "^3.2.1", "shiki": "^3.2.1",
"smol-toml": "^1.3.4",
"tinyexec": "^0.3.2", "tinyexec": "^0.3.2",
"tinyglobby": "^0.2.12", "tinyglobby": "^0.2.12",
"tsconfck": "^3.1.5", "tsconfck": "^3.1.5",
@@ -2109,7 +2111,7 @@
"xxhash-wasm": "^1.1.0", "xxhash-wasm": "^1.1.0",
"yargs-parser": "^21.1.1", "yargs-parser": "^21.1.1",
"yocto-spinner": "^0.2.1", "yocto-spinner": "^0.2.1",
"zod": "^3.24.2", "zod": "^3.24.4",
"zod-to-json-schema": "^3.24.5", "zod-to-json-schema": "^3.24.5",
"zod-to-ts": "^1.2.0" "zod-to-ts": "^1.2.0"
}, },
@@ -2117,7 +2119,7 @@
"astro": "astro.js" "astro": "astro.js"
}, },
"engines": { "engines": {
"node": "^18.17.1 || ^20.3.0 || >=22.0.0", "node": "18.20.8 || ^20.3.0 || >=22.0.0",
"npm": ">=9.6.5", "npm": ">=9.6.5",
"pnpm": ">=7.1.0" "pnpm": ">=7.1.0"
}, },
@@ -2141,6 +2143,53 @@
"astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0"
} }
}, },
"node_modules/astro/node_modules/@astrojs/internal-helpers": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz",
"integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==",
"license": "MIT"
},
"node_modules/astro/node_modules/@astrojs/markdown-remark": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.5.tgz",
"integrity": "sha512-MiR92CkE2BcyWf3b86cBBw/1dKiOH0qhLgXH2OXA6cScrrmmks1Rr4Tl0p/lFpvmgQQrP54Pd1uidJfmxGrpWQ==",
"license": "MIT",
"dependencies": {
"@astrojs/internal-helpers": "0.7.1",
"@astrojs/prism": "3.3.0",
"github-slugger": "^2.0.0",
"hast-util-from-html": "^2.0.3",
"hast-util-to-text": "^4.0.2",
"import-meta-resolve": "^4.1.0",
"js-yaml": "^4.1.0",
"mdast-util-definitions": "^6.0.0",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2",
"shiki": "^3.2.1",
"smol-toml": "^1.3.4",
"unified": "^11.0.5",
"unist-util-remove-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"unist-util-visit-parents": "^6.0.1",
"vfile": "^6.0.3"
}
},
"node_modules/astro/node_modules/@astrojs/prism": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz",
"integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==",
"license": "MIT",
"dependencies": {
"prismjs": "^1.30.0"
},
"engines": {
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
"node_modules/astro/node_modules/picomatch": { "node_modules/astro/node_modules/picomatch": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
@@ -2548,9 +2597,9 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
"integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",

View File

@@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.3", "@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.34.3", "@astrojs/starlight": "^0.34.3",
"astro": "^5.7.13", "astro": "^5.12.8",
"sharp": "^0.32.5", "sharp": "^0.32.5",
"typescript": "^5.6.2" "typescript": "^5.6.2"
} }

View File

@@ -21,6 +21,7 @@ This section lists community projects around using linkding, in alphabetical ord
- [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) - [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)
- [linkding-healthcheck](https://github.com/sebw/linkding-healthcheck) A Go application that checks the health of your bookmarks and add a tag on dead and problematic URLs. By [sebw](https://github.com/sebw) - [linkding-healthcheck](https://github.com/sebw/linkding-healthcheck) A Go application that checks the health of your bookmarks and add a tag on dead and problematic URLs. By [sebw](https://github.com/sebw)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold) - [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [linkding-media-archiver](https://github.com/proog/linkding-media-archiver) Automatically downloads media files for your bookmarks with yt-dlp and makes them available within Linkding. By [proog](https://github.com/proog)
- [linkding-reminder](https://github.com/sebw/linkding-reminder) A Python application that will send an email reminder for links with a specific tag. By [sebw](https://github.com/sebw) - [linkding-reminder](https://github.com/sebw/linkding-reminder) A Python application that will send an email reminder for links with a specific tag. By [sebw](https://github.com/sebw)
- [linkding-rs](https://github.com/zbrox/linkding-rs) A Rust client library to interact with the linkding REST API with cross platform support to be easily used in Android or iOS apps. By [zbrox](https://github.com/zbrox) - [linkding-rs](https://github.com/zbrox/linkding-rs) A Rust client library to interact with the linkding REST API with cross platform support to be easily used in Android or iOS apps. By [zbrox](https://github.com/zbrox)
- [Linkdy](https://github.com/JGeek00/linkdy): An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). By [JGeek00](https://github.com/JGeek00). - [Linkdy](https://github.com/JGeek00/linkdy): An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). By [JGeek00](https://github.com/JGeek00).
@@ -29,5 +30,6 @@ This section lists community projects around using linkding, in alphabetical ord
- [linktiles](https://github.com/haondt/linktiles) A web app that displays your links as tiles in a configurable mosaic. By [haondt](https://github.com/haondt) - [linktiles](https://github.com/haondt/linktiles) A web app that displays your links as tiles in a configurable mosaic. By [haondt](https://github.com/haondt)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy) - [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti) - [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
- [Pocket2Linkding](https://github.com/hkclark/Pocket2Linkding/) A tool to migrate from Mozilla Pocket to lingding. Preserves the date the link was added to pocket and any tags.
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman) - [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
- [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen) - [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen)

View File

@@ -10,6 +10,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
The following services provide fully managed hosting for linkding, including automatic updates and backups: The following services provide fully managed hosting for linkding, including automatic updates and backups:
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](/acknowledgements#pikapods)) - [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](/acknowledgements#pikapods))
- [CloudBreak](https://cloudbreak.app/products/linkding/?utm_medium=referral&utm_source=linkding-docs&utm_content=managed-hosting&rby=linkding-docs-managed-hosting) - Managed hosting for linkding, US regions available.
## Self-Managed ## Self-Managed

View File

@@ -13,11 +13,11 @@ To fix this, check the [reverse proxy setup documentation](/installation#reverse
## Automatically detected title and description are incorrect ## Automatically detected title and description are incorrect
linkding automatically fetches the title and description of the web page from the metadata in the HTML `<head>`. This happens on the server, which can return different results than what you see in your browser, for example if a website uses JavaScript to dynamically change the title or description, or if a website requires login. linkding automatically fetches the title and description of the web page from the metadata in the HTML `<head>`. By default, this happens on the server, which can return different results than what you see in your browser, for example, if a website uses JavaScript to dynamically change the title or description, or if a website requires login. Alternatively, both the browser extension and the bookmarklet can use the metadata directly from the page you are currently viewing in your browser. Note that for some websites this can give worse results, as not all websites correctly update the metadata in `<head>` while browsing the website (which is why fetching a fresh page on the server is still the default).
When using the linkding browser extension, you can enable the *Use browser metadata* option to use the title and description that your browser sees. This will override the server-fetched metadata. Note that for some websites this can give worse results, as not all websites correctly update the metadata in `<head>` while browsing the website (which is why fetching a fresh page on the server is still the default). To use the title and description that you see in your browser:
- When using the linkding browser extension, enable the *Use browser metadata* option in the options of the extension.
The bookmarklet currently does not have such an option. - When adding the bookmarklet, the respective settings page allows you to choose whether to detect title and description from the server or in the browser.
## Archiving fails for certain websites ## Archiving fails for certain websites

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.41.0", "version": "1.42.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -12,9 +12,9 @@ click==8.1.7
# via black # via black
coverage==7.6.1 coverage==7.6.1
# via -r requirements.dev.in # via -r requirements.dev.in
django==5.1.10 django==5.2.3
# via django-debug-toolbar # via django-debug-toolbar
django-debug-toolbar==4.4.6 django-debug-toolbar==5.2.0
# via -r requirements.dev.in # via -r requirements.dev.in
execnet==2.1.1 execnet==2.1.1
# via pytest-xdist # via pytest-xdist

View File

@@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.12
# by the following command: # by the following command:
# #
# pip-compile requirements.in # pip-compile
# #
asgiref==3.8.1 asgiref==3.8.1
# via django # via django
@@ -27,7 +27,7 @@ cryptography==43.0.1
# josepy # josepy
# mozilla-django-oidc # mozilla-django-oidc
# pyopenssl # pyopenssl
django==5.1.10 django==5.2.3
# via # via
# -r requirements.in # -r requirements.in
# django-registration # django-registration

View File

@@ -1 +1 @@
1.41.0 1.42.0