mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-08 02:43:12 +08:00
Compare commits
36 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 | ||
|
|
2bb33ff96d | ||
|
|
549554cc17 | ||
|
|
20e31397cc | ||
|
|
94ae5fb41c | ||
|
|
2a550e2315 | ||
|
|
a79e8bcd59 | ||
|
|
1710d44df7 | ||
|
|
9967b3e27b | ||
|
|
1672dc0152 | ||
|
|
8be72a5d1f | ||
|
|
bb796c9bdb | ||
|
|
578680c3c1 | ||
|
|
8debb5c5aa | ||
|
|
be752f8146 |
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,5 +1,93 @@
|
||||
# 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
|
||||
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
|
||||
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
|
||||
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
|
||||
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
|
||||
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
|
||||
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
|
||||
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
|
||||
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
|
||||
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
|
||||
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
|
||||
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
|
||||
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
|
||||
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
|
||||
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
|
||||
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
|
||||
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
|
||||
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
|
||||
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
|
||||
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
|
||||
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
|
||||
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
|
||||
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
|
||||
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
|
||||
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
|
||||
|
||||
### New Contributors
|
||||
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
|
||||
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
|
||||
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
|
||||
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
|
||||
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
|
||||
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
|
||||
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
|
||||
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
|
||||
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
|
||||
|
||||
---
|
||||
|
||||
## v1.39.1 (06/03/2025)
|
||||
|
||||
> [!WARNING]
|
||||
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
|
||||
|
||||
---
|
||||
|
||||
## v1.39.0 (06/03/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
|
||||
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
|
||||
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
|
||||
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
|
||||
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
|
||||
|
||||
### New Contributors
|
||||
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
|
||||
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
|
||||
|
||||
---
|
||||
|
||||
## v1.38.1 (22/02/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)
|
||||
|
||||
@@ -16,9 +16,17 @@ from bookmarks.api.serializers import (
|
||||
BookmarkAssetSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
BookmarkBundleSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
User,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
@@ -50,7 +58,7 @@ class BookmarkViewSet(
|
||||
def get_queryset(self):
|
||||
# Provide filtered queryset for list actions
|
||||
user = self.request.user
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||
if self.action == "list":
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
elif self.action == "archived":
|
||||
@@ -191,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")
|
||||
@@ -264,6 +269,28 @@ class UserViewSet(viewsets.GenericViewSet):
|
||||
return Response(UserProfileSerializer(request.user.profile).data)
|
||||
|
||||
|
||||
class BookmarkBundleViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkBundleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return BookmarkBundle.objects.filter(owner=user).order_by("order")
|
||||
|
||||
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
|
||||
# The default router is only used to allow reversing a URL for the API root
|
||||
@@ -278,5 +305,8 @@ tag_router.register("", TagViewSet, basename="tag")
|
||||
user_router = SimpleRouter()
|
||||
user_router.register("", UserViewSet, basename="user")
|
||||
|
||||
bundle_router = SimpleRouter()
|
||||
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
|
||||
|
||||
bookmark_asset_router = SimpleRouter()
|
||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.db.models import Max, prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services import bookmarks
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
UserProfile,
|
||||
BookmarkBundle,
|
||||
)
|
||||
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
|
||||
@@ -27,6 +34,32 @@ class EmtpyField(serializers.ReadOnlyField):
|
||||
return None
|
||||
|
||||
|
||||
class BookmarkBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"order",
|
||||
"date_created",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"date_created",
|
||||
"date_modified",
|
||||
]
|
||||
|
||||
def create(self, 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):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
|
||||
@@ -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;
|
||||
@@ -77,6 +78,7 @@
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
|
||||
|
||||
close();
|
||||
}
|
||||
@@ -109,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>
|
||||
@@ -128,41 +131,41 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.1.9 on 2025-06-19 08:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0044_bookmark_latest_snapshot"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="hide_bundles",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookmarkBundle",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=256)),
|
||||
("search", models.CharField(blank=True, max_length=256)),
|
||||
("any_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("all_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("excluded_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("order", models.IntegerField(default=0)),
|
||||
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||
("date_modified", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@ import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property
|
||||
from typing import List
|
||||
|
||||
from django import forms
|
||||
@@ -39,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)
|
||||
@@ -132,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:
|
||||
@@ -157,6 +166,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||
|
||||
|
||||
class BookmarkBundle(models.Model):
|
||||
name = models.CharField(max_length=256, blank=False)
|
||||
search = models.CharField(max_length=256, blank=True)
|
||||
any_tags = models.CharField(max_length=1024, blank=True)
|
||||
all_tags = models.CharField(max_length=1024, blank=True)
|
||||
excluded_tags = models.CharField(max_length=1024, blank=True)
|
||||
order = models.IntegerField(null=False, default=0)
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||
date_modified = models.DateTimeField(auto_now=True, null=False)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BookmarkBundleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
SORT_ADDED_ASC = "added_asc"
|
||||
SORT_ADDED_DESC = "added_desc"
|
||||
@@ -171,34 +201,54 @@ class BookmarkSearch:
|
||||
FILTER_UNREAD_YES = "yes"
|
||||
FILTER_UNREAD_NO = "no"
|
||||
|
||||
params = ["q", "user", "sort", "shared", "unread"]
|
||||
params = [
|
||||
"q",
|
||||
"user",
|
||||
"bundle",
|
||||
"sort",
|
||||
"shared",
|
||||
"unread",
|
||||
"modified_since",
|
||||
"added_since",
|
||||
]
|
||||
preferences = ["sort", "shared", "unread"]
|
||||
defaults = {
|
||||
"q": "",
|
||||
"user": "",
|
||||
"bundle": None,
|
||||
"sort": SORT_ADDED_DESC,
|
||||
"shared": FILTER_SHARED_OFF,
|
||||
"unread": FILTER_UNREAD_OFF,
|
||||
"modified_since": None,
|
||||
"added_since": None,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
bundle: BookmarkBundle = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
modified_since: str = None,
|
||||
added_since: str = None,
|
||||
preferences: dict = None,
|
||||
request: any = None,
|
||||
):
|
||||
if not preferences:
|
||||
preferences = {}
|
||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||
self.request = request
|
||||
|
||||
self.q = q or self.defaults["q"]
|
||||
self.user = user or self.defaults["user"]
|
||||
self.bundle = bundle or self.defaults["bundle"]
|
||||
self.sort = sort or self.defaults["sort"]
|
||||
self.shared = shared or self.defaults["shared"]
|
||||
self.unread = unread or self.defaults["unread"]
|
||||
self.modified_since = modified_since or self.defaults["modified_since"]
|
||||
self.added_since = added_since or self.defaults["added_since"]
|
||||
|
||||
def is_modified(self, param):
|
||||
value = self.__dict__[param]
|
||||
@@ -226,7 +276,14 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
def query_params(self):
|
||||
return {param: self.__dict__[param] for param in self.modified_params}
|
||||
query_params = {}
|
||||
for param in self.modified_params:
|
||||
value = self.__dict__[param]
|
||||
if isinstance(value, models.Model):
|
||||
query_params[param] = value.id
|
||||
else:
|
||||
query_params[param] = value
|
||||
return query_params
|
||||
|
||||
@property
|
||||
def preferences_dict(self):
|
||||
@@ -235,14 +292,21 @@ class BookmarkSearch:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||
initial_values = {}
|
||||
for param in BookmarkSearch.params:
|
||||
value = query_dict.get(param)
|
||||
if value:
|
||||
initial_values[param] = value
|
||||
if param == "bundle":
|
||||
initial_values[param] = BookmarkBundle.objects.filter(
|
||||
owner=request.user, pk=value
|
||||
).first()
|
||||
else:
|
||||
initial_values[param] = value
|
||||
|
||||
return BookmarkSearch(**initial_values, preferences=preferences)
|
||||
return BookmarkSearch(
|
||||
**initial_values, preferences=preferences, request=request
|
||||
)
|
||||
|
||||
|
||||
class BookmarkSearchForm(forms.Form):
|
||||
@@ -265,9 +329,12 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
bundle = forms.CharField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
modified_since = forms.CharField(required=False)
|
||||
added_since = forms.CharField(required=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -287,7 +354,11 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
for param in search.params:
|
||||
# set initial values for modified params
|
||||
self.fields[param].initial = search.__dict__[param]
|
||||
value = search.__dict__.get(param)
|
||||
if isinstance(value, models.Model):
|
||||
self.fields[param].initial = value.id
|
||||
else:
|
||||
self.fields[param].initial = value
|
||||
|
||||
# Mark non-editable modified fields as hidden. That way, templates
|
||||
# rendering a form can just loop over hidden_fields to ensure that
|
||||
@@ -408,6 +479,7 @@ class UserProfile(models.Model):
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
hide_bundles = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
@@ -448,6 +520,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
"hide_bundles",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,26 @@ from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
UserProfile,
|
||||
parse_tag_string,
|
||||
)
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
user: User,
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||
|
||||
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
|
||||
# Search terms
|
||||
search_terms = parse_query_string(bundle.search)["search_terms"]
|
||||
for term in search_terms:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
# Any tags - at least one tag must match
|
||||
any_tags = parse_tag_string(bundle.any_tags, " ")
|
||||
if len(any_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in any_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
|
||||
query_set = query_set.filter(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
# All tags - all tags must match
|
||||
all_tags = parse_tag_string(bundle.all_tags, " ")
|
||||
for tag in all_tags:
|
||||
query_set = query_set.filter(tags__name__iexact=tag)
|
||||
|
||||
# Excluded tags - no tags must match
|
||||
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
|
||||
if len(exclude_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in exclude_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
query_set = query_set.exclude(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
return query_set
|
||||
|
||||
|
||||
def _base_bookmarks_query(
|
||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
|
||||
if user:
|
||||
query_set = query_set.filter(owner=user)
|
||||
|
||||
# Filter by modified_since if provided
|
||||
if search.modified_since:
|
||||
try:
|
||||
query_set = query_set.filter(date_modified__gt=search.modified_since)
|
||||
except ValidationError:
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# Filter by added_since if provided
|
||||
if search.added_since:
|
||||
try:
|
||||
query_set = query_set.filter(date_added__gt=search.added_since)
|
||||
except ValidationError:
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# Split query into search terms and tags
|
||||
query = parse_query_string(search.q)
|
||||
|
||||
@@ -85,6 +154,10 @@ def _base_bookmarks_query(
|
||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||
query_set = query_set.filter(shared=False)
|
||||
|
||||
# Filter by bundle
|
||||
if search.bundle:
|
||||
query_set = _filter_bundle(query_set, search.bundle)
|
||||
|
||||
# Sort
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
|
||||
@@ -53,6 +53,7 @@ def create_snapshot(asset: BookmarkAsset):
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
except Exception as error:
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
@@ -75,6 +76,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
return asset
|
||||
@@ -92,14 +94,33 @@ 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()
|
||||
asset.bookmark.save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
@@ -128,9 +149,10 @@ def remove_asset(asset: BookmarkAsset):
|
||||
)
|
||||
|
||||
bookmark.latest_snapshot = latest
|
||||
bookmark.save()
|
||||
|
||||
asset.delete()
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def _generate_asset_filename(
|
||||
|
||||
@@ -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;
|
||||
@@ -49,50 +57,9 @@
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .asset-text .filesize {
|
||||
& .filesize {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
& .asset-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .assets-actions {
|
||||
|
||||
@@ -379,6 +379,26 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.bundle-menu {
|
||||
list-style-type: none;
|
||||
margin: 0 0 var(--unit-6);
|
||||
|
||||
.bundle-menu-item {
|
||||
margin: 0;
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.bundle-menu-item a {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.bundle-menu-item.selected a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
34
bookmarks/styles/bundles.css
Normal file
34
bookmarks/styles/bundles.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.bundles-page {
|
||||
h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
.list-item .list-item-icon {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.list-item.drag-start {
|
||||
--secondary-border-color: transparent;
|
||||
}
|
||||
|
||||
.list-item.dragging > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bundles-editor-page {
|
||||
&.grid {
|
||||
gap: var(--unit-9);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
background: var(--body-color);
|
||||
padding: var(--unit-3) 0;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.section-header {
|
||||
.section-header:not(.no-wrap) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -60,3 +60,60 @@ span.confirmation {
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.message-list {
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
.toast {
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Item list */
|
||||
.item-list {
|
||||
& .list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .list-item-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .list-item-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .list-item-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +27,3 @@ header {
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,4 @@
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
||||
@import "bundles.css";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -242,6 +242,14 @@
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@@ -30,16 +30,10 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<div class="side-panel col-1">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -77,72 +77,76 @@
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% if not bookmark_list.is_preview %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
{% if not bookmark_list.is_preview %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% if not request.user_profile.hide_bundles %}
|
||||
<section aria-labelledby="bundles-heading">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<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 %}
|
||||
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
|
||||
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -1,12 +1,12 @@
|
||||
<div>
|
||||
{% if details.assets %}
|
||||
<div class="assets">
|
||||
<div class="item-list assets">
|
||||
{% for asset in details.assets %}
|
||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
||||
<div class="asset-icon {{ asset.icon_classes }}">
|
||||
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||
{% include 'bookmarks/details/asset_icon.html' %}
|
||||
</div>
|
||||
<div class="asset-text {{ asset.text_classes }}">
|
||||
<div class="list-item-text {{ asset.text_classes }}">
|
||||
<span class="truncate">
|
||||
{{ asset.display_name }}
|
||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<div class="list-item-actions">
|
||||
{% if asset.file %}
|
||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,16 +32,10 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<div class="side-panel col-1">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts">
|
||||
<div class="message-list">
|
||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ul class="pagination">
|
||||
{% if prev_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
@@ -14,7 +14,7 @@
|
||||
{% for page_link in page_links %}
|
||||
{% if page_link %}
|
||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{% if next_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
||||
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</main>
|
||||
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1">
|
||||
<div class="side-panel col-1 hide-md">
|
||||
<section aria-labelledby="user-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="user-heading">User</h2>
|
||||
@@ -38,14 +38,7 @@
|
||||
<br>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
8
bookmarks/templates/bookmarks/tag_section.html
Normal file
8
bookmarks/templates/bookmarks/tag_section.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
33
bookmarks/templates/bundles/edit.html
Normal file
33
bookmarks/templates/bundles/edit.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit bundle - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bundles-editor-page grid columns-md-1">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bundle</h1>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'bundles/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="col-2" aria-labelledby="preview-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="preview-heading">Preview</h2>
|
||||
</div>
|
||||
|
||||
{% include 'bundles/preview.html' %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
bookmarks/templates/bundles/form.html
Normal file
91
bookmarks/templates/bundles/form.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
|
||||
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.name.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
|
||||
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.search.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.search.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-input-hint">
|
||||
Search terms to match bookmarks in this bundle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
At least one of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
|
||||
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
All of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
|
||||
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
None of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer d-flex mt-4">
|
||||
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
|
||||
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
|
||||
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundleForm = document.getElementById('bundle-form');
|
||||
const previewLink = document.getElementById('preview-link');
|
||||
|
||||
let pendingUpdate;
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingUpdate) {
|
||||
clearTimeout(pendingUpdate);
|
||||
}
|
||||
pendingUpdate = setTimeout(() => {
|
||||
// Ignore if link has been removed (e.g. form submit or navigation)
|
||||
if (!previewLink.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = previewLink.href.split('?')[0];
|
||||
const params = new URLSearchParams();
|
||||
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value.trim()) {
|
||||
params.set(input.name, input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||
previewLink.click();
|
||||
}, 500)
|
||||
}
|
||||
|
||||
bundleForm.addEventListener('input', scheduleUpdate);
|
||||
bundleForm.addEventListener('change', scheduleUpdate);
|
||||
})();
|
||||
</script>
|
||||
124
bookmarks/templates/bundles/index.html
Normal file
124
bookmarks/templates/bundles/index.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Bundles - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="bundles-page" aria-labelledby="main-heading">
|
||||
<h1 id="main-heading">Bundles</h1>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
{% if bundles %}
|
||||
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="item-list bundles">
|
||||
{% for bundle in bundles %}
|
||||
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{{ bundle.name }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||
class="btn btn-link">Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||
<input type="hidden" name="move_position" value="">
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bundles yet</p>
|
||||
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundlesList = document.querySelector(".item-list.bundles");
|
||||
if (!bundlesList) return;
|
||||
|
||||
let draggedElement = null;
|
||||
|
||||
const listItems = bundlesList.querySelectorAll('.list-item');
|
||||
listItems.forEach((item) => {
|
||||
item.addEventListener('dragstart', handleDragStart);
|
||||
item.addEventListener('dragend', handleDragEnd);
|
||||
item.addEventListener('dragover', handleDragOver);
|
||||
item.addEventListener('dragenter', handleDragEnter);
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
this.classList.add('drag-start');
|
||||
setTimeout(() => {
|
||||
this.classList.remove('drag-start');
|
||||
this.classList.add('dragging');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
|
||||
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
||||
const movePositionInput = document.querySelector('input[name="move_position"]');
|
||||
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
||||
movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
|
||||
|
||||
const form = this.closest('form');
|
||||
form.requestSubmit(moveBundleInput);
|
||||
|
||||
draggedElement = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnter() {
|
||||
if (this !== draggedElement) {
|
||||
const listItems = Array.from(bundlesList.children);
|
||||
const draggedIndex = listItems.indexOf(draggedElement);
|
||||
const currentIndex = listItems.indexOf(this);
|
||||
|
||||
if (draggedIndex < currentIndex) {
|
||||
this.insertAdjacentElement('afterend', draggedElement);
|
||||
} else {
|
||||
this.insertAdjacentElement('beforebegin', draggedElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
33
bookmarks/templates/bundles/new.html
Normal file
33
bookmarks/templates/bundles/new.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="New bundle - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bundles-editor-page grid columns-md-1">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bundle</h1>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'bundles/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="col-2" aria-labelledby="preview-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="preview-heading">Preview</h2>
|
||||
</div>
|
||||
|
||||
{% include 'bundles/preview.html' %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
bookmarks/templates/bundles/preview.html
Normal file
12
bookmarks/templates/bundles/preview.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<turbo-frame id="preview">
|
||||
{% if bookmark_list.is_empty %}
|
||||
<div>
|
||||
No bookmarks match the current bundle.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4">
|
||||
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
|
||||
</div>
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
@@ -139,6 +139,15 @@
|
||||
Instead, the tags are shown in an expandable drawer.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
|
||||
{{ form.hide_bundles }}
|
||||
<i class="form-icon"></i> Hide bundles
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Allows to hide the bundles in the side panel if you don't intend to use them.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
|
||||
@@ -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 %}
|
||||
9
bookmarks/templates/shared/messages.html
Normal file
9
bookmarks/templates/shared/messages.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% if messages %}
|
||||
<div class="message-list">
|
||||
{% for message in messages %}
|
||||
<div class="toast toast-{{ message.tags }}" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -13,18 +13,21 @@ register = template.Library()
|
||||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||
)
|
||||
def pagination(context, page: Page):
|
||||
request = context["request"]
|
||||
base_url = request.build_absolute_uri(request.path)
|
||||
|
||||
# remove page number and details from query parameters
|
||||
query_params = context["request"].GET.copy()
|
||||
query_params = request.GET.copy()
|
||||
query_params.pop("page", None)
|
||||
query_params.pop("details", None)
|
||||
|
||||
prev_link = (
|
||||
_generate_link(query_params, page.previous_page_number())
|
||||
_generate_link(base_url, query_params, page.previous_page_number())
|
||||
if page.has_previous()
|
||||
else None
|
||||
)
|
||||
next_link = (
|
||||
_generate_link(query_params, page.next_page_number())
|
||||
_generate_link(base_url, query_params, page.next_page_number())
|
||||
if page.has_next()
|
||||
else None
|
||||
)
|
||||
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
|
||||
if page_number == -1:
|
||||
page_links.append(None)
|
||||
else:
|
||||
link = _generate_link(query_params, page_number)
|
||||
link = _generate_link(base_url, query_params, page_number)
|
||||
page_links.append(
|
||||
{
|
||||
"active": page_number == page.number,
|
||||
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||
return reduce(append_page, visible_pages, [])
|
||||
|
||||
|
||||
def _generate_link(query_params: QueryDict, page_number: int) -> str:
|
||||
def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
|
||||
query_params = query_params.copy()
|
||||
query_params["page"] = page_number
|
||||
return query_params.urlencode()
|
||||
return f"{base_url}?{query_params.urlencode()}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
|
||||
|
||||
|
||||
class BookmarkFactoryMixin:
|
||||
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
return Bookmark.objects.get(title=title)
|
||||
|
||||
def setup_bundle(
|
||||
self,
|
||||
user: User = None,
|
||||
name: str = None,
|
||||
search: str = "",
|
||||
any_tags: str = "",
|
||||
all_tags: str = "",
|
||||
excluded_tags: str = "",
|
||||
order: int = 0,
|
||||
):
|
||||
if user is None:
|
||||
user = self.get_or_create_test_user()
|
||||
if not name:
|
||||
name = get_random_string(length=32)
|
||||
bundle = BookmarkBundle(
|
||||
name=name,
|
||||
owner=user,
|
||||
date_created=timezone.now(),
|
||||
search=search,
|
||||
any_tags=any_tags,
|
||||
all_tags=all_tags,
|
||||
excluded_tags=excluded_tags,
|
||||
order=order,
|
||||
)
|
||||
bundle.save()
|
||||
return bundle
|
||||
|
||||
def setup_asset(
|
||||
self,
|
||||
bookmark: Bookmark,
|
||||
@@ -209,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)
|
||||
@@ -239,7 +275,7 @@ class BookmarkFactoryMixin:
|
||||
user.profile.save()
|
||||
return user
|
||||
|
||||
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
|
||||
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
|
||||
all_tags = []
|
||||
for bookmark in bookmarks:
|
||||
all_tags = all_tags + list(bookmark.tags.all())
|
||||
|
||||
@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertIsNone(asset.id)
|
||||
|
||||
def test_create_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(
|
||||
url="https://example.com", modified=initial_modified
|
||||
)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
asset.date_created = timezone.datetime(
|
||||
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(asset.file, expected_filename)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
def test_create_snapshot_failure(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
||||
|
||||
def test_upload_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(
|
||||
url="https://example.com", modified=initial_modified
|
||||
)
|
||||
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
|
||||
# should create gzip file in asset folder
|
||||
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(asset.file, saved_file_name)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
def test_upload_snapshot_failure(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||
file_content = b"test content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
@@ -187,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)
|
||||
@@ -201,9 +220,52 @@ 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)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset_truncates_asset_file_name(self):
|
||||
# Create a bookmark with a very long URL
|
||||
@@ -221,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):
|
||||
@@ -409,3 +471,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
# Verify that latest_snapshot hasn't changed
|
||||
self.assertEqual(bookmark.latest_snapshot, latest_asset)
|
||||
|
||||
@disable_logging
|
||||
def test_remove_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 = b"test content for removal"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_remove_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
asset_filepath = os.path.join(self.assets_dir, asset.file)
|
||||
|
||||
# Verify asset and file exist
|
||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
self.assertTrue(os.path.exists(asset_filepath))
|
||||
|
||||
bookmark.date_modified = initial_modified
|
||||
bookmark.save()
|
||||
|
||||
# Remove the asset
|
||||
assets.remove_asset(asset)
|
||||
|
||||
# Verify asset is removed from DB
|
||||
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
# Verify file is removed from disk
|
||||
self.assertFalse(os.path.exists(asset_filepath))
|
||||
|
||||
# Verify bookmark modified date is updated
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_index_action_bulk_select_across_respects_bundle(self):
|
||||
self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_respects_bundle(self):
|
||||
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_shared_action_bulk_select_across_not_supported(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
BookmarkListTestMixin,
|
||||
TagCloudTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="foo", archived=True
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="bar", archived=True
|
||||
)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
@@ -284,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
|
||||
@@ -310,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"))
|
||||
@@ -515,3 +600,20 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
def test_hide_bundles_when_enabled_in_profile(self):
|
||||
# visible by default
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||
|
||||
# hidden when disabled in profile
|
||||
user_profile = self.get_or_create_test_user().profile
|
||||
user_profile.hide_bundles = True
|
||||
user_profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
asset_item = self.find_asset(asset_list, asset)
|
||||
self.assertIsNotNone(asset_item)
|
||||
|
||||
asset_icon = asset_item.select_one(".asset-icon svg")
|
||||
asset_icon = asset_item.select_one(".list-item-icon svg")
|
||||
self.assertIsNotNone(asset_icon)
|
||||
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
asset_text = asset_item.select_one(".list-item-text span")
|
||||
self.assertIsNotNone(asset_text)
|
||||
self.assertIn(asset.display_name, asset_text.text)
|
||||
|
||||
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, pending_asset)
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
asset_text = asset_item.select_one(".list-item-text span")
|
||||
self.assertIn("(queued)", asset_text.text)
|
||||
|
||||
asset_item = self.find_asset(soup, failed_asset)
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
asset_text = asset_item.select_one(".list-item-text span")
|
||||
self.assertIn("(failed)", asset_text.text)
|
||||
|
||||
def test_asset_file_size(self):
|
||||
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, asset1)
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
asset_text = asset_item.select_one(".list-item-text")
|
||||
self.assertEqual(asset_text.text.strip(), asset1.display_name)
|
||||
|
||||
asset_item = self.find_asset(soup, asset2)
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
asset_text = asset_item.select_one(".list-item-text")
|
||||
self.assertIn("53.4\xa0KB", asset_text.text)
|
||||
|
||||
asset_item = self.find_asset(soup, asset3)
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
asset_text = asset_item.select_one(".list-item-text")
|
||||
self.assertIn("11.0\xa0MB", asset_text.text)
|
||||
|
||||
def test_asset_actions_visibility(self):
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertIsNotNone(form)
|
||||
self.assertEqual(form.attrs["action"], url)
|
||||
|
||||
def assertVisibleBundles(self, soup, bundles):
|
||||
bundle_list = soup.select_one("ul.bundle-menu")
|
||||
self.assertIsNotNone(bundle_list)
|
||||
|
||||
list_items = bundle_list.select("li.bundle-menu-item")
|
||||
self.assertEqual(len(list_items), len(bundles))
|
||||
|
||||
for index, list_item in enumerate(list_items):
|
||||
bundle = bundles[index]
|
||||
link = list_item.select_one("a")
|
||||
href = link.attrs["href"]
|
||||
|
||||
self.assertEqual(bundle.name, list_item.text.strip())
|
||||
self.assertEqual(f"?bundle={bundle.id}", href)
|
||||
|
||||
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
@@ -265,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
|
||||
@@ -291,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"))
|
||||
@@ -494,3 +592,43 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
def test_list_bundles(self):
|
||||
books = self.setup_bundle(name="Books bundle", order=3)
|
||||
music = self.setup_bundle(name="Music bundle", order=1)
|
||||
tools = self.setup_bundle(name="Tools bundle", order=2)
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertVisibleBundles(soup, [music, tools, books])
|
||||
|
||||
def test_list_bundles_only_shows_user_owned_bundles(self):
|
||||
user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
|
||||
other_user = self.setup_user()
|
||||
self.setup_bundle(user=other_user)
|
||||
self.setup_bundle(user=other_user)
|
||||
self.setup_bundle(user=other_user)
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertVisibleBundles(soup, user_bundles)
|
||||
|
||||
def test_hide_bundles_when_enabled_in_profile(self):
|
||||
# visible by default
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||
|
||||
# hidden when disabled in profile
|
||||
user_profile = self.get_or_create_test_user().profile
|
||||
user_profile.hide_bundles = True
|
||||
user_profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
||||
form = BookmarkSearchForm(search)
|
||||
self.assertEqual(form["q"].initial, "")
|
||||
self.assertEqual(form["user"].initial, "")
|
||||
self.assertEqual(form["bundle"].initial, None)
|
||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
|
||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
|
||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
|
||||
# with params
|
||||
bundle = self.setup_bundle()
|
||||
search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
form = BookmarkSearchForm(search)
|
||||
self.assertEqual(form["q"].initial, "search query")
|
||||
self.assertEqual(form["user"].initial, "user123")
|
||||
self.assertEqual(form["bundle"].initial, bundle.id)
|
||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
|
||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
|
||||
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
|
||||
|
||||
# all modified params
|
||||
bundle = self.setup_bundle()
|
||||
search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
form = BookmarkSearchForm(search)
|
||||
self.assertCountEqual(
|
||||
form.hidden_fields(),
|
||||
[form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
|
||||
[
|
||||
form["q"],
|
||||
form["sort"],
|
||||
form["user"],
|
||||
form["bundle"],
|
||||
form["shared"],
|
||||
form["unread"],
|
||||
],
|
||||
)
|
||||
|
||||
# some modified params are editable fields
|
||||
|
||||
@@ -2,16 +2,23 @@ from django.http import QueryDict
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.models import BookmarkSearch
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkSearchModelTest(TestCase):
|
||||
class MockRequest:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
|
||||
class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
|
||||
def test_from_request(self):
|
||||
# no params
|
||||
query_dict = QueryDict()
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict)
|
||||
search = BookmarkSearch.from_request(None, query_dict)
|
||||
self.assertEqual(search.q, "")
|
||||
self.assertEqual(search.user, "")
|
||||
self.assertEqual(search.bundle, None)
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
|
||||
# some params
|
||||
query_dict = QueryDict("q=search query&user=user123")
|
||||
|
||||
bookmark_search = BookmarkSearch.from_request(query_dict)
|
||||
bookmark_search = BookmarkSearch.from_request(None, query_dict)
|
||||
self.assertEqual(bookmark_search.q, "search query")
|
||||
self.assertEqual(bookmark_search.user, "user123")
|
||||
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
|
||||
# all params
|
||||
bundle = self.setup_bundle()
|
||||
request = MockRequest(self.get_or_create_test_user())
|
||||
query_dict = QueryDict(
|
||||
"q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
|
||||
f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
|
||||
)
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict)
|
||||
search = BookmarkSearch.from_request(request, query_dict)
|
||||
self.assertEqual(search.q, "search query")
|
||||
self.assertEqual(search.user, "user123")
|
||||
self.assertEqual(search.bundle, bundle)
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
|
||||
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
|
||||
}
|
||||
query_dict = QueryDict("q=search query")
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict, preferences)
|
||||
search = BookmarkSearch.from_request(None, query_dict, preferences)
|
||||
self.assertEqual(search.q, "search query")
|
||||
self.assertEqual(search.user, "")
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
|
||||
}
|
||||
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict, preferences)
|
||||
search = BookmarkSearch.from_request(None, query_dict, preferences)
|
||||
self.assertEqual(search.q, "")
|
||||
self.assertEqual(search.user, "")
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
|
||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
|
||||
def test_from_request_ignores_invalid_bundle_param(self):
|
||||
self.setup_bundle()
|
||||
|
||||
# bundle does not exist
|
||||
request = MockRequest(self.get_or_create_test_user())
|
||||
query_dict = QueryDict("bundle=99999")
|
||||
search = BookmarkSearch.from_request(request, query_dict)
|
||||
self.assertIsNone(search.bundle)
|
||||
|
||||
# bundle belongs to another user
|
||||
other_user = self.setup_user()
|
||||
bundle = self.setup_bundle(user=other_user)
|
||||
query_dict = QueryDict(f"bundle={bundle.id}")
|
||||
search = BookmarkSearch.from_request(request, query_dict)
|
||||
self.assertIsNone(search.bundle)
|
||||
|
||||
def test_query_params(self):
|
||||
# no params
|
||||
search = BookmarkSearch()
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# params are default values
|
||||
search = BookmarkSearch(
|
||||
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
|
||||
)
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# some modified params
|
||||
search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
|
||||
self.assertEqual(
|
||||
search.query_params,
|
||||
{"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
|
||||
)
|
||||
|
||||
# all modified params
|
||||
bundle = self.setup_bundle()
|
||||
search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
self.assertEqual(
|
||||
search.query_params,
|
||||
{
|
||||
"q": "search query",
|
||||
"sort": BookmarkSearch.SORT_ADDED_ASC,
|
||||
"user": "user123",
|
||||
"bundle": bundle.id,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
|
||||
# preferences are not query params if they match default
|
||||
preferences = {
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
search = BookmarkSearch(preferences=preferences)
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# param is not a query param if it matches the preference
|
||||
preferences = {
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
search = BookmarkSearch(
|
||||
sort=BookmarkSearch.SORT_TITLE_ASC,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
preferences=preferences,
|
||||
)
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# overriding preferences is a query param
|
||||
preferences = {
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
search = BookmarkSearch(
|
||||
sort=BookmarkSearch.SORT_TITLE_DESC,
|
||||
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
preferences=preferences,
|
||||
)
|
||||
self.assertEqual(
|
||||
search.query_params,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
def test_modified_params(self):
|
||||
# no params
|
||||
bookmark_search = BookmarkSearch()
|
||||
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
|
||||
self.assertCountEqual(modified_params, ["q", "sort"])
|
||||
|
||||
# all modified params
|
||||
bundle = self.setup_bundle()
|
||||
bookmark_search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
modified_params = bookmark_search.modified_params
|
||||
self.assertCountEqual(
|
||||
modified_params, ["q", "sort", "user", "shared", "unread"]
|
||||
modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
|
||||
)
|
||||
|
||||
# preferences are not modified params
|
||||
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
|
||||
)
|
||||
|
||||
# only returns preferences
|
||||
bookmark_search = BookmarkSearch(q="search query", user="user123")
|
||||
bundle = self.setup_bundle()
|
||||
bookmark_search = BookmarkSearch(
|
||||
q="search query", user="user123", bundle=bundle
|
||||
)
|
||||
self.assertEqual(
|
||||
bookmark_search.preferences_dict,
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
request.user_profile = self.get_or_create_test_user().profile
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(request, request.GET)
|
||||
context = RequestContext(
|
||||
request,
|
||||
{
|
||||
|
||||
@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
self.authenticate()
|
||||
user = self.setup_user(enable_sharing=True)
|
||||
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, shared=True, user=user, prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
|
||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
self.authenticate()
|
||||
user1 = self.setup_user(enable_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
user3 = self.setup_user(enable_sharing=True)
|
||||
visible_tags = [
|
||||
self.setup_tag(user=user1),
|
||||
self.setup_tag(user=user2),
|
||||
self.setup_tag(user=user3),
|
||||
]
|
||||
invisible_tags = [
|
||||
self.setup_tag(user=user1),
|
||||
self.setup_tag(user=user2),
|
||||
self.setup_tag(user=user3),
|
||||
]
|
||||
|
||||
self.setup_bookmark(
|
||||
shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
|
||||
)
|
||||
self.setup_bookmark(
|
||||
shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
|
||||
)
|
||||
self.setup_bookmark(
|
||||
shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
|
||||
)
|
||||
|
||||
self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
|
||||
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
|
||||
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
|
||||
|
||||
bundle = self.setup_bundle(search="searchvalue")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
|
||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
|
||||
@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
def test_list_bookmarks_should_filter_by_bundle(self):
|
||||
self.authenticate()
|
||||
search_value = self.get_random_string()
|
||||
bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
|
||||
self.setup_numbered_bookmarks(5)
|
||||
bundle = self.setup_bundle(search=search_value)
|
||||
|
||||
response = self.get(
|
||||
reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
def test_list_bookmarks_filter_unread(self):
|
||||
self.authenticate()
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
|
||||
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
|
||||
def test_list_archived_bookmarks_should_filter_by_bundle(self):
|
||||
self.authenticate()
|
||||
search_value = self.get_random_string()
|
||||
archived_bookmarks = self.setup_numbered_bookmarks(
|
||||
5, archived=True, prefix=search_value
|
||||
)
|
||||
self.setup_numbered_bookmarks(5, archived=True)
|
||||
bundle = self.setup_bundle(search=search_value)
|
||||
|
||||
response = self.get(
|
||||
reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
|
||||
def test_list_archived_bookmarks_should_respect_sort(self):
|
||||
self.authenticate()
|
||||
bookmarks = self.setup_numbered_bookmarks(5, archived=True)
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.middlewares import LinkdingMiddleware
|
||||
from bookmarks.models import Bookmark, UserProfile, User
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.views import contexts
|
||||
|
||||
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
{label_content}
|
||||
</a>
|
||||
<span>|</span>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
contexts.BookmarkListContext
|
||||
] = contexts.ActiveBookmarkListContext,
|
||||
user: User | AnonymousUser = None,
|
||||
is_preview: bool = False,
|
||||
) -> str:
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
||||
middleware(request)
|
||||
|
||||
bookmark_list_context = context_type(request)
|
||||
search = BookmarkSearch.from_request(request, request.GET)
|
||||
bookmark_list_context = context_type(request, search)
|
||||
if is_preview:
|
||||
bookmark_list_context.is_preview = True
|
||||
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
|
||||
|
||||
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
|
||||
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
soup = self.make_soup(html)
|
||||
bookmarks = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(10, len(bookmarks))
|
||||
|
||||
def test_no_actions_rendered_when_is_preview(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.date_added = timezone.now() - relativedelta(days=8)
|
||||
bookmark.web_archive_snapshot_url = "https://example.com"
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_template(is_preview=True)
|
||||
|
||||
# Verify no actions are rendered
|
||||
self.assertNoViewLink(html, bookmark)
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
self.assertNotesToggle(html, count=0)
|
||||
|
||||
# But date should still be rendered
|
||||
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
321
bookmarks/tests/test_bundles_api.py
Normal file
321
bookmarks/tests/test_bundles_api.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from bookmarks.models import BookmarkBundle
|
||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
def assertBundle(self, bundle: BookmarkBundle, data: dict):
|
||||
self.assertEqual(bundle.id, data["id"])
|
||||
self.assertEqual(bundle.name, data["name"])
|
||||
self.assertEqual(bundle.search, data["search"])
|
||||
self.assertEqual(bundle.any_tags, data["any_tags"])
|
||||
self.assertEqual(bundle.all_tags, data["all_tags"])
|
||||
self.assertEqual(bundle.excluded_tags, data["excluded_tags"])
|
||||
self.assertEqual(bundle.order, data["order"])
|
||||
self.assertEqual(
|
||||
bundle.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
|
||||
)
|
||||
self.assertEqual(
|
||||
bundle.date_modified.isoformat().replace("+00:00", "Z"),
|
||||
data["date_modified"],
|
||||
)
|
||||
|
||||
def test_bundle_list(self):
|
||||
self.authenticate()
|
||||
|
||||
bundles = [
|
||||
self.setup_bundle(name="Bundle 1", order=0),
|
||||
self.setup_bundle(name="Bundle 2", order=1),
|
||||
self.setup_bundle(name="Bundle 3", order=2),
|
||||
]
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(response.data["results"]), 3)
|
||||
self.assertBundle(bundles[0], response.data["results"][0])
|
||||
self.assertBundle(bundles[1], response.data["results"][1])
|
||||
self.assertBundle(bundles[2], response.data["results"][2])
|
||||
|
||||
def test_bundle_list_only_returns_own_bundles(self):
|
||||
self.authenticate()
|
||||
|
||||
user_bundles = [
|
||||
self.setup_bundle(name="User Bundle 1"),
|
||||
self.setup_bundle(name="User Bundle 2"),
|
||||
]
|
||||
|
||||
other_user = self.setup_user()
|
||||
self.setup_bundle(name="Other User Bundle 1", user=other_user)
|
||||
self.setup_bundle(name="Other User Bundle 2", user=other_user)
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(response.data["results"]), 2)
|
||||
self.assertBundle(user_bundles[0], response.data["results"][0])
|
||||
self.assertBundle(user_bundles[1], response.data["results"][1])
|
||||
|
||||
def test_bundle_list_requires_authentication(self):
|
||||
url = reverse("linkding:bundle-list")
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_bundle_detail(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle = self.setup_bundle(
|
||||
name="Test Bundle",
|
||||
search="test search",
|
||||
any_tags="tag1 tag2",
|
||||
all_tags="required-tag",
|
||||
excluded_tags="excluded-tag",
|
||||
order=5,
|
||||
)
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertBundle(bundle, response.data)
|
||||
|
||||
def test_bundle_detail_only_returns_own_bundles(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_bundle_detail_requires_authentication(self):
|
||||
bundle = self.setup_bundle()
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_create_bundle(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle_data = {
|
||||
"name": "New Bundle",
|
||||
"search": "test search",
|
||||
"any_tags": "tag1 tag2",
|
||||
"all_tags": "required-tag",
|
||||
"excluded_tags": "excluded-tag",
|
||||
}
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.post(
|
||||
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
bundle = BookmarkBundle.objects.get(id=response.data["id"])
|
||||
self.assertEqual(bundle.name, bundle_data["name"])
|
||||
self.assertEqual(bundle.search, bundle_data["search"])
|
||||
self.assertEqual(bundle.any_tags, bundle_data["any_tags"])
|
||||
self.assertEqual(bundle.all_tags, bundle_data["all_tags"])
|
||||
self.assertEqual(bundle.excluded_tags, bundle_data["excluded_tags"])
|
||||
self.assertEqual(bundle.owner, self.user)
|
||||
self.assertEqual(bundle.order, 0)
|
||||
|
||||
self.assertBundle(bundle, response.data)
|
||||
|
||||
def test_create_bundle_auto_increments_order(self):
|
||||
self.authenticate()
|
||||
|
||||
self.setup_bundle(name="Existing Bundle", order=2)
|
||||
|
||||
bundle_data = {"name": "New Bundle", "search": "test search"}
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.post(
|
||||
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
bundle = BookmarkBundle.objects.get(id=response.data["id"])
|
||||
self.assertEqual(bundle.order, 3)
|
||||
|
||||
def test_create_bundle_with_custom_order(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle_data = {"name": "New Bundle", "order": 10}
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.post(
|
||||
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
bundle = BookmarkBundle.objects.get(id=response.data["id"])
|
||||
self.assertEqual(bundle.order, 10)
|
||||
|
||||
def test_create_bundle_requires_name(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle_data = {"search": "test search"}
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
self.post(url, bundle_data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_create_bundle_fields_can_be_empty(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle_data = {
|
||||
"name": "Minimal Bundle",
|
||||
"search": "",
|
||||
"any_tags": "",
|
||||
"all_tags": "",
|
||||
"excluded_tags": "",
|
||||
}
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.post(
|
||||
url, bundle_data, expected_status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
bundle = BookmarkBundle.objects.get(id=response.data["id"])
|
||||
self.assertEqual(bundle.name, "Minimal Bundle")
|
||||
self.assertEqual(bundle.search, "")
|
||||
self.assertEqual(bundle.any_tags, "")
|
||||
self.assertEqual(bundle.all_tags, "")
|
||||
self.assertEqual(bundle.excluded_tags, "")
|
||||
|
||||
def test_create_bundle_requires_authentication(self):
|
||||
bundle_data = {"name": "New Bundle"}
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
self.post(url, bundle_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_update_bundle_put(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle = self.setup_bundle(
|
||||
name="Original Bundle",
|
||||
search="original search",
|
||||
any_tags="original-tag",
|
||||
order=1,
|
||||
)
|
||||
|
||||
updated_data = {
|
||||
"name": "Updated Bundle",
|
||||
"search": "updated search",
|
||||
"any_tags": "updated-tag1 updated-tag2",
|
||||
"all_tags": "required-updated-tag",
|
||||
"excluded_tags": "excluded-updated-tag",
|
||||
"order": 5,
|
||||
}
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
response = self.put(url, updated_data, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
bundle.refresh_from_db()
|
||||
self.assertEqual(bundle.name, updated_data["name"])
|
||||
self.assertEqual(bundle.search, updated_data["search"])
|
||||
self.assertEqual(bundle.any_tags, updated_data["any_tags"])
|
||||
self.assertEqual(bundle.all_tags, updated_data["all_tags"])
|
||||
self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
|
||||
self.assertEqual(bundle.order, updated_data["order"])
|
||||
|
||||
self.assertBundle(bundle, response.data)
|
||||
|
||||
def test_update_bundle_patch(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle = self.setup_bundle(
|
||||
name="Original Bundle", search="original search", any_tags="original-tag"
|
||||
)
|
||||
|
||||
updated_data = {
|
||||
"name": "Partially Updated Bundle",
|
||||
"search": "partially updated search",
|
||||
}
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
response = self.patch(
|
||||
url, updated_data, expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
bundle.refresh_from_db()
|
||||
self.assertEqual(bundle.name, updated_data["name"])
|
||||
self.assertEqual(bundle.search, updated_data["search"])
|
||||
self.assertEqual(bundle.any_tags, "original-tag") # Should remain unchanged
|
||||
|
||||
self.assertBundle(bundle, response.data)
|
||||
|
||||
def test_update_bundle_only_allows_own_bundles(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
|
||||
|
||||
updated_data = {"name": "Updated Bundle"}
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
|
||||
self.put(url, updated_data, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_update_bundle_requires_authentication(self):
|
||||
bundle = self.setup_bundle()
|
||||
updated_data = {"name": "Updated Bundle"}
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
self.put(url, updated_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_bundle(self):
|
||||
self.authenticate()
|
||||
|
||||
bundle = self.setup_bundle(name="Bundle to Delete")
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
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()
|
||||
|
||||
other_user = self.setup_user()
|
||||
other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
|
||||
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
|
||||
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
self.assertTrue(BookmarkBundle.objects.filter(id=other_bundle.id).exists())
|
||||
|
||||
def test_delete_bundle_requires_authentication(self):
|
||||
bundle = self.setup_bundle()
|
||||
url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
|
||||
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
|
||||
|
||||
def test_bundles_ordered_by_order_field(self):
|
||||
self.authenticate()
|
||||
|
||||
self.setup_bundle(name="Third Bundle", order=2)
|
||||
self.setup_bundle(name="First Bundle", order=0)
|
||||
self.setup_bundle(name="Second Bundle", order=1)
|
||||
|
||||
url = reverse("linkding:bundle-list")
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(response.data["results"]), 3)
|
||||
self.assertEqual(response.data["results"][0]["name"], "First Bundle")
|
||||
self.assertEqual(response.data["results"][1]["name"], "Second Bundle")
|
||||
self.assertEqual(response.data["results"][2]["name"], "Third Bundle")
|
||||
160
bookmarks/tests/test_bundles_edit_view.py
Normal file
160
bookmarks/tests/test_bundles_edit_view.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def create_form_data(self, overrides=None):
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
form_data = {
|
||||
"name": "Test Bundle",
|
||||
"search": "test search",
|
||||
"any_tags": "tag1 tag2",
|
||||
"all_tags": "required-tag",
|
||||
"excluded_tags": "excluded-tag",
|
||||
}
|
||||
return {**form_data, **overrides}
|
||||
|
||||
def test_should_edit_bundle(self):
|
||||
bundle = self.setup_bundle()
|
||||
|
||||
updated_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:bundles.edit", args=[bundle.id]), updated_data
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:bundles.index"))
|
||||
|
||||
bundle.refresh_from_db()
|
||||
self.assertEqual(bundle.name, updated_data["name"])
|
||||
self.assertEqual(bundle.search, updated_data["search"])
|
||||
self.assertEqual(bundle.any_tags, updated_data["any_tags"])
|
||||
self.assertEqual(bundle.all_tags, updated_data["all_tags"])
|
||||
self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
|
||||
|
||||
def test_should_render_edit_form_with_prefilled_fields(self):
|
||||
bundle = self.setup_bundle(
|
||||
name="Test Bundle",
|
||||
search="test search terms",
|
||||
any_tags="tag1 tag2 tag3",
|
||||
all_tags="required-tag all-tag",
|
||||
excluded_tags="excluded-tag banned-tag",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="name" value="{bundle.name}" '
|
||||
'autocomplete="off" placeholder=" " class="form-input" '
|
||||
'maxlength="256" required id="id_name">',
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="search" value="{bundle.search}" '
|
||||
'autocomplete="off" placeholder=" " class="form-input" '
|
||||
'maxlength="256" id="id_search">',
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="any_tags" value="{bundle.any_tags}" '
|
||||
'autocomplete="off" autocapitalize="off" class="form-input" '
|
||||
'maxlength="1024" id="id_any_tags">',
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="all_tags" value="{bundle.all_tags}" '
|
||||
'autocomplete="off" autocapitalize="off" class="form-input" '
|
||||
'maxlength="1024" id="id_all_tags">',
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="excluded_tags" value="{bundle.excluded_tags}" '
|
||||
'autocomplete="off" autocapitalize="off" class="form-input" '
|
||||
'maxlength="1024" id="id_excluded_tags">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_return_422_with_invalid_form(self):
|
||||
bundle = self.setup_bundle(
|
||||
name="Test Bundle",
|
||||
search="test search",
|
||||
any_tags="tag1 tag2",
|
||||
all_tags="required-tag",
|
||||
excluded_tags="excluded-tag",
|
||||
)
|
||||
|
||||
invalid_data = self.create_form_data({"name": ""})
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:bundles.edit", args=[bundle.id]), invalid_data
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_should_not_allow_editing_other_users_bundles(self):
|
||||
other_user = self.setup_user(name="otheruser")
|
||||
other_users_bundle = self.setup_bundle(user=other_user)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bundles.edit", args=[other_users_bundle.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
updated_data = self.create_form_data()
|
||||
response = self.client.post(
|
||||
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())
|
||||
210
bookmarks/tests/test_bundles_index_view.py
Normal file
210
bookmarks/tests/test_bundles_index_view.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import BookmarkBundle
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def test_render_bundle_list(self):
|
||||
bundles = [
|
||||
self.setup_bundle(name="Bundle 1"),
|
||||
self.setup_bundle(name="Bundle 2"),
|
||||
self.setup_bundle(name="Bundle 3"),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
for bundle in bundles:
|
||||
expected_list_item = f"""
|
||||
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{bundle.name}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
self.assertInHTML(expected_list_item, html)
|
||||
|
||||
def test_renders_user_owned_bundles_only(self):
|
||||
user_bundle = self.setup_bundle(name="User Bundle")
|
||||
|
||||
other_user = self.setup_user(name="otheruser")
|
||||
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
|
||||
self.assertNotIn(other_user_bundle.name, html)
|
||||
|
||||
def test_empty_state(self):
|
||||
response = self.client.get(reverse("linkding:bundles.index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<p class="empty-title h5">You have no bundles yet</p>', html)
|
||||
self.assertInHTML(
|
||||
'<p class="empty-subtitle">Create your first bundle to get started</p>',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_add_new_button(self):
|
||||
response = self.client.get(reverse("linkding:bundles.index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_remove_bundle(self):
|
||||
bundle = self.setup_bundle(name="Test Bundle")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:bundles.action"),
|
||||
{"remove_bundle": str(bundle.id)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertRedirects(response, reverse("linkding:bundles.index"))
|
||||
|
||||
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)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:bundles.action"),
|
||||
{"remove_bundle": str(other_user_bundle.id)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())
|
||||
|
||||
def assertBundleOrder(self, expected_bundles, user=None):
|
||||
if user is None:
|
||||
user = self.user
|
||||
actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by("order")
|
||||
self.assertEqual(len(actual_bundles), len(expected_bundles))
|
||||
for i, bundle in enumerate(expected_bundles):
|
||||
self.assertEqual(actual_bundles[i].id, bundle.id)
|
||||
self.assertEqual(actual_bundles[i].order, i)
|
||||
|
||||
def move_bundle(self, bundle: BookmarkBundle, position: int):
|
||||
return self.client.post(
|
||||
reverse("linkding:bundles.action"),
|
||||
{"move_bundle": str(bundle.id), "move_position": position},
|
||||
)
|
||||
|
||||
def test_move_bundle(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.move_bundle(bundle1, 1)
|
||||
self.assertBundleOrder([bundle2, bundle1, bundle3])
|
||||
|
||||
self.move_bundle(bundle1, 0)
|
||||
self.assertBundleOrder([bundle1, bundle2, bundle3])
|
||||
|
||||
self.move_bundle(bundle1, 2)
|
||||
self.assertBundleOrder([bundle2, bundle3, bundle1])
|
||||
|
||||
self.move_bundle(bundle1, 2)
|
||||
self.assertBundleOrder([bundle2, bundle3, bundle1])
|
||||
|
||||
def test_move_bundle_response(self):
|
||||
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
|
||||
self.setup_bundle(name="Bundle 2", order=1)
|
||||
|
||||
response = self.move_bundle(bundle1, 1)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertRedirects(response, reverse("linkding:bundles.index"))
|
||||
|
||||
def test_can_only_move_user_owned_bundles(self):
|
||||
other_user = self.setup_user()
|
||||
other_user_bundle1 = self.setup_bundle(user=other_user)
|
||||
self.setup_bundle(user=other_user)
|
||||
|
||||
response = self.move_bundle(other_user_bundle1, 1)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_move_bundle_only_affects_own_bundles(self):
|
||||
user_bundle1 = self.setup_bundle(name="User Bundle 1", order=0)
|
||||
user_bundle2 = self.setup_bundle(name="User Bundle 2", order=1)
|
||||
|
||||
other_user = self.setup_user(name="otheruser")
|
||||
other_user_bundle = self.setup_bundle(
|
||||
name="Other User Bundle", user=other_user, order=0
|
||||
)
|
||||
|
||||
# Move user bundle
|
||||
self.move_bundle(user_bundle1, 1)
|
||||
self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)
|
||||
|
||||
# Check that other user's bundle is unaffected
|
||||
self.assertBundleOrder([other_user_bundle], user=other_user)
|
||||
|
||||
def test_remove_non_existing_bundle(self):
|
||||
non_existent_id = 99999
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:bundles.action"),
|
||||
{"remove_bundle": str(non_existent_id)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_post_without_action(self):
|
||||
bundle = self.setup_bundle(name="Test Bundle")
|
||||
|
||||
response = self.client.post(reverse("linkding:bundles.action"), {})
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertRedirects(response, reverse("linkding:bundles.index"))
|
||||
|
||||
self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
|
||||
147
bookmarks/tests/test_bundles_new_view.py
Normal file
147
bookmarks/tests/test_bundles_new_view.py
Normal file
@@ -0,0 +1,147 @@
|
||||
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, HtmlTestMixin
|
||||
|
||||
|
||||
class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def create_form_data(self, overrides=None):
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
form_data = {
|
||||
"name": "Test Bundle",
|
||||
"search": "test search",
|
||||
"any_tags": "tag1 tag2",
|
||||
"all_tags": "required-tag",
|
||||
"excluded_tags": "excluded-tag",
|
||||
}
|
||||
return {**form_data, **overrides}
|
||||
|
||||
def test_should_create_new_bundle(self):
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(reverse("linkding:bundles.new"), form_data)
|
||||
|
||||
self.assertEqual(BookmarkBundle.objects.count(), 1)
|
||||
|
||||
bundle = BookmarkBundle.objects.first()
|
||||
self.assertEqual(bundle.owner, self.user)
|
||||
self.assertEqual(bundle.name, form_data["name"])
|
||||
self.assertEqual(bundle.search, form_data["search"])
|
||||
self.assertEqual(bundle.any_tags, form_data["any_tags"])
|
||||
self.assertEqual(bundle.all_tags, form_data["all_tags"])
|
||||
self.assertEqual(bundle.excluded_tags, form_data["excluded_tags"])
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:bundles.index"))
|
||||
|
||||
def test_should_increment_order_for_subsequent_bundles(self):
|
||||
# Create first bundle
|
||||
form_data_1 = self.create_form_data({"name": "Bundle 1"})
|
||||
self.client.post(reverse("linkding:bundles.new"), form_data_1)
|
||||
bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
|
||||
self.assertEqual(bundle1.order, 0)
|
||||
|
||||
# Create second bundle
|
||||
form_data_2 = self.create_form_data({"name": "Bundle 2"})
|
||||
self.client.post(reverse("linkding:bundles.new"), form_data_2)
|
||||
bundle2 = BookmarkBundle.objects.get(name="Bundle 2")
|
||||
self.assertEqual(bundle2.order, 1)
|
||||
|
||||
# Create another bundle with a higher order
|
||||
self.setup_bundle(order=5)
|
||||
|
||||
# Create third bundle
|
||||
form_data_3 = self.create_form_data({"name": "Bundle 3"})
|
||||
self.client.post(reverse("linkding:bundles.new"), form_data_3)
|
||||
bundle3 = BookmarkBundle.objects.get(name="Bundle 3")
|
||||
self.assertEqual(bundle3.order, 6)
|
||||
|
||||
def test_incrementing_order_ignores_other_user_bookmark(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_bundle(user=other_user, order=10)
|
||||
|
||||
form_data = self.create_form_data({"name": "Bundle 1"})
|
||||
self.client.post(reverse("linkding:bundles.new"), form_data)
|
||||
bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
|
||||
self.assertEqual(bundle1.order, 0)
|
||||
|
||||
def test_should_return_422_with_invalid_form(self):
|
||||
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)
|
||||
116
bookmarks/tests/test_bundles_preview_view.py
Normal file
116
bookmarks/tests/test_bundles_preview_view.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def test_preview_empty_bundle(self):
|
||||
bookmark1 = self.setup_bookmark(title="Test Bookmark 1")
|
||||
bookmark2 = self.setup_bookmark(title="Test Bookmark 2")
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.preview"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Found 2 bookmarks matching this bundle")
|
||||
self.assertContains(response, bookmark1.title)
|
||||
self.assertContains(response, bookmark2.title)
|
||||
self.assertNotContains(response, "No bookmarks match the current bundle")
|
||||
|
||||
def test_preview_with_search_terms(self):
|
||||
bookmark1 = self.setup_bookmark(title="Python Programming")
|
||||
bookmark2 = self.setup_bookmark(title="JavaScript Tutorial")
|
||||
bookmark3 = self.setup_bookmark(title="Django Framework")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bundles.preview"), {"search": "python"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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_preview_no_matching_bookmarks(self):
|
||||
bookmark = self.setup_bookmark(title="Python Guide")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bundles.preview"), {"search": "nonexistent"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "No bookmarks match the current bundle")
|
||||
self.assertNotContains(response, bookmark.title)
|
||||
|
||||
def test_preview_renders_bookmark(self):
|
||||
tag = self.setup_tag(name="test-tag")
|
||||
bookmark = self.setup_bookmark(
|
||||
title="Test Bookmark",
|
||||
description="Test description",
|
||||
url="https://example.com/test",
|
||||
tags=[tag],
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.preview"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, bookmark.title)
|
||||
self.assertContains(response, bookmark.description)
|
||||
self.assertContains(response, bookmark.url)
|
||||
self.assertContains(response, "#test-tag")
|
||||
|
||||
def test_preview_renders_bookmark_in_preview_mode(self):
|
||||
tag = self.setup_tag(name="test-tag")
|
||||
self.setup_bookmark(
|
||||
title="Test Bookmark",
|
||||
description="Test description",
|
||||
url="https://example.com/test",
|
||||
tags=[tag],
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.preview"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
actions = list_item.select(".actions > *")
|
||||
self.assertEqual(len(actions), 1)
|
||||
|
||||
def test_preview_ignores_archived_bookmarks(self):
|
||||
active_bookmark = self.setup_bookmark(title="Active Bookmark")
|
||||
archived_bookmark = self.setup_bookmark(
|
||||
title="Archived Bookmark", is_archived=True
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.preview"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Found 1 bookmarks matching this bundle")
|
||||
self.assertContains(response, active_bookmark.title)
|
||||
self.assertNotContains(response, archived_bookmark.title)
|
||||
|
||||
def test_preview_requires_authentication(self):
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.preview"), follow=True)
|
||||
|
||||
self.assertRedirects(
|
||||
response, f"/login/?next={reverse('linkding:bundles.preview')}"
|
||||
)
|
||||
|
||||
def test_preview_only_shows_user_bookmarks(self):
|
||||
other_user = self.setup_user()
|
||||
own_bookmark = self.setup_bookmark(title="Own Bookmark")
|
||||
other_bookmark = self.setup_bookmark(title="Other Bookmark", user=other_user)
|
||||
|
||||
response = self.client.get(reverse("linkding:bundles.preview"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Found 1 bookmarks matching this bundle")
|
||||
self.assertContains(response, own_bookmark.title)
|
||||
self.assertNotContains(response, other_bookmark.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(
|
||||
|
||||
@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def assertPrevLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else "?page={0}".format(page_number)
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item">
|
||||
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else "?page={0}".format(page_number)
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item">
|
||||
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
href: str = None,
|
||||
):
|
||||
active_class = "active" if active else ""
|
||||
href = href if href else "?page={0}".format(page_number)
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item {1}">
|
||||
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
rendered_template = self.render_template(
|
||||
100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
|
||||
)
|
||||
self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
|
||||
self.assertPageLink(
|
||||
rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
|
||||
self.assertPrevLink(
|
||||
rendered_template,
|
||||
1,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
|
||||
rendered_template,
|
||||
1,
|
||||
False,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template,
|
||||
2,
|
||||
True,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=2",
|
||||
)
|
||||
self.assertNextLink(
|
||||
rendered_template,
|
||||
3,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=3",
|
||||
)
|
||||
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
|
||||
|
||||
def test_removes_details_parameter(self):
|
||||
rendered_template = self.render_template(
|
||||
100, 10, 2, url="/test?details=1&page=2"
|
||||
)
|
||||
self.assertPrevLink(rendered_template, 1, href="?page=1")
|
||||
self.assertPageLink(rendered_template, 1, False, href="?page=1")
|
||||
self.assertPageLink(rendered_template, 2, True, href="?page=2")
|
||||
self.assertNextLink(rendered_template, 3, href="?page=3")
|
||||
self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
|
||||
self.assertPageLink(
|
||||
rendered_template, 1, False, href="http://testserver/test?page=1"
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template, 2, True, href="http://testserver/test?page=2"
|
||||
)
|
||||
self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")
|
||||
|
||||
@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
|
||||
]
|
||||
|
||||
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
|
||||
def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
|
||||
expected_items = []
|
||||
for item_list in item_lists:
|
||||
expected_items = expected_items + item_list
|
||||
@@ -1211,3 +1211,343 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertEqual(list(query), sorted_bookmarks)
|
||||
|
||||
def test_query_bookmarks_filter_modified_since(self):
|
||||
# Create bookmarks with different modification dates
|
||||
older_bookmark = self.setup_bookmark(title="old bookmark")
|
||||
recent_bookmark = self.setup_bookmark(title="recent bookmark")
|
||||
|
||||
# Modify date field on bookmark directly to test modified_since
|
||||
older_bookmark.date_modified = timezone.datetime(
|
||||
2025, 1, 1, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
older_bookmark.save()
|
||||
recent_bookmark.date_modified = timezone.datetime(
|
||||
2025, 5, 15, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
recent_bookmark.save()
|
||||
|
||||
# Test with date between the two bookmarks
|
||||
search = BookmarkSearch(modified_since="2025-03-01T00:00:00Z")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [recent_bookmark])
|
||||
|
||||
# Test with date before both bookmarks
|
||||
search = BookmarkSearch(modified_since="2024-12-31T00:00:00Z")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
|
||||
|
||||
# Test with date after both bookmarks
|
||||
search = BookmarkSearch(modified_since="2025-05-16T00:00:00Z")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
# Test with no modified_since - should return all bookmarks
|
||||
search = BookmarkSearch()
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
|
||||
|
||||
# Test with invalid date format - should be ignored
|
||||
search = BookmarkSearch(modified_since="invalid-date")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
|
||||
|
||||
def test_query_bookmarks_filter_added_since(self):
|
||||
# Create bookmarks with different dates
|
||||
older_bookmark = self.setup_bookmark(
|
||||
title="old bookmark",
|
||||
added=timezone.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
recent_bookmark = self.setup_bookmark(
|
||||
title="recent bookmark",
|
||||
added=timezone.datetime(2025, 5, 15, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
|
||||
# Test with date between the two bookmarks
|
||||
search = BookmarkSearch(added_since="2025-03-01T00:00:00Z")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [recent_bookmark])
|
||||
|
||||
# Test with date before both bookmarks
|
||||
search = BookmarkSearch(added_since="2024-12-31T00:00:00Z")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
|
||||
|
||||
# Test with date after both bookmarks
|
||||
search = BookmarkSearch(added_since="2025-05-16T00:00:00Z")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
# Test with no added_since - should return all bookmarks
|
||||
search = BookmarkSearch()
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
|
||||
|
||||
# Test with invalid date format - should be ignored
|
||||
search = BookmarkSearch(added_since="invalid-date")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
|
||||
|
||||
def test_query_bookmarks_with_bundle_search_terms(self):
|
||||
bundle = self.setup_bundle(search="search_term_A search_term_B")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(
|
||||
title="search_term_A content", description="search_term_B also here"
|
||||
),
|
||||
self.setup_bookmark(url="http://example.com/search_term_A/search_term_B"),
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(title="search_term_A only")
|
||||
self.setup_bookmark(description="search_term_B only")
|
||||
self.setup_bookmark(title="unrelated content")
|
||||
|
||||
query = queries.query_bookmarks(
|
||||
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_search_and_bundle_search_terms(self):
|
||||
bundle = self.setup_bundle(search="bundle_term_B")
|
||||
search = BookmarkSearch(q="search_term_A", bundle=bundle)
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(
|
||||
title="search_term_A content", description="bundle_term_B also here"
|
||||
)
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(title="search_term_A only")
|
||||
self.setup_bookmark(description="bundle_term_B only")
|
||||
self.setup_bookmark(title="unrelated content")
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_bundle_any_tags(self):
|
||||
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
|
||||
|
||||
tag1 = self.setup_tag(name="bundleTag1")
|
||||
tag2 = self.setup_tag(name="bundleTag2")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(tags=[tag1]),
|
||||
self.setup_bookmark(tags=[tag2]),
|
||||
self.setup_bookmark(tags=[tag1, tag2]),
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(tags=[other_tag])
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(
|
||||
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):
|
||||
bundle = self.setup_bundle(any_tags="bundleTagA bundleTagB")
|
||||
search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
|
||||
|
||||
search_tag1 = self.setup_tag(name="searchTag1")
|
||||
search_tag2 = self.setup_tag(name="searchTag2")
|
||||
bundle_tag_a = self.setup_tag(name="bundleTagA")
|
||||
bundle_tag_b = self.setup_tag(name="bundleTagB")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),
|
||||
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),
|
||||
self.setup_bookmark(
|
||||
tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
|
||||
),
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])
|
||||
self.setup_bookmark(tags=[search_tag1, search_tag2])
|
||||
self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
|
||||
self.setup_bookmark(tags=[search_tag2, bundle_tag_b])
|
||||
self.setup_bookmark(tags=[bundle_tag_a])
|
||||
self.setup_bookmark(tags=[bundle_tag_b])
|
||||
self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
|
||||
self.setup_bookmark(tags=[other_tag])
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_bundle_all_tags(self):
|
||||
bundle = self.setup_bundle(all_tags="bundleTag1 bundleTag2")
|
||||
|
||||
tag1 = self.setup_tag(name="bundleTag1")
|
||||
tag2 = self.setup_tag(name="bundleTag2")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
|
||||
matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag2])
|
||||
self.setup_bookmark(tags=[tag1, other_tag])
|
||||
self.setup_bookmark(tags=[other_tag])
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(
|
||||
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):
|
||||
bundle = self.setup_bundle(all_tags="bundleTagA bundleTagB")
|
||||
search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
|
||||
|
||||
search_tag1 = self.setup_tag(name="searchTag1")
|
||||
search_tag2 = self.setup_tag(name="searchTag2")
|
||||
bundle_tag_a = self.setup_tag(name="bundleTagA")
|
||||
bundle_tag_b = self.setup_tag(name="bundleTagB")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(
|
||||
tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
|
||||
)
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])
|
||||
self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])
|
||||
self.setup_bookmark(tags=[search_tag1, search_tag2])
|
||||
self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
|
||||
self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
|
||||
self.setup_bookmark(tags=[other_tag])
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_bundle_excluded_tags(self):
|
||||
bundle = self.setup_bundle(excluded_tags="excludeTag1 excludeTag2")
|
||||
|
||||
exclude_tag1 = self.setup_tag(name="excludeTag1")
|
||||
exclude_tag2 = self.setup_tag(name="excludeTag2")
|
||||
keep_tag = self.setup_tag(name="keepTag")
|
||||
keep_other_tag = self.setup_tag(name="keepOtherTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(tags=[keep_tag]),
|
||||
self.setup_bookmark(tags=[keep_other_tag]),
|
||||
self.setup_bookmark(tags=[keep_tag, keep_other_tag]),
|
||||
self.setup_bookmark(),
|
||||
]
|
||||
|
||||
# Bookmarks that should not be returned
|
||||
self.setup_bookmark(tags=[exclude_tag1])
|
||||
self.setup_bookmark(tags=[exclude_tag2])
|
||||
self.setup_bookmark(tags=[exclude_tag1, keep_tag])
|
||||
self.setup_bookmark(tags=[exclude_tag2, keep_tag])
|
||||
self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])
|
||||
self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])
|
||||
|
||||
query = queries.query_bookmarks(
|
||||
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_bookmarks_with_bundle_combined_tags(self):
|
||||
bundle = self.setup_bundle(
|
||||
any_tags="anyTagA anyTagB",
|
||||
all_tags="allTag1 allTag2",
|
||||
excluded_tags="excludedTag",
|
||||
)
|
||||
|
||||
any_tag_a = self.setup_tag(name="anyTagA")
|
||||
any_tag_b = self.setup_tag(name="anyTagB")
|
||||
all_tag_1 = self.setup_tag(name="allTag1")
|
||||
all_tag_2 = self.setup_tag(name="allTag2")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
excluded_tag = self.setup_tag(name="excludedTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),
|
||||
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),
|
||||
self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),
|
||||
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),
|
||||
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(tags=[any_tag_a, all_tag_1])
|
||||
self.setup_bookmark(tags=[any_tag_b, all_tag_2])
|
||||
self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])
|
||||
self.setup_bookmark(tags=[all_tag_1, all_tag_2])
|
||||
self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])
|
||||
self.setup_bookmark(tags=[any_tag_a])
|
||||
self.setup_bookmark(tags=[any_tag_b])
|
||||
self.setup_bookmark(tags=[all_tag_1])
|
||||
self.setup_bookmark(tags=[all_tag_2])
|
||||
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])
|
||||
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])
|
||||
self.setup_bookmark(tags=[other_tag])
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(
|
||||
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_archived_bookmarks_with_bundle(self):
|
||||
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
|
||||
|
||||
tag1 = self.setup_tag(name="bundleTag1")
|
||||
tag2 = self.setup_tag(name="bundleTag2")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True, tags=[tag1]),
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2]),
|
||||
self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(is_archived=True, tags=[other_tag])
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(tags=[tag1]),
|
||||
self.setup_bookmark(tags=[tag2]),
|
||||
self.setup_bookmark(tags=[tag1, tag2]),
|
||||
|
||||
query = queries.query_archived_bookmarks(
|
||||
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
def test_query_shared_bookmarks_with_bundle(self):
|
||||
user1 = self.setup_user(enable_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
|
||||
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
|
||||
|
||||
tag1 = self.setup_tag(name="bundleTag1")
|
||||
tag2 = self.setup_tag(name="bundleTag2")
|
||||
other_tag = self.setup_tag(name="otherTag")
|
||||
|
||||
matching_bookmarks = [
|
||||
self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
|
||||
self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
|
||||
self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),
|
||||
]
|
||||
|
||||
# Bookmarks that should not match
|
||||
self.setup_bookmark(user=user1, shared=True, tags=[other_tag])
|
||||
self.setup_bookmark(user=user2, shared=True)
|
||||
self.setup_bookmark(user=user1, shared=False, tags=[tag1]),
|
||||
self.setup_bookmark(user=user2, shared=False, tags=[tag2]),
|
||||
self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2]),
|
||||
|
||||
query = queries.query_shared_bookmarks(
|
||||
None, self.profile, BookmarkSearch(q="", bundle=bundle), False
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"items_per_page": "30",
|
||||
"sticky_pagination": False,
|
||||
"collapse_side_panel": False,
|
||||
"hide_bundles": False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"items_per_page": "10",
|
||||
"sticky_pagination": True,
|
||||
"collapse_side_panel": True,
|
||||
"hide_bundles": True,
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("linkding:settings.update"), form_data, follow=True
|
||||
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
|
||||
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from bookmarks.middlewares import LinkdingMiddleware
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.models import BookmarkSearch, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.views import contexts
|
||||
|
||||
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
||||
middleware(request)
|
||||
|
||||
tag_cloud_context = context_type(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
tag_cloud_context = context_type(request, search)
|
||||
context = RequestContext(request, {"tag_cloud": tag_cloud_context})
|
||||
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
|
||||
return template_to_render.render(context)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
# Should render toasts container
|
||||
self.assertContains(response, '<div class="toasts">')
|
||||
self.assertContains(response, '<div class="message-list">')
|
||||
# Should render two toasts
|
||||
self.assertContains(response, '<div class="toast d-flex">', count=2)
|
||||
|
||||
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
# Should not render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
|
||||
self.assertContains(response, '<div class="message-list">', count=0)
|
||||
# Should not render toasts
|
||||
self.assertContains(response, '<div class="toast">', count=0)
|
||||
|
||||
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
# Should not render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
|
||||
self.assertContains(response, '<div class="message-list">', count=0)
|
||||
# Should not render toasts
|
||||
self.assertContains(response, '<div class="toast">', count=0)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
request.user_profile = self.get_or_create_test_user().profile
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(request, request.GET)
|
||||
context = RequestContext(
|
||||
request,
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
50
bookmarks/tests_e2e/e2e_test_bundle_preview.py
Normal file
50
bookmarks/tests_e2e/e2e_test_bundle_preview.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_update_preview_on_filter_changes(self):
|
||||
group1 = self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
group2 = self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
with sync_playwright() as p:
|
||||
# shows all bookmarks initially
|
||||
page = self.open(reverse("linkding:bundles.new"), p)
|
||||
|
||||
expect(
|
||||
page.get_by_text(f"Found 6 bookmarks matching this bundle")
|
||||
).to_be_visible()
|
||||
self.assertVisibleBookmarks(group1 + group2)
|
||||
|
||||
# filter by group1
|
||||
search = page.get_by_label("Search")
|
||||
search.fill("foo")
|
||||
|
||||
expect(
|
||||
page.get_by_text(f"Found 3 bookmarks matching this bundle")
|
||||
).to_be_visible()
|
||||
self.assertVisibleBookmarks(group1)
|
||||
|
||||
# filter by group2
|
||||
search.fill("bar")
|
||||
|
||||
expect(
|
||||
page.get_by_text(f"Found 3 bookmarks matching this bundle")
|
||||
).to_be_visible()
|
||||
self.assertVisibleBookmarks(group2)
|
||||
|
||||
# filter by invalid group
|
||||
search.fill("invalid")
|
||||
|
||||
expect(
|
||||
page.get_by_text(f"No bookmarks match the current bundle")
|
||||
).to_be_visible()
|
||||
self.assertVisibleBookmarks([])
|
||||
|
||||
def assertVisibleBookmarks(self, bookmarks):
|
||||
self.assertEqual(len(bookmarks), self.count_bookmarks())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
expect(self.locate_bookmark(bookmark.title)).to_be_visible()
|
||||
@@ -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)
|
||||
|
||||
@@ -49,6 +49,10 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
def count_bookmarks(self):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
return bookmark_tags.count()
|
||||
|
||||
def locate_details_modal(self):
|
||||
return self.page.locator(".modal.bookmark-details")
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ urlpatterns = [
|
||||
views.assets.read,
|
||||
name="assets.read",
|
||||
),
|
||||
# Bundles
|
||||
path("bundles", views.bundles.index, name="bundles.index"),
|
||||
path("bundles/action", views.bundles.action, name="bundles.action"),
|
||||
path("bundles/new", views.bundles.new, name="bundles.new"),
|
||||
path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
|
||||
path("bundles/preview", views.bundles.preview, name="bundles.preview"),
|
||||
# Settings
|
||||
path("settings", views.settings.general, name="settings.index"),
|
||||
path("settings/general", views.settings.general, name="settings.general"),
|
||||
@@ -64,6 +70,7 @@ urlpatterns = [
|
||||
include(api_routes.bookmark_asset_router.urls),
|
||||
),
|
||||
path("api/tags/", include(api_routes.tag_router.urls)),
|
||||
path("api/bundles/", include(api_routes.bundle_router.urls)),
|
||||
path("api/user/", include(api_routes.user_router.urls)),
|
||||
# Feeds
|
||||
path("feeds/<str:feed_key>/all", feeds.AllBookmarksFeed(), name="feeds.all"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .assets import *
|
||||
from .auth import *
|
||||
from .bookmarks import *
|
||||
from . import bundles
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
from .health import health
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.http import Http404
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Toast
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ 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, ValueError):
|
||||
raise Http404("Bundle does not exist")
|
||||
|
||||
|
||||
def asset_read(request: HttpRequest, asset_id: int | str):
|
||||
try:
|
||||
asset = BookmarkAsset.objects.get(pk=asset_id)
|
||||
|
||||
@@ -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
|
||||
@@ -42,8 +43,12 @@ def index(request: HttpRequest):
|
||||
if request.method == "POST":
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
|
||||
bundles = contexts.BundlesContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request, search)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.ActiveBookmarkDetailsContext
|
||||
)
|
||||
@@ -54,6 +59,7 @@ def index(request: HttpRequest):
|
||||
{
|
||||
"page_title": "Bookmarks - Linkding",
|
||||
"bookmark_list": bookmark_list,
|
||||
"bundles": bundles,
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
},
|
||||
@@ -65,8 +71,12 @@ def archived(request: HttpRequest):
|
||||
if request.method == "POST":
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
|
||||
bundles = contexts.BundlesContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.ArchivedBookmarkDetailsContext
|
||||
)
|
||||
@@ -77,6 +87,7 @@ def archived(request: HttpRequest):
|
||||
{
|
||||
"page_title": "Archived bookmarks - Linkding",
|
||||
"bookmark_list": bookmark_list,
|
||||
"bundles": bundles,
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
},
|
||||
@@ -87,8 +98,11 @@ def shared(request: HttpRequest):
|
||||
if request.method == "POST":
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request, search)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.SharedBookmarkDetailsContext
|
||||
)
|
||||
@@ -132,13 +146,13 @@ def search_action(request: HttpRequest):
|
||||
if "save" in request.POST:
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseForbidden()
|
||||
search = BookmarkSearch.from_request(request.POST)
|
||||
search = BookmarkSearch.from_request(request, request.POST)
|
||||
request.user_profile.search_preferences = search.preferences_dict
|
||||
request.user_profile.save()
|
||||
|
||||
# redirect to base url including new query params
|
||||
search = BookmarkSearch.from_request(
|
||||
request.POST, request.user_profile.search_preferences
|
||||
request, request.POST, request.user_profile.search_preferences
|
||||
)
|
||||
base_url = request.path
|
||||
query_params = search.query_params
|
||||
@@ -248,7 +262,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
|
||||
|
||||
@login_required
|
||||
def index_action(request: HttpRequest):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
||||
|
||||
response = handle_action(request, query)
|
||||
@@ -263,7 +279,9 @@ def index_action(request: HttpRequest):
|
||||
|
||||
@login_required
|
||||
def archived_action(request: HttpRequest):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||
|
||||
response = handle_action(request, query)
|
||||
@@ -351,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
|
||||
|
||||
119
bookmarks/views/bundles.py
Normal file
119
bookmarks/views/bundles.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request: HttpRequest):
|
||||
bundles = BookmarkBundle.objects.filter(owner=request.user).order_by("order")
|
||||
context = {"bundles": bundles}
|
||||
return render(request, "bundles/index.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def action(request: HttpRequest):
|
||||
if "remove_bundle" in request.POST:
|
||||
remove_bundle_id = request.POST.get("remove_bundle")
|
||||
bundle = access.bundle_write(request, remove_bundle_id)
|
||||
bundle_name = bundle.name
|
||||
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")
|
||||
bundle_to_move = access.bundle_write(request, bundle_id)
|
||||
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
|
||||
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)
|
||||
|
||||
if bundle is None:
|
||||
instance.order = None
|
||||
bundles.create_bundle(instance, request.user)
|
||||
else:
|
||||
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, initial_data)
|
||||
context = {
|
||||
"form": form,
|
||||
"bundle": bundle,
|
||||
"bookmark_list": bookmark_list,
|
||||
}
|
||||
|
||||
return render(request, template, context, status=status)
|
||||
|
||||
|
||||
@login_required
|
||||
def new(request: HttpRequest):
|
||||
return _handle_edit(request, "bundles/new.html")
|
||||
|
||||
|
||||
@login_required
|
||||
def edit(request: HttpRequest, bundle_id: int):
|
||||
bundle = access.bundle_write(request, bundle_id)
|
||||
|
||||
return _handle_edit(request, "bundles/edit.html", bundle)
|
||||
|
||||
|
||||
@login_required
|
||||
def preview(request: HttpRequest):
|
||||
bookmark_list = _get_bookmark_list_preview(request)
|
||||
context = {"bookmark_list": bookmark_list}
|
||||
return render(request, "bundles/preview.html", context)
|
||||
|
||||
|
||||
def _get_bookmark_list_preview(
|
||||
request: HttpRequest,
|
||||
bundle: BookmarkBundle | None = None,
|
||||
initial_data: dict = None,
|
||||
):
|
||||
if request.method == "GET" and bundle:
|
||||
preview_bundle = bundle
|
||||
else:
|
||||
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)
|
||||
|
||||
search = BookmarkSearch(bundle=preview_bundle)
|
||||
bookmark_list = ActiveBookmarkListContext(request, search)
|
||||
bookmark_list.is_preview = True
|
||||
return bookmark_list
|
||||
@@ -13,6 +13,7 @@ from bookmarks import utils
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
User,
|
||||
UserProfile,
|
||||
@@ -178,15 +179,13 @@ class BookmarkItem:
|
||||
class BookmarkListContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
|
||||
request_context = self.request_context(request)
|
||||
user = request.user
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
self.search = BookmarkSearch.from_request(
|
||||
self.request.GET, user_profile.search_preferences
|
||||
)
|
||||
self.search = search
|
||||
|
||||
query_set = request_context.get_bookmark_query_set(self.search)
|
||||
page_number = request.GET.get("page")
|
||||
@@ -219,6 +218,8 @@ class BookmarkListContext:
|
||||
self.show_preview_images = user_profile.enable_preview_images
|
||||
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):
|
||||
@@ -315,14 +316,12 @@ class TagGroup:
|
||||
class TagCloudContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
|
||||
request_context = self.request_context(request)
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
self.search = BookmarkSearch.from_request(
|
||||
self.request.GET, user_profile.search_preferences
|
||||
)
|
||||
self.search = search
|
||||
|
||||
query_set = request_context.get_tag_query_set(self.search)
|
||||
tags = list(query_set)
|
||||
@@ -371,6 +370,7 @@ class BookmarkAssetItem:
|
||||
self.asset_type = asset.asset_type
|
||||
self.file = asset.file
|
||||
self.file_size = asset.file_size
|
||||
self.content_type = asset.content_type
|
||||
self.status = asset.status
|
||||
|
||||
icon_classes = []
|
||||
@@ -461,3 +461,23 @@ def get_details_context(
|
||||
return None
|
||||
|
||||
return context_type(request, bookmark)
|
||||
|
||||
|
||||
class BundlesContext:
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
self.request = request
|
||||
self.user = request.user
|
||||
self.user_profile = request.user_profile
|
||||
|
||||
self.bundles = (
|
||||
BookmarkBundle.objects.filter(owner=self.user).order_by("order").all()
|
||||
)
|
||||
self.is_empty = len(self.bundles) == 0
|
||||
|
||||
selected_bundle_id = (
|
||||
int(request.GET.get("bundle")) if request.GET.get("bundle") else None
|
||||
)
|
||||
self.selected_bundle = next(
|
||||
(bundle for bundle in self.bundles if bundle.id == selected_bundle_id),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from bookmarks.models import BookmarkSearch
|
||||
from bookmarks.views import contexts, turbo
|
||||
|
||||
|
||||
@@ -14,8 +15,11 @@ def render_bookmark_update(request, bookmark_list, tag_cloud, details):
|
||||
|
||||
|
||||
def active_bookmark_update(request):
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request, search)
|
||||
details = contexts.get_details_context(
|
||||
request, contexts.ActiveBookmarkDetailsContext
|
||||
)
|
||||
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
|
||||
|
||||
|
||||
def archived_bookmark_update(request):
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
|
||||
details = contexts.get_details_context(
|
||||
request, contexts.ArchivedBookmarkDetailsContext
|
||||
)
|
||||
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
|
||||
|
||||
|
||||
def shared_bookmark_update(request):
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request, search)
|
||||
details = contexts.get_details_context(
|
||||
request, contexts.SharedBookmarkDetailsContext
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlight from "@astrojs/starlight";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'linkding',
|
||||
logo: {
|
||||
src: './src/assets/logo.svg',
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/sissbruecker/linkding',
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
{ label: 'Installation', slug: 'installation' },
|
||||
{ label: 'Options', slug: 'options' },
|
||||
{ label: 'Managed Hosting', slug: 'managed-hosting' },
|
||||
{ label: 'Browser Extension', slug: 'browser-extension' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
items: [
|
||||
{ label: 'Backups', slug: 'backups' },
|
||||
{ label: 'Archiving', slug: 'archiving' },
|
||||
{ label: 'Auto Tagging', slug: 'auto-tagging' },
|
||||
{ label: 'Keyboard Shortcuts', slug: 'shortcuts' },
|
||||
{ label: 'How To', slug: 'how-to' },
|
||||
{ label: 'Troubleshooting', slug: 'troubleshooting' },
|
||||
{ label: 'Admin', slug: 'admin' },
|
||||
{ label: 'REST API', slug: 'api' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
items: [
|
||||
{ label: 'Community', slug: 'community' },
|
||||
{ label: 'Acknowledgements', slug: 'acknowledgements' },
|
||||
],
|
||||
},
|
||||
],
|
||||
customCss: [
|
||||
'./src/styles/custom.css',
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/sissbruecker/linkding/edit/master/docs/',
|
||||
},
|
||||
}),
|
||||
],
|
||||
integrations: [
|
||||
starlight({
|
||||
title: "linkding",
|
||||
logo: {
|
||||
src: "./src/assets/logo.svg",
|
||||
},
|
||||
social: [
|
||||
{
|
||||
icon: "github",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/sissbruecker/linkding",
|
||||
},
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
label: "Getting Started",
|
||||
items: [
|
||||
{ label: "Installation", slug: "installation" },
|
||||
{ label: "Options", slug: "options" },
|
||||
{ label: "Managed Hosting", slug: "managed-hosting" },
|
||||
{ label: "Browser Extension", slug: "browser-extension" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Guides",
|
||||
items: [
|
||||
{ label: "Backups", slug: "backups" },
|
||||
{ label: "Archiving", slug: "archiving" },
|
||||
{ label: "Auto Tagging", slug: "auto-tagging" },
|
||||
{ label: "Keyboard Shortcuts", slug: "shortcuts" },
|
||||
{ label: "How To", slug: "how-to" },
|
||||
{ label: "Troubleshooting", slug: "troubleshooting" },
|
||||
{ label: "Admin", slug: "admin" },
|
||||
{ label: "REST API", slug: "api" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Resources",
|
||||
items: [
|
||||
{ label: "Community", slug: "community" },
|
||||
{ label: "Acknowledgements", slug: "acknowledgements" },
|
||||
],
|
||||
},
|
||||
],
|
||||
customCss: ["./src/styles/custom.css"],
|
||||
editLink: {
|
||||
baseUrl: "https://github.com/sissbruecker/linkding/edit/master/docs/",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
101
docs/package-lock.json
generated
101
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",
|
||||
@@ -5584,9 +5633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
|
||||
"integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
@@ -6511,9 +6560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
|
||||
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
|
||||
"integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user