mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-01 23:43:14 +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
|
||||
|
||||
## 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)
|
||||
|
||||
### 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.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
|
||||
|
||||
|
||||
@@ -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):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
|
||||
@@ -26,7 +26,7 @@ from bookmarks.models import (
|
||||
User,
|
||||
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.views import access
|
||||
|
||||
@@ -199,13 +199,10 @@ class BookmarkAssetViewSet(
|
||||
if asset.gzip
|
||||
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["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
||||
response["Content-Disposition"] = (
|
||||
f'attachment; filename="{asset.download_name}"'
|
||||
)
|
||||
return response
|
||||
except FileNotFoundError:
|
||||
raise Http404("Asset file does not exist")
|
||||
@@ -290,6 +287,9 @@ class BookmarkBundleViewSet(
|
||||
def get_serializer_context(self):
|
||||
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>/
|
||||
# Instead create separate routers for each view set and manually register them in urls.py
|
||||
|
||||
@@ -11,7 +11,7 @@ from bookmarks.models import (
|
||||
UserProfile,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services import bookmarks
|
||||
from bookmarks.services import bookmarks, bundles
|
||||
from bookmarks.services.tags import get_or_create_tag
|
||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||
from bookmarks.utils import app_version
|
||||
@@ -55,17 +55,9 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
# Set owner to the authenticated user
|
||||
validated_data["owner"] = 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)
|
||||
bundle = BookmarkBundle(**validated_data)
|
||||
bundle.order = validated_data["order"] if "order" in validated_data else None
|
||||
return bundles.create_bundle(bundle, self.context["user"])
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
from bookmarks.views import access
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,10 +31,16 @@ def sanitize(text: str):
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str | 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(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
bundle=bundle,
|
||||
)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.forms.utils import ErrorList
|
||||
|
||||
from bookmarks.models import Bookmark, build_tag_string
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
@@ -6,6 +7,10 @@ from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
|
||||
|
||||
class CustomErrorList(ErrorList):
|
||||
template_name = "shared/error_list.html"
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
@@ -48,7 +53,9 @@ class BookmarkForm(forms.ModelForm):
|
||||
if instance is not None and request.method == "GET":
|
||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||
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
|
||||
def is_auto_close(self):
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
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 {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -51,5 +73,6 @@ class UploadButton extends Behavior {
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form-submit", FormSubmit);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
|
||||
@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let ariaDescribedBy;
|
||||
export let variant = 'default';
|
||||
|
||||
let isFocus = false;
|
||||
@@ -110,6 +111,7 @@
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
aria-describedby="{ariaDescribedBy}"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
# 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
|
||||
names = unique(names, 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)
|
||||
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):
|
||||
if self.file:
|
||||
try:
|
||||
|
||||
@@ -94,13 +94,28 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
||||
gzip=False,
|
||||
)
|
||||
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)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
|
||||
# automatically gzip the file if it is not already gzipped
|
||||
if upload_file.content_type != "application/gzip":
|
||||
filename = _generate_asset_filename(
|
||||
asset, name, extension.lstrip(".") + ".gz"
|
||||
)
|
||||
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.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)
|
||||
|
||||
|
||||
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):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
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 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)
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name, owner=user)
|
||||
|
||||
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
.title {
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.section-header {
|
||||
.section-header:not(.no-wrap) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,12 +224,13 @@ textarea.form-input {
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
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 {
|
||||
outline: var(--focus-outline);
|
||||
@@ -243,9 +244,9 @@ textarea.form-input {
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
pointer-events: none;
|
||||
border: var(--border-width) solid var(--checkbox-border-color);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
var bookmarkUrl = window.location;
|
||||
var applicationUrl = '{{ application_url }}';
|
||||
const bookmarkUrl = window.location;
|
||||
|
||||
let applicationUrl = '{{ application_url }}';
|
||||
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||
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>
|
||||
{% endif %}
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
{% if bookmark_list.snapshot_feature_enabled %}
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<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 %}
|
||||
<section aria-labelledby="bundles-heading">
|
||||
<div class="section-header">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path
|
||||
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="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn dropdown-toggle" aria-label="Bundles menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 6l16 0"/>
|
||||
<path d="M4 12l16 0"/>
|
||||
<path d="M4 18l16 0"/>
|
||||
</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>
|
||||
<ul class="bundle-menu">
|
||||
{% for bundle in bundles.bundles %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<h1 id="main-heading">Edit bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load widget_tweaks %}
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
|
||||
<div class="bookmarks-form">
|
||||
{% csrf_token %}
|
||||
@@ -7,7 +8,7 @@
|
||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||
<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>
|
||||
</div>
|
||||
{% if form.url.errors %}
|
||||
@@ -22,8 +23,8 @@
|
||||
</div>
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<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" }}
|
||||
<div class="form-input-hint">
|
||||
{{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<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 (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
</div>
|
||||
@@ -35,7 +36,8 @@
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||
<div class="flex">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -60,31 +62,31 @@
|
||||
<span class="form-label d-inline-block">Notes</span>
|
||||
</summary>
|
||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div class="form-input-hint">
|
||||
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
</details>
|
||||
{{ form.notes.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||
{{ form.unread }}
|
||||
<div class="form-checkbox">
|
||||
{{ form.unread|form_field:"help" }}
|
||||
<i class="form-icon"></i>
|
||||
<span>Mark as unread</span>
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
<label for="{{ form.unread.id_for_label }}">Mark as unread</label>
|
||||
</div>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
||||
{{ form.shared }}
|
||||
<div class="form-checkbox">
|
||||
{{ form.shared|form_field:"help" }}
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
<label for="{{ form.shared.id_for_label }}">Share</label>
|
||||
</div>
|
||||
<div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
|
||||
{% if request.user_profile.enable_public_sharing %}
|
||||
Share this bookmark with other registered users and anonymous users.
|
||||
{% else %}
|
||||
@@ -100,7 +102,7 @@
|
||||
{% else %}
|
||||
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn">Nevermind</a>
|
||||
<a href="{{ return_url }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
@@ -227,6 +229,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshButton.addEventListener('click', refreshMetadata);
|
||||
|
||||
// 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">
|
||||
<h1 id="main-heading">New bookmark</h1>
|
||||
</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' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% 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>
|
||||
<body>
|
||||
<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
|
||||
application first. Here's how it works:</p>
|
||||
<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>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>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>
|
||||
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
|
||||
<div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
|
||||
<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 aria-labelledby="rest-api-heading">
|
||||
@@ -90,4 +108,28 @@
|
||||
</p>
|
||||
</section>
|
||||
</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 %}
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
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):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
|
||||
@@ -207,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
# verify file name
|
||||
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
|
||||
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
|
||||
self.assertEqual(file.read(), file_content)
|
||||
self.assertEqual(self.read_asset_file(asset), file_content)
|
||||
|
||||
# should create asset
|
||||
self.assertIsNotNone(asset.id)
|
||||
@@ -221,6 +220,45 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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, 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.assertFalse(asset.gzip)
|
||||
|
||||
@@ -245,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertEqual(192, len(saved_file))
|
||||
self.assertTrue(saved_file.startswith("upload_"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.txt"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset_failure(self):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import urllib.parse
|
||||
|
||||
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 bookmarks.models import BookmarkSearch, UserProfile
|
||||
@@ -319,6 +319,28 @@ class BookmarkArchivedViewTestCase(
|
||||
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):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
@@ -345,6 +367,34 @@ class BookmarkArchivedViewTestCase(
|
||||
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):
|
||||
# no params
|
||||
response = self.client.post(reverse("linkding:bookmarks.archived"))
|
||||
|
||||
@@ -4,9 +4,8 @@ from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
)
|
||||
from bookmarks.models import BookmarkAsset
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -23,7 +22,21 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setup_asset_with_file(self, bookmark):
|
||||
filename = f"temp_{bookmark.id}.html.gzip"
|
||||
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
|
||||
|
||||
def view_access_test(self, view_name: str):
|
||||
@@ -127,3 +140,25 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_reader_view_access_guest_user(self):
|
||||
self.view_access_guest_user_test("linkding:assets.read")
|
||||
|
||||
def test_snapshot_download_name(self):
|
||||
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.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
||||
self.assertEqual(asset.content_type, "text/plain")
|
||||
self.assertEqual(asset.file_size, len(file_content))
|
||||
self.assertFalse(asset.gzip)
|
||||
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
content = self.read_asset_file(asset)
|
||||
self.assertEqual(content, file_content)
|
||||
|
||||
@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
|
||||
autofocus class="form-input" required id="id_url">
|
||||
""",
|
||||
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<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,
|
||||
)
|
||||
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
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}
|
||||
</textarea>
|
||||
""",
|
||||
@@ -259,12 +258,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<label for="id_shared" class="form-checkbox">
|
||||
<input type="checkbox" name="shared" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
""",
|
||||
<div class="form-checkbox">
|
||||
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<label for="id_shared">Share</label>
|
||||
</div>
|
||||
""",
|
||||
html,
|
||||
count=0,
|
||||
)
|
||||
@@ -278,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<label for="id_shared" class="form-checkbox">
|
||||
<input type="checkbox" name="shared" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
""",
|
||||
<div class="form-checkbox">
|
||||
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<label for="id_shared">Share</label>
|
||||
</div>
|
||||
""",
|
||||
html,
|
||||
count=1,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import urllib.parse
|
||||
|
||||
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 bookmarks.models import BookmarkSearch, UserProfile
|
||||
@@ -313,6 +313,28 @@ class BookmarkIndexViewTestCase(
|
||||
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):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
@@ -339,6 +361,34 @@ class BookmarkIndexViewTestCase(
|
||||
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):
|
||||
# no params
|
||||
response = self.client.post(reverse("linkding:bookmarks.index"))
|
||||
|
||||
@@ -78,9 +78,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="text" name="url" value="http://example.com" '
|
||||
'placeholder=" " autofocus class="form-input" required '
|
||||
'id="id_url">',
|
||||
"""
|
||||
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -117,9 +117,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="text" name="tag_string" value="tag1 tag2 tag3" '
|
||||
'class="form-input" autocomplete="off" autocapitalize="off" '
|
||||
'id="id_tag_string">',
|
||||
"""
|
||||
<input type="text" name="tag_string" value="tag1 tag2 tag3"
|
||||
aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -137,8 +138,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
<span class="form-label d-inline-block">Notes</span>
|
||||
</summary>
|
||||
<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>
|
||||
<div class="form-input-hint">
|
||||
<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 id="id_notes_help" class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
</details>
|
||||
@@ -196,12 +197,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<label for="id_shared" class="form-checkbox">
|
||||
<input type="checkbox" name="shared" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
""",
|
||||
<div class="form-checkbox">
|
||||
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<label for="id_shared">Share</label>
|
||||
</div>
|
||||
""",
|
||||
html,
|
||||
count=0,
|
||||
)
|
||||
@@ -213,12 +214,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<label for="id_shared" class="form-checkbox">
|
||||
<input type="checkbox" name="shared" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
""",
|
||||
<div class="form-checkbox">
|
||||
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<label for="id_shared">Share</label>
|
||||
</div>
|
||||
""",
|
||||
html,
|
||||
count=1,
|
||||
)
|
||||
@@ -231,10 +232,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="form-input-hint">
|
||||
Share this bookmark with other registered users.
|
||||
</div>
|
||||
""",
|
||||
<div id="id_shared_help" class="form-input-hint">
|
||||
Share this bookmark with other registered users.
|
||||
</div>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -245,10 +246,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="form-input-hint">
|
||||
Share this bookmark with other registered users and anonymous users.
|
||||
</div>
|
||||
""",
|
||||
<div id="id_shared_help" class="form-input-hint">
|
||||
Share this bookmark with other registered users and anonymous users.
|
||||
</div>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -265,7 +266,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="checkbox" name="unread" id="id_unread">',
|
||||
'<input type="checkbox" name="unread" id="id_unread" aria-describedby="id_unread_help">',
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -277,6 +278,6 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from bookmarks.services.bookmarks import (
|
||||
unshare_bookmarks,
|
||||
enhance_with_website_metadata,
|
||||
refresh_bookmarks_metadata,
|
||||
create_html_snapshots,
|
||||
)
|
||||
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_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())
|
||||
|
||||
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):
|
||||
self.authenticate()
|
||||
|
||||
|
||||
@@ -120,3 +120,41 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
|
||||
)
|
||||
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())
|
||||
|
||||
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):
|
||||
other_user = self.setup_user(name="otheruser")
|
||||
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from urllib.parse import urlencode
|
||||
|
||||
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:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -75,3 +76,72 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
form_data = self.create_form_data({"name": ""})
|
||||
response = self.client.post(reverse("linkding:bundles.new"), form_data)
|
||||
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):
|
||||
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"])
|
||||
|
||||
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
|
||||
def test_validate_empty_or_missing_bookmark_url(self):
|
||||
test_html = self.render_html(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
@@ -29,9 +30,6 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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():
|
||||
self.assertContains(response, bookmark.url)
|
||||
@@ -78,3 +76,18 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertFormErrorHint(
|
||||
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"]
|
||||
)
|
||||
|
||||
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):
|
||||
self.assertCountEqual(
|
||||
parse_tag_string("travel guide, book recommendations"),
|
||||
|
||||
@@ -124,7 +124,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
# Cancel edit, verify return to details url
|
||||
details_url = url + f"&details={bookmark.id}"
|
||||
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):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -4,8 +4,9 @@ from urllib.parse import quote
|
||||
from django.urls import reverse
|
||||
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.tests_e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
mock_website_metadata = website_loader.WebsiteMetadata(
|
||||
url="https://example.com",
|
||||
@@ -311,3 +312,26 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
# Verify that the fields are NOT visually marked as modified
|
||||
expect(title_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")
|
||||
|
||||
|
||||
def bundle_read(request: HttpRequest, bundle_id: int | str):
|
||||
return bundle_write(request, bundle_id)
|
||||
|
||||
|
||||
def bundle_write(request: HttpRequest, bundle_id: int | str):
|
||||
try:
|
||||
return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
|
||||
except BookmarkBundle.DoesNotExist:
|
||||
except (BookmarkBundle.DoesNotExist, ValueError):
|
||||
raise Http404("Bundle does not exist")
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ def view(request, asset_id: int):
|
||||
asset = access.asset_read(request, asset_id)
|
||||
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):
|
||||
|
||||
@@ -31,6 +31,7 @@ from bookmarks.services.bookmarks import (
|
||||
share_bookmarks,
|
||||
unshare_bookmarks,
|
||||
refresh_bookmarks_metadata,
|
||||
create_html_snapshots,
|
||||
)
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
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)
|
||||
if "bulk_refresh" == bulk_action:
|
||||
return refresh_bookmarks_metadata(bookmark_ids, request.user)
|
||||
if "bulk_snapshot" == bulk_action:
|
||||
return create_html_snapshots(bookmark_ids, request.user)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Max
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
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.contexts import ActiveBookmarkListContext
|
||||
|
||||
@@ -23,50 +24,53 @@ def action(request: HttpRequest):
|
||||
remove_bundle_id = request.POST.get("remove_bundle")
|
||||
bundle = access.bundle_write(request, remove_bundle_id)
|
||||
bundle_name = bundle.name
|
||||
bundle.delete()
|
||||
bundles.delete_bundle(bundle)
|
||||
messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
|
||||
|
||||
elif "move_bundle" in request.POST:
|
||||
bundle_id = request.POST.get("move_bundle")
|
||||
move_position = int(request.POST.get("move_position"))
|
||||
bundle_to_move = access.bundle_write(request, bundle_id)
|
||||
user_bundles = list(
|
||||
BookmarkBundle.objects.filter(owner=request.user).order_by("order")
|
||||
)
|
||||
|
||||
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"])
|
||||
move_position = int(request.POST.get("move_position"))
|
||||
bundles.move_bundle(bundle_to_move, move_position)
|
||||
|
||||
return HttpResponseRedirect(reverse("linkding:bundles.index"))
|
||||
|
||||
|
||||
def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = 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 form.is_valid():
|
||||
instance = form.save(commit=False)
|
||||
instance.owner = request.user
|
||||
|
||||
if bundle is None: # New bundle
|
||||
max_order_result = BookmarkBundle.objects.filter(
|
||||
owner=request.user
|
||||
).aggregate(Max("order", default=-1))
|
||||
instance.order = max_order_result["order__max"] + 1
|
||||
if bundle is None:
|
||||
instance.order = None
|
||||
bundles.create_bundle(instance, request.user)
|
||||
else:
|
||||
instance.save()
|
||||
|
||||
instance.save()
|
||||
messages.success(request, "Bundle saved successfully.")
|
||||
return HttpResponseRedirect(reverse("linkding:bundles.index"))
|
||||
|
||||
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||
bookmark_list = _get_bookmark_list_preview(request, bundle)
|
||||
context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list}
|
||||
bookmark_list = _get_bookmark_list_preview(request, bundle, initial_data)
|
||||
context = {
|
||||
"form": form,
|
||||
"bundle": bundle,
|
||||
"bookmark_list": bookmark_list,
|
||||
}
|
||||
|
||||
return render(request, template, context, status=status)
|
||||
|
||||
@@ -91,7 +95,9 @@ def preview(request: HttpRequest):
|
||||
|
||||
|
||||
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:
|
||||
preview_bundle = bundle
|
||||
@@ -99,6 +105,10 @@ def _get_bookmark_list_preview(
|
||||
form_data = (
|
||||
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 = BookmarkBundleForm(form_data)
|
||||
preview_bundle = form.save(commit=False)
|
||||
|
||||
@@ -219,6 +219,7 @@ class BookmarkListContext:
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||
self.is_preview = False
|
||||
self.snapshot_feature_enabled = settings.LD_ENABLE_SNAPSHOTS
|
||||
|
||||
@staticmethod
|
||||
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.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import (
|
||||
@@ -239,8 +240,12 @@ def bookmark_export(request: HttpRequest):
|
||||
prefetch_related_objects(bookmarks, "tags")
|
||||
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["Content-Disposition"] = 'attachment; filename="bookmarks.html"'
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
response.write(file_content)
|
||||
|
||||
return response
|
||||
|
||||
89
docs/package-lock.json
generated
89
docs/package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.7.13",
|
||||
"astro": "^5.12.8",
|
||||
"sharp": "^0.32.5",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/compiler": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.0.tgz",
|
||||
"integrity": "sha512-7bCjW6tVDpUurQLeKBUN9tZ5kSv5qYrGmcn0sG0IwacL7isR2ZbyyA3AdZ4uxsuUFOS2SlgReTH7wkxO6zpqWA==",
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz",
|
||||
"integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@astrojs/internal-helpers": {
|
||||
@@ -204,9 +204,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/telemetry": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.2.1.tgz",
|
||||
"integrity": "sha512-SSVM820Jqc6wjsn7qYfV9qfeQvePtVc1nSofhyap7l0/iakUKywj3hfy3UJAOV4sGV4Q/u450RD4AaCaFvNPlg==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
|
||||
"integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ci-info": "^4.2.0",
|
||||
@@ -218,7 +218,7 @@
|
||||
"which-pm-runs": "^1.1.0"
|
||||
},
|
||||
"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": {
|
||||
@@ -2047,15 +2047,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/astro": {
|
||||
"version": "5.7.13",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.7.13.tgz",
|
||||
"integrity": "sha512-cRGq2llKOhV3XMcYwQpfBIUcssN6HEK5CRbcMxAfd9OcFhvWE7KUy50zLioAZVVl3AqgUTJoNTlmZfD2eG0G1w==",
|
||||
"version": "5.12.8",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.12.8.tgz",
|
||||
"integrity": "sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^2.11.0",
|
||||
"@astrojs/internal-helpers": "0.6.1",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
"@astrojs/telemetry": "3.2.1",
|
||||
"@astrojs/compiler": "^2.12.2",
|
||||
"@astrojs/internal-helpers": "0.7.1",
|
||||
"@astrojs/markdown-remark": "6.3.5",
|
||||
"@astrojs/telemetry": "3.3.0",
|
||||
"@capsizecss/unpack": "^2.4.0",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@rollup/pluginutils": "^5.1.4",
|
||||
@@ -2082,6 +2082,7 @@
|
||||
"github-slugger": "^2.0.0",
|
||||
"html-escaper": "3.0.3",
|
||||
"http-cache-semantics": "^4.1.1",
|
||||
"import-meta-resolve": "^4.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.17",
|
||||
@@ -2096,6 +2097,7 @@
|
||||
"rehype": "^13.0.2",
|
||||
"semver": "^7.7.1",
|
||||
"shiki": "^3.2.1",
|
||||
"smol-toml": "^1.3.4",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.12",
|
||||
"tsconfck": "^3.1.5",
|
||||
@@ -2109,7 +2111,7 @@
|
||||
"xxhash-wasm": "^1.1.0",
|
||||
"yargs-parser": "^21.1.1",
|
||||
"yocto-spinner": "^0.2.1",
|
||||
"zod": "^3.24.2",
|
||||
"zod": "^3.24.4",
|
||||
"zod-to-json-schema": "^3.24.5",
|
||||
"zod-to-ts": "^1.2.0"
|
||||
},
|
||||
@@ -2117,7 +2119,7 @@
|
||||
"astro": "astro.js"
|
||||
},
|
||||
"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",
|
||||
"pnpm": ">=7.1.0"
|
||||
},
|
||||
@@ -2141,6 +2143,53 @@
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
@@ -2548,9 +2597,9 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz",
|
||||
"integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
|
||||
"integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.7.13",
|
||||
"astro": "^5.12.8",
|
||||
"sharp": "^0.32.5",
|
||||
"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-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-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-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).
|
||||
@@ -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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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:
|
||||
|
||||
- [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
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ To fix this, check the [reverse proxy setup documentation](/installation#reverse
|
||||
|
||||
## 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).
|
||||
|
||||
The bookmarklet currently does not have such an option.
|
||||
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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.41.0",
|
||||
"version": "1.42.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,9 +12,9 @@ click==8.1.7
|
||||
# via black
|
||||
coverage==7.6.1
|
||||
# via -r requirements.dev.in
|
||||
django==5.1.10
|
||||
django==5.2.3
|
||||
# via django-debug-toolbar
|
||||
django-debug-toolbar==4.4.6
|
||||
django-debug-toolbar==5.2.0
|
||||
# via -r requirements.dev.in
|
||||
execnet==2.1.1
|
||||
# via pytest-xdist
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile requirements.in
|
||||
# pip-compile
|
||||
#
|
||||
asgiref==3.8.1
|
||||
# via django
|
||||
@@ -27,7 +27,7 @@ cryptography==43.0.1
|
||||
# josepy
|
||||
# mozilla-django-oidc
|
||||
# pyopenssl
|
||||
django==5.1.10
|
||||
django==5.2.3
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-registration
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.41.0
|
||||
1.42.0
|
||||
|
||||
Reference in New Issue
Block a user