mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-11 20:24:41 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04248a7fba | ||
|
|
0ff36a94fe | ||
|
|
f83eb25569 | ||
|
|
c746afcf76 | ||
|
|
aaa0f6e119 | ||
|
|
cd215a9237 | ||
|
|
1e56b0e6f3 | ||
|
|
5cc8c9c010 | ||
|
|
846808d870 | ||
|
|
6d9a694756 | ||
|
|
de38e56b3f | ||
|
|
c6fb695af2 | ||
|
|
93faf70b37 | ||
|
|
5330252db9 | ||
|
|
ef00d289f5 | ||
|
|
4e8318d0ae | ||
|
|
a8623d11ef | ||
|
|
8cd992ca30 | ||
|
|
68c104ba54 | ||
|
|
7a4236d179 | ||
|
|
e87304501f | ||
|
|
809e9e02f3 |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
bookmarks/services/bundles.py
Normal file
37
bookmarks/services/bundles.py
Normal 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"])
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal file
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal 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);
|
||||||
|
})();
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
6
bookmarks/templates/shared/error_list.html
Normal file
6
bookmarks/templates/shared/error_list.html
Normal 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 %}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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}"',
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
89
docs/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.41.0
|
1.42.0
|
||||||
|
|||||||
Reference in New Issue
Block a user