mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-06 18:03:14 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb33ff96d | ||
|
|
549554cc17 | ||
|
|
20e31397cc | ||
|
|
94ae5fb41c | ||
|
|
2a550e2315 | ||
|
|
a79e8bcd59 | ||
|
|
1710d44df7 | ||
|
|
9967b3e27b | ||
|
|
1672dc0152 | ||
|
|
8be72a5d1f | ||
|
|
bb796c9bdb | ||
|
|
578680c3c1 | ||
|
|
8debb5c5aa | ||
|
|
be752f8146 |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -1,5 +1,72 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## v1.38.1 (22/02/2025)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ from bookmarks.api.serializers import (
|
|||||||
BookmarkAssetSerializer,
|
BookmarkAssetSerializer,
|
||||||
TagSerializer,
|
TagSerializer,
|
||||||
UserProfileSerializer,
|
UserProfileSerializer,
|
||||||
|
BookmarkBundleSerializer,
|
||||||
|
)
|
||||||
|
from bookmarks.models import (
|
||||||
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
|
BookmarkSearch,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
BookmarkBundle,
|
||||||
)
|
)
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
|
|
||||||
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
||||||
from bookmarks.type_defs import HttpRequest
|
from bookmarks.type_defs import HttpRequest
|
||||||
from bookmarks.views import access
|
from bookmarks.views import access
|
||||||
@@ -50,7 +58,7 @@ class BookmarkViewSet(
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Provide filtered queryset for list actions
|
# Provide filtered queryset for list actions
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
search = BookmarkSearch.from_request(self.request.GET)
|
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||||
if self.action == "list":
|
if self.action == "list":
|
||||||
return queries.query_bookmarks(user, user.profile, search)
|
return queries.query_bookmarks(user, user.profile, search)
|
||||||
elif self.action == "archived":
|
elif self.action == "archived":
|
||||||
@@ -264,6 +272,25 @@ class UserViewSet(viewsets.GenericViewSet):
|
|||||||
return Response(UserProfileSerializer(request.user.profile).data)
|
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}
|
||||||
|
|
||||||
|
|
||||||
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
||||||
# Instead create separate routers for each view set and manually register them in urls.py
|
# Instead create separate routers for each view set and manually register them in urls.py
|
||||||
# The default router is only used to allow reversing a URL for the API root
|
# 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 = SimpleRouter()
|
||||||
user_router.register("", UserViewSet, basename="user")
|
user_router.register("", UserViewSet, basename="user")
|
||||||
|
|
||||||
|
bundle_router = SimpleRouter()
|
||||||
|
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
|
||||||
|
|
||||||
bookmark_asset_router = SimpleRouter()
|
bookmark_asset_router = SimpleRouter()
|
||||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
from django.db.models import prefetch_related_objects
|
from django.db.models import Max, prefetch_related_objects
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import ListSerializer
|
from rest_framework.serializers import ListSerializer
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
|
from bookmarks.models import (
|
||||||
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
|
Tag,
|
||||||
|
build_tag_string,
|
||||||
|
UserProfile,
|
||||||
|
BookmarkBundle,
|
||||||
|
)
|
||||||
from bookmarks.services import bookmarks
|
from bookmarks.services import bookmarks
|
||||||
from bookmarks.services.tags import get_or_create_tag
|
from bookmarks.services.tags import get_or_create_tag
|
||||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||||
@@ -27,6 +34,40 @@ class EmtpyField(serializers.ReadOnlyField):
|
|||||||
return None
|
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):
|
||||||
|
# Set owner to the authenticated user
|
||||||
|
validated_data["owner"] = self.context["user"]
|
||||||
|
|
||||||
|
# Set order to the next available position if not provided
|
||||||
|
if "order" not in validated_data:
|
||||||
|
max_order = BookmarkBundle.objects.filter(
|
||||||
|
owner=self.context["user"]
|
||||||
|
).aggregate(Max("order", default=-1))["order__max"]
|
||||||
|
validated_data["order"] = max_order + 1
|
||||||
|
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSerializer(serializers.ModelSerializer):
|
class BookmarkSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
const bounds = getCurrentWordBounds(input);
|
const bounds = getCurrentWordBounds(input);
|
||||||
const value = input.value;
|
const value = input.value;
|
||||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||||
|
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
|
||||||
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
@@ -128,41 +129,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.menu {
|
.menu {
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu.open {
|
.menu.open {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete-input {
|
.form-autocomplete-input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--control-size);
|
height: var(--control-size);
|
||||||
min-height: var(--control-size);
|
min-height: var(--control-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete-input input {
|
.form-autocomplete-input input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input {
|
.form-autocomplete.small .form-autocomplete-input {
|
||||||
height: var(--control-size-sm);
|
height: var(--control-size-sm);
|
||||||
min-height: var(--control-size-sm);
|
min-height: var(--control-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input input {
|
.form-autocomplete.small .form-autocomplete-input input {
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete.small .menu .menu-item {
|
.form-autocomplete.small .menu .menu-item {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
</style>
|
</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 hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from functools import cached_property
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -157,6 +158,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
|||||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
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:
|
class BookmarkSearch:
|
||||||
SORT_ADDED_ASC = "added_asc"
|
SORT_ADDED_ASC = "added_asc"
|
||||||
SORT_ADDED_DESC = "added_desc"
|
SORT_ADDED_DESC = "added_desc"
|
||||||
@@ -171,34 +193,54 @@ class BookmarkSearch:
|
|||||||
FILTER_UNREAD_YES = "yes"
|
FILTER_UNREAD_YES = "yes"
|
||||||
FILTER_UNREAD_NO = "no"
|
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"]
|
preferences = ["sort", "shared", "unread"]
|
||||||
defaults = {
|
defaults = {
|
||||||
"q": "",
|
"q": "",
|
||||||
"user": "",
|
"user": "",
|
||||||
|
"bundle": None,
|
||||||
"sort": SORT_ADDED_DESC,
|
"sort": SORT_ADDED_DESC,
|
||||||
"shared": FILTER_SHARED_OFF,
|
"shared": FILTER_SHARED_OFF,
|
||||||
"unread": FILTER_UNREAD_OFF,
|
"unread": FILTER_UNREAD_OFF,
|
||||||
|
"modified_since": None,
|
||||||
|
"added_since": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
q: str = None,
|
q: str = None,
|
||||||
user: str = None,
|
user: str = None,
|
||||||
|
bundle: BookmarkBundle = None,
|
||||||
sort: str = None,
|
sort: str = None,
|
||||||
shared: str = None,
|
shared: str = None,
|
||||||
unread: str = None,
|
unread: str = None,
|
||||||
|
modified_since: str = None,
|
||||||
|
added_since: str = None,
|
||||||
preferences: dict = None,
|
preferences: dict = None,
|
||||||
|
request: any = None,
|
||||||
):
|
):
|
||||||
if not preferences:
|
if not preferences:
|
||||||
preferences = {}
|
preferences = {}
|
||||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||||
|
self.request = request
|
||||||
|
|
||||||
self.q = q or self.defaults["q"]
|
self.q = q or self.defaults["q"]
|
||||||
self.user = user or self.defaults["user"]
|
self.user = user or self.defaults["user"]
|
||||||
|
self.bundle = bundle or self.defaults["bundle"]
|
||||||
self.sort = sort or self.defaults["sort"]
|
self.sort = sort or self.defaults["sort"]
|
||||||
self.shared = shared or self.defaults["shared"]
|
self.shared = shared or self.defaults["shared"]
|
||||||
self.unread = unread or self.defaults["unread"]
|
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):
|
def is_modified(self, param):
|
||||||
value = self.__dict__[param]
|
value = self.__dict__[param]
|
||||||
@@ -226,7 +268,14 @@ class BookmarkSearch:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def query_params(self):
|
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
|
@property
|
||||||
def preferences_dict(self):
|
def preferences_dict(self):
|
||||||
@@ -235,14 +284,21 @@ class BookmarkSearch:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||||
initial_values = {}
|
initial_values = {}
|
||||||
for param in BookmarkSearch.params:
|
for param in BookmarkSearch.params:
|
||||||
value = query_dict.get(param)
|
value = query_dict.get(param)
|
||||||
if value:
|
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):
|
class BookmarkSearchForm(forms.Form):
|
||||||
@@ -265,9 +321,12 @@ class BookmarkSearchForm(forms.Form):
|
|||||||
|
|
||||||
q = forms.CharField()
|
q = forms.CharField()
|
||||||
user = forms.ChoiceField(required=False)
|
user = forms.ChoiceField(required=False)
|
||||||
|
bundle = forms.CharField(required=False)
|
||||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -287,7 +346,11 @@ class BookmarkSearchForm(forms.Form):
|
|||||||
|
|
||||||
for param in search.params:
|
for param in search.params:
|
||||||
# set initial values for modified 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
|
# Mark non-editable modified fields as hidden. That way, templates
|
||||||
# rendering a form can just loop over hidden_fields to ensure that
|
# rendering a form can just loop over hidden_fields to ensure that
|
||||||
@@ -408,6 +471,7 @@ class UserProfile(models.Model):
|
|||||||
)
|
)
|
||||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||||
collapse_side_panel = 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):
|
def save(self, *args, **kwargs):
|
||||||
if self.custom_css:
|
if self.custom_css:
|
||||||
@@ -448,6 +512,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"items_per_page",
|
"items_per_page",
|
||||||
"sticky_pagination",
|
"sticky_pagination",
|
||||||
"collapse_side_panel",
|
"collapse_side_panel",
|
||||||
|
"hide_bundles",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ from typing import Optional
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
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 import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.db.models.functions import Lower
|
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
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
|
||||||
def query_bookmarks(
|
def query_bookmarks(
|
||||||
user: User, profile: UserProfile, search: BookmarkSearch
|
user: User,
|
||||||
|
profile: UserProfile,
|
||||||
|
search: BookmarkSearch,
|
||||||
) -> QuerySet:
|
) -> QuerySet:
|
||||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
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)
|
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(
|
def _base_bookmarks_query(
|
||||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
user: Optional[User],
|
||||||
|
profile: UserProfile,
|
||||||
|
search: BookmarkSearch,
|
||||||
) -> QuerySet:
|
) -> QuerySet:
|
||||||
query_set = Bookmark.objects
|
query_set = Bookmark.objects
|
||||||
|
|
||||||
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
|
|||||||
if user:
|
if user:
|
||||||
query_set = query_set.filter(owner=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
|
# Split query into search terms and tags
|
||||||
query = parse_query_string(search.q)
|
query = parse_query_string(search.q)
|
||||||
|
|
||||||
@@ -85,6 +154,10 @@ def _base_bookmarks_query(
|
|||||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||||
query_set = query_set.filter(shared=False)
|
query_set = query_set.filter(shared=False)
|
||||||
|
|
||||||
|
# Filter by bundle
|
||||||
|
if search.bundle:
|
||||||
|
query_set = _filter_bundle(query_set, search.bundle)
|
||||||
|
|
||||||
# Sort
|
# Sort
|
||||||
if (
|
if (
|
||||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def create_snapshot(asset: BookmarkAsset):
|
|||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
asset.bookmark.latest_snapshot = asset
|
asset.bookmark.latest_snapshot = asset
|
||||||
|
asset.bookmark.date_modified = timezone.now()
|
||||||
asset.bookmark.save()
|
asset.bookmark.save()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||||
@@ -75,6 +76,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
|
|||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
asset.bookmark.latest_snapshot = asset
|
asset.bookmark.latest_snapshot = asset
|
||||||
|
asset.bookmark.date_modified = timezone.now()
|
||||||
asset.bookmark.save()
|
asset.bookmark.save()
|
||||||
|
|
||||||
return asset
|
return asset
|
||||||
@@ -100,6 +102,10 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
|||||||
asset.file = filename
|
asset.file = filename
|
||||||
asset.file_size = upload_file.size
|
asset.file_size = upload_file.size
|
||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
|
asset.bookmark.date_modified = timezone.now()
|
||||||
|
asset.bookmark.save()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||||
)
|
)
|
||||||
@@ -128,9 +134,10 @@ def remove_asset(asset: BookmarkAsset):
|
|||||||
)
|
)
|
||||||
|
|
||||||
bookmark.latest_snapshot = latest
|
bookmark.latest_snapshot = latest
|
||||||
bookmark.save()
|
|
||||||
|
|
||||||
asset.delete()
|
asset.delete()
|
||||||
|
bookmark.date_modified = timezone.now()
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
|
||||||
def _generate_asset_filename(
|
def _generate_asset_filename(
|
||||||
|
|||||||
@@ -49,50 +49,9 @@
|
|||||||
& .assets {
|
& .assets {
|
||||||
margin-top: var(--unit-2);
|
margin-top: var(--unit-2);
|
||||||
|
|
||||||
& .asset {
|
& .filesize {
|
||||||
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 {
|
|
||||||
color: var(--tertiary-text-color);
|
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 {
|
& .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 {
|
.tag-cloud {
|
||||||
/* Increase line-height for better separation within / between items */
|
/* Increase line-height for better separation within / between items */
|
||||||
line-height: 1.1rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,3 +60,60 @@ span.confirmation {
|
|||||||
.turbo-progress-bar {
|
.turbo-progress-bar {
|
||||||
background-color: var(--primary-color);
|
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;
|
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 "markdown.css";
|
||||||
@import "reader-mode.css";
|
@import "reader-mode.css";
|
||||||
@import "settings.css";
|
@import "settings.css";
|
||||||
|
@import "bundles.css";
|
||||||
|
|||||||
@@ -242,6 +242,14 @@
|
|||||||
margin-top: var(--unit-4) !important;
|
margin-top: var(--unit-4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-auto {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|||||||
@@ -30,16 +30,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Filters #}
|
||||||
<div class="side-panel col-1">
|
<div class="side-panel col-1 hide-md">
|
||||||
<section aria-labelledby="tags-heading">
|
{% include 'bookmarks/bundle_section.html' %}
|
||||||
<div class="section-header">
|
{% include 'bookmarks/tag_section.html' %}
|
||||||
<h2 id="tags-heading">Tags</h2>
|
|
||||||
</div>
|
|
||||||
<div id="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -77,72 +77,76 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span>{{ bookmark_item.display_date }}</span>
|
<span>{{ bookmark_item.display_date }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>|</span>
|
{% if not bookmark_list.is_preview %}
|
||||||
{% endif %}
|
<span>|</span>
|
||||||
{# 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if bookmark_item.has_extra_actions %}
|
{% if not bookmark_list.is_preview %}
|
||||||
<div class="extra-actions">
|
{# View link is visible for both owned and shared bookmarks #}
|
||||||
<span class="hide-sm">|</span>
|
{% if bookmark_list.show_view_action %}
|
||||||
{% if bookmark_item.show_mark_as_read %}
|
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||||
class="btn btn-link btn-sm btn-icon"
|
{% endif %}
|
||||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
{% if bookmark_item.is_editable %}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
{# Bookmark owner actions #}
|
||||||
<use xlink:href="#ld-icon-unread"></use>
|
{% if bookmark_list.show_edit_action %}
|
||||||
</svg>
|
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||||
Unread
|
{% 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>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.show_unshare %}
|
{% else %}
|
||||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
{# Shared bookmark actions #}
|
||||||
class="btn btn-link btn-sm btn-icon"
|
<span>Shared by
|
||||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
</span>
|
||||||
<use xlink:href="#ld-icon-share"></use>
|
{% endif %}
|
||||||
</svg>
|
{% if bookmark_item.has_extra_actions %}
|
||||||
Shared
|
<div class="extra-actions">
|
||||||
</button>
|
<span class="hide-sm">|</span>
|
||||||
{% endif %}
|
{% if bookmark_item.show_mark_as_read %}
|
||||||
{% if bookmark_item.show_notes_button %}
|
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
class="btn btn-link btn-sm btn-icon"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||||
<use xlink:href="#ld-icon-note"></use>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
</svg>
|
<use xlink:href="#ld-icon-unread"></use>
|
||||||
Notes
|
</svg>
|
||||||
</button>
|
Unread
|
||||||
{% endif %}
|
</button>
|
||||||
</div>
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
bookmarks/templates/bookmarks/bundle_section.html
Normal file
23
bookmarks/templates/bookmarks/bundle_section.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% if not request.user_profile.hide_bundles %}
|
||||||
|
<section aria-labelledby="bundles-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 id="bundles-heading">Bundles</h2>
|
||||||
|
<a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path
|
||||||
|
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
|
||||||
|
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<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>
|
<div>
|
||||||
{% if details.assets %}
|
{% if details.assets %}
|
||||||
<div class="assets">
|
<div class="item-list assets">
|
||||||
{% for asset in details.assets %}
|
{% for asset in details.assets %}
|
||||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||||
<div class="asset-icon {{ asset.icon_classes }}">
|
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||||
{% include 'bookmarks/details/asset_icon.html' %}
|
{% include 'bookmarks/details/asset_icon.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="asset-text {{ asset.text_classes }}">
|
<div class="list-item-text {{ asset.text_classes }}">
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{{ asset.display_name }}
|
{{ asset.display_name }}
|
||||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="asset-actions">
|
<div class="list-item-actions">
|
||||||
{% if asset.file %}
|
{% if asset.file %}
|
||||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -32,16 +32,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Filters #}
|
||||||
<div class="side-panel col-1">
|
<div class="side-panel col-1 hide-md">
|
||||||
<section aria-labelledby="tags-heading">
|
{% include 'bookmarks/bundle_section.html' %}
|
||||||
<div class="section-header">
|
{% include 'bookmarks/tag_section.html' %}
|
||||||
<h2 id="tags-heading">Tags</h2>
|
|
||||||
</div>
|
|
||||||
<div id="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<header class="container">
|
<header class="container">
|
||||||
{% if has_toasts %}
|
{% if has_toasts %}
|
||||||
<div class="toasts">
|
<div class="message-list">
|
||||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for toast in toast_messages %}
|
{% for toast in toast_messages %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if prev_link %}
|
{% if prev_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
{% for page_link in page_links %}
|
{% for page_link in page_links %}
|
||||||
{% if page_link %}
|
{% if page_link %}
|
||||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
<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>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
{% if next_link %}
|
{% if next_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Filters #}
|
{# Filters #}
|
||||||
<div class="side-panel col-1">
|
<div class="side-panel col-1 hide-md">
|
||||||
<section aria-labelledby="user-heading">
|
<section aria-labelledby="user-heading">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 id="user-heading">User</h2>
|
<h2 id="user-heading">User</h2>
|
||||||
@@ -38,14 +38,7 @@
|
|||||||
<br>
|
<br>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="tags-heading">
|
{% include 'bookmarks/tag_section.html' %}
|
||||||
<div class="section-header">
|
|
||||||
<h2 id="tags-heading">Tags</h2>
|
|
||||||
</div>
|
|
||||||
<div id="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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.
|
Instead, the tags are shown in an expandable drawer.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
<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" }}
|
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
|||||||
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
|
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||||
)
|
)
|
||||||
def pagination(context, page: Page):
|
def pagination(context, page: Page):
|
||||||
|
request = context["request"]
|
||||||
|
base_url = request.build_absolute_uri(request.path)
|
||||||
|
|
||||||
# remove page number and details from query parameters
|
# 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("page", None)
|
||||||
query_params.pop("details", None)
|
query_params.pop("details", None)
|
||||||
|
|
||||||
prev_link = (
|
prev_link = (
|
||||||
_generate_link(query_params, page.previous_page_number())
|
_generate_link(base_url, query_params, page.previous_page_number())
|
||||||
if page.has_previous()
|
if page.has_previous()
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
next_link = (
|
next_link = (
|
||||||
_generate_link(query_params, page.next_page_number())
|
_generate_link(base_url, query_params, page.next_page_number())
|
||||||
if page.has_next()
|
if page.has_next()
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
|
|||||||
if page_number == -1:
|
if page_number == -1:
|
||||||
page_links.append(None)
|
page_links.append(None)
|
||||||
else:
|
else:
|
||||||
link = _generate_link(query_params, page_number)
|
link = _generate_link(base_url, query_params, page_number)
|
||||||
page_links.append(
|
page_links.append(
|
||||||
{
|
{
|
||||||
"active": page_number == page.number,
|
"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, [])
|
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
|
query_params["page"] = page_number
|
||||||
return query_params.urlencode()
|
return f"{base_url}?{query_params.urlencode()}"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from rest_framework import status
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.test import APITestCase
|
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:
|
class BookmarkFactoryMixin:
|
||||||
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
|
|||||||
def get_numbered_bookmark(self, title: str):
|
def get_numbered_bookmark(self, title: str):
|
||||||
return Bookmark.objects.get(title=title)
|
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(
|
def setup_asset(
|
||||||
self,
|
self,
|
||||||
bookmark: Bookmark,
|
bookmark: Bookmark,
|
||||||
@@ -239,7 +266,7 @@ class BookmarkFactoryMixin:
|
|||||||
user.profile.save()
|
user.profile.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
|
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
|
||||||
all_tags = []
|
all_tags = []
|
||||||
for bookmark in bookmarks:
|
for bookmark in bookmarks:
|
||||||
all_tags = all_tags + list(bookmark.tags.all())
|
all_tags = all_tags + list(bookmark.tags.all())
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertIsNone(asset.id)
|
self.assertIsNone(asset.id)
|
||||||
|
|
||||||
def test_create_snapshot(self):
|
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 = assets.create_snapshot_asset(bookmark)
|
||||||
asset.save()
|
asset.save()
|
||||||
asset.date_created = timezone.datetime(
|
asset.date_created = timezone.datetime(
|
||||||
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.file, expected_filename)
|
self.assertEqual(asset.file, expected_filename)
|
||||||
self.assertTrue(asset.gzip)
|
self.assertTrue(asset.gzip)
|
||||||
|
|
||||||
|
# should update bookmark modified date
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
def test_create_snapshot_failure(self):
|
def test_create_snapshot_failure(self):
|
||||||
bookmark = self.setup_bookmark(url="https://example.com")
|
bookmark = self.setup_bookmark(url="https://example.com")
|
||||||
asset = assets.create_snapshot_asset(bookmark)
|
asset = assets.create_snapshot_asset(bookmark)
|
||||||
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
||||||
|
|
||||||
def test_upload_snapshot(self):
|
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())
|
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||||
|
|
||||||
# should create gzip file in asset folder
|
# should create gzip file in asset folder
|
||||||
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.file, saved_file_name)
|
self.assertEqual(asset.file, saved_file_name)
|
||||||
self.assertTrue(asset.gzip)
|
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):
|
def test_upload_snapshot_failure(self):
|
||||||
bookmark = self.setup_bookmark(url="https://example.com")
|
bookmark = self.setup_bookmark(url="https://example.com")
|
||||||
|
|
||||||
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def test_upload_asset(self):
|
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"
|
file_content = b"test content"
|
||||||
upload_file = SimpleUploadedFile(
|
upload_file = SimpleUploadedFile(
|
||||||
"test_file.txt", file_content, content_type="text/plain"
|
"test_file.txt", file_content, content_type="text/plain"
|
||||||
@@ -204,6 +224,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.file_size, len(file_content))
|
self.assertEqual(asset.file_size, len(file_content))
|
||||||
self.assertFalse(asset.gzip)
|
self.assertFalse(asset.gzip)
|
||||||
|
|
||||||
|
# should update bookmark modified date
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def test_upload_asset_truncates_asset_file_name(self):
|
def test_upload_asset_truncates_asset_file_name(self):
|
||||||
# Create a bookmark with a very long URL
|
# Create a bookmark with a very long URL
|
||||||
@@ -409,3 +433,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
# Verify that latest_snapshot hasn't changed
|
# Verify that latest_snapshot hasn't changed
|
||||||
self.assertEqual(bookmark.latest_snapshot, latest_asset)
|
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(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").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):
|
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||||
self.setup_bulk_edit_scope_test_data()
|
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(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").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):
|
def test_shared_action_bulk_select_across_not_supported(self):
|
||||||
self.setup_bulk_edit_scope_test_data()
|
self.setup_bulk_edit_scope_test_data()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
|
|||||||
BookmarkFactoryMixin,
|
BookmarkFactoryMixin,
|
||||||
BookmarkListTestMixin,
|
BookmarkListTestMixin,
|
||||||
TagCloudTestMixin,
|
TagCloudTestMixin,
|
||||||
collapse_whitespace,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
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.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
|
|||||||
self.assertVisibleTags(response, visible_tags)
|
self.assertVisibleTags(response, visible_tags)
|
||||||
self.assertInvisibleTags(response, invisible_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):
|
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||||
user_profile = self.user.profile
|
user_profile = self.user.profile
|
||||||
user_profile.search_preferences = {
|
user_profile.search_preferences = {
|
||||||
@@ -515,3 +550,20 @@ class BookmarkArchivedViewTestCase(
|
|||||||
|
|
||||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
self.assertIsNone(feed)
|
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)
|
||||||
|
|||||||
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
asset_item = self.find_asset(asset_list, asset)
|
asset_item = self.find_asset(asset_list, asset)
|
||||||
self.assertIsNotNone(asset_item)
|
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)
|
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.assertIsNotNone(asset_text)
|
||||||
self.assertIn(asset.display_name, asset_text.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)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, pending_asset)
|
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)
|
self.assertIn("(queued)", asset_text.text)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, failed_asset)
|
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)
|
self.assertIn("(failed)", asset_text.text)
|
||||||
|
|
||||||
def test_asset_file_size(self):
|
def test_asset_file_size(self):
|
||||||
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset1)
|
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)
|
self.assertEqual(asset_text.text.strip(), asset1.display_name)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset2)
|
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)
|
self.assertIn("53.4\xa0KB", asset_text.text)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset3)
|
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)
|
self.assertIn("11.0\xa0MB", asset_text.text)
|
||||||
|
|
||||||
def test_asset_actions_visibility(self):
|
def test_asset_actions_visibility(self):
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertIsNotNone(form)
|
self.assertIsNotNone(form)
|
||||||
self.assertEqual(form.attrs["action"], url)
|
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):
|
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
"otheruser", "otheruser@example.com", "password123"
|
"otheruser", "otheruser@example.com", "password123"
|
||||||
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
self.assertInvisibleBookmarks(response, invisible_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):
|
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
"otheruser", "otheruser@example.com", "password123"
|
"otheruser", "otheruser@example.com", "password123"
|
||||||
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertVisibleTags(response, visible_tags)
|
self.assertVisibleTags(response, visible_tags)
|
||||||
self.assertInvisibleTags(response, invisible_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):
|
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||||
user_profile = self.user.profile
|
user_profile = self.user.profile
|
||||||
user_profile.search_preferences = {
|
user_profile.search_preferences = {
|
||||||
@@ -494,3 +542,43 @@ class BookmarkIndexViewTestCase(
|
|||||||
|
|
||||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
self.assertIsNone(feed)
|
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)
|
||||||
|
|||||||
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
|||||||
form = BookmarkSearchForm(search)
|
form = BookmarkSearchForm(search)
|
||||||
self.assertEqual(form["q"].initial, "")
|
self.assertEqual(form["q"].initial, "")
|
||||||
self.assertEqual(form["user"].initial, "")
|
self.assertEqual(form["user"].initial, "")
|
||||||
|
self.assertEqual(form["bundle"].initial, None)
|
||||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
|
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
|
||||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
|
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
|
||||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
|
|
||||||
# with params
|
# with params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
search = BookmarkSearch(
|
search = BookmarkSearch(
|
||||||
q="search query",
|
q="search query",
|
||||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
user="user123",
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
)
|
)
|
||||||
form = BookmarkSearchForm(search)
|
form = BookmarkSearchForm(search)
|
||||||
self.assertEqual(form["q"].initial, "search query")
|
self.assertEqual(form["q"].initial, "search query")
|
||||||
self.assertEqual(form["user"].initial, "user123")
|
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["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
|
||||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
|
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
|
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"]])
|
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
|
||||||
|
|
||||||
# all modified params
|
# all modified params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
search = BookmarkSearch(
|
search = BookmarkSearch(
|
||||||
q="search query",
|
q="search query",
|
||||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
user="user123",
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
)
|
)
|
||||||
form = BookmarkSearchForm(search)
|
form = BookmarkSearchForm(search)
|
||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
form.hidden_fields(),
|
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
|
# some modified params are editable fields
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ from django.http import QueryDict
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from bookmarks.models import BookmarkSearch
|
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):
|
def test_from_request(self):
|
||||||
# no params
|
# no params
|
||||||
query_dict = QueryDict()
|
query_dict = QueryDict()
|
||||||
|
|
||||||
search = BookmarkSearch.from_request(query_dict)
|
search = BookmarkSearch.from_request(None, query_dict)
|
||||||
self.assertEqual(search.q, "")
|
self.assertEqual(search.q, "")
|
||||||
self.assertEqual(search.user, "")
|
self.assertEqual(search.user, "")
|
||||||
|
self.assertEqual(search.bundle, None)
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
|
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
|
||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
# some params
|
# some params
|
||||||
query_dict = QueryDict("q=search query&user=user123")
|
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.q, "search query")
|
||||||
self.assertEqual(bookmark_search.user, "user123")
|
self.assertEqual(bookmark_search.user, "user123")
|
||||||
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||||
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
|
|
||||||
# all params
|
# all params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
|
request = MockRequest(self.get_or_create_test_user())
|
||||||
query_dict = QueryDict(
|
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.q, "search query")
|
||||||
self.assertEqual(search.user, "user123")
|
self.assertEqual(search.user, "user123")
|
||||||
|
self.assertEqual(search.bundle, bundle)
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
|
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
|
||||||
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
}
|
}
|
||||||
query_dict = QueryDict("q=search query")
|
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.q, "search query")
|
||||||
self.assertEqual(search.user, "")
|
self.assertEqual(search.user, "")
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
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")
|
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.q, "")
|
||||||
self.assertEqual(search.user, "")
|
self.assertEqual(search.user, "")
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
|
||||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
|
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
|
||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
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):
|
def test_modified_params(self):
|
||||||
# no params
|
# no params
|
||||||
bookmark_search = BookmarkSearch()
|
bookmark_search = BookmarkSearch()
|
||||||
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
self.assertCountEqual(modified_params, ["q", "sort"])
|
self.assertCountEqual(modified_params, ["q", "sort"])
|
||||||
|
|
||||||
# all modified params
|
# all modified params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
bookmark_search = BookmarkSearch(
|
bookmark_search = BookmarkSearch(
|
||||||
q="search query",
|
q="search query",
|
||||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
user="user123",
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
)
|
)
|
||||||
modified_params = bookmark_search.modified_params
|
modified_params = bookmark_search.modified_params
|
||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
modified_params, ["q", "sort", "user", "shared", "unread"]
|
modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# preferences are not modified params
|
# preferences are not modified params
|
||||||
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# only returns preferences
|
# 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(
|
self.assertEqual(
|
||||||
bookmark_search.preferences_dict,
|
bookmark_search.preferences_dict,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
request.user = self.get_or_create_test_user()
|
request.user = self.get_or_create_test_user()
|
||||||
request.user_profile = self.get_or_create_test_user().profile
|
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(
|
context = RequestContext(
|
||||||
request,
|
request,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
|
|||||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
self.assertInvisibleBookmarks(response, invisible_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):
|
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
|
||||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
|
|||||||
self.assertVisibleTags(response, visible_tags)
|
self.assertVisibleTags(response, visible_tags)
|
||||||
self.assertInvisibleTags(response, invisible_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):
|
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
|
||||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|||||||
@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
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):
|
def test_list_bookmarks_filter_unread(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
|
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
|
||||||
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
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):
|
def test_list_archived_bookmarks_should_respect_sort(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmarks = self.setup_numbered_bookmarks(5, archived=True)
|
bookmarks = self.setup_numbered_bookmarks(5, archived=True)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone, formats
|
from django.utils import timezone, formats
|
||||||
|
|
||||||
from bookmarks.middlewares import LinkdingMiddleware
|
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.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
from bookmarks.views import contexts
|
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">
|
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||||
{label_content}
|
{label_content}
|
||||||
</a>
|
</a>
|
||||||
<span>|</span>
|
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
contexts.BookmarkListContext
|
contexts.BookmarkListContext
|
||||||
] = contexts.ActiveBookmarkListContext,
|
] = contexts.ActiveBookmarkListContext,
|
||||||
user: User | AnonymousUser = None,
|
user: User | AnonymousUser = None,
|
||||||
|
is_preview: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
||||||
middleware(request)
|
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})
|
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
|
||||||
|
|
||||||
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
|
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
|
||||||
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
soup = self.make_soup(html)
|
soup = self.make_soup(html)
|
||||||
bookmarks = soup.select("li[ld-bookmark-item]")
|
bookmarks = soup.select("li[ld-bookmark-item]")
|
||||||
self.assertEqual(10, len(bookmarks))
|
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)
|
||||||
|
|||||||
303
bookmarks/tests/test_bundles_api.py
Normal file
303
bookmarks/tests/test_bundles_api.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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_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")
|
||||||
122
bookmarks/tests/test_bundles_edit_view.py
Normal file
122
bookmarks/tests/test_bundles_edit_view.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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)
|
||||||
198
bookmarks/tests/test_bundles_index_view.py
Normal file
198
bookmarks/tests/test_bundles_index_view.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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_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())
|
||||||
77
bookmarks/tests/test_bundles_new_view.py
Normal file
77
bookmarks/tests/test_bundles_new_view.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import BookmarkBundle
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BundleNewViewTestCase(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_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)
|
||||||
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)
|
||||||
@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def assertPrevLink(self, html: str, page_number: int, href: str = None):
|
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(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
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(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
href: str = None,
|
href: str = None,
|
||||||
):
|
):
|
||||||
active_class = "active" if active else ""
|
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(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<li class="page-item {1}">
|
<li class="page-item {1}">
|
||||||
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
rendered_template = self.render_template(
|
rendered_template = self.render_template(
|
||||||
100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
|
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.assertPrevLink(
|
||||||
self.assertPageLink(
|
rendered_template,
|
||||||
rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
|
1,
|
||||||
|
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||||
)
|
)
|
||||||
self.assertPageLink(
|
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):
|
def test_removes_details_parameter(self):
|
||||||
rendered_template = self.render_template(
|
rendered_template = self.render_template(
|
||||||
100, 10, 2, url="/test?details=1&page=2"
|
100, 10, 2, url="/test?details=1&page=2"
|
||||||
)
|
)
|
||||||
self.assertPrevLink(rendered_template, 1, href="?page=1")
|
self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
|
||||||
self.assertPageLink(rendered_template, 1, False, href="?page=1")
|
self.assertPageLink(
|
||||||
self.assertPageLink(rendered_template, 2, True, href="?page=2")
|
rendered_template, 1, False, href="http://testserver/test?page=1"
|
||||||
self.assertNextLink(rendered_template, 3, href="?page=3")
|
)
|
||||||
|
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()]),
|
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 = []
|
expected_items = []
|
||||||
for item_list in item_lists:
|
for item_list in item_lists:
|
||||||
expected_items = expected_items + item_list
|
expected_items = expected_items + item_list
|
||||||
@@ -1211,3 +1211,343 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||||
self.assertEqual(list(query), sorted_bookmarks)
|
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])
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
"items_per_page": "30",
|
"items_per_page": "30",
|
||||||
"sticky_pagination": False,
|
"sticky_pagination": False,
|
||||||
"collapse_side_panel": False,
|
"collapse_side_panel": False,
|
||||||
|
"hide_bundles": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {**form_data, **overrides}
|
return {**form_data, **overrides}
|
||||||
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
"items_per_page": "10",
|
"items_per_page": "10",
|
||||||
"sticky_pagination": True,
|
"sticky_pagination": True,
|
||||||
"collapse_side_panel": True,
|
"collapse_side_panel": True,
|
||||||
|
"hide_bundles": True,
|
||||||
}
|
}
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("linkding:settings.update"), form_data, follow=True
|
reverse("linkding:settings.update"), form_data, follow=True
|
||||||
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
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")
|
self.assertSuccessMessage(html, "Profile updated")
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
|
|||||||
from django.test import TestCase, RequestFactory
|
from django.test import TestCase, RequestFactory
|
||||||
|
|
||||||
from bookmarks.middlewares import LinkdingMiddleware
|
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.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
from bookmarks.views import contexts
|
from bookmarks.views import contexts
|
||||||
|
|
||||||
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
||||||
middleware(request)
|
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})
|
context = RequestContext(request, {"tag_cloud": tag_cloud_context})
|
||||||
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
|
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
|
||||||
return template_to_render.render(context)
|
return template_to_render.render(context)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
|
||||||
# Should render toasts container
|
# Should render toasts container
|
||||||
self.assertContains(response, '<div class="toasts">')
|
self.assertContains(response, '<div class="message-list">')
|
||||||
# Should render two toasts
|
# Should render two toasts
|
||||||
self.assertContains(response, '<div class="toast d-flex">', count=2)
|
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"))
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
|
||||||
# Should not render toasts container
|
# 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
|
# Should not render toasts
|
||||||
self.assertContains(response, '<div class="toast">', count=0)
|
self.assertContains(response, '<div class="toast">', count=0)
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
|
||||||
# Should not render toasts container
|
# 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
|
# Should not render toasts
|
||||||
self.assertContains(response, '<div class="toast">', count=0)
|
self.assertContains(response, '<div class="toast">', count=0)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
request.user = self.get_or_create_test_user()
|
request.user = self.get_or_create_test_user()
|
||||||
request.user_profile = self.get_or_create_test_user().profile
|
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(
|
context = RequestContext(
|
||||||
request,
|
request,
|
||||||
{
|
{
|
||||||
|
|||||||
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()
|
||||||
@@ -49,6 +49,10 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
|||||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||||
return bookmark_tags.filter(has_text=title)
|
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):
|
def locate_details_modal(self):
|
||||||
return self.page.locator(".modal.bookmark-details")
|
return self.page.locator(".modal.bookmark-details")
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ urlpatterns = [
|
|||||||
views.assets.read,
|
views.assets.read,
|
||||||
name="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
|
# Settings
|
||||||
path("settings", views.settings.general, name="settings.index"),
|
path("settings", views.settings.general, name="settings.index"),
|
||||||
path("settings/general", views.settings.general, name="settings.general"),
|
path("settings/general", views.settings.general, name="settings.general"),
|
||||||
@@ -64,6 +70,7 @@ urlpatterns = [
|
|||||||
include(api_routes.bookmark_asset_router.urls),
|
include(api_routes.bookmark_asset_router.urls),
|
||||||
),
|
),
|
||||||
path("api/tags/", include(api_routes.tag_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)),
|
path("api/user/", include(api_routes.user_router.urls)),
|
||||||
# Feeds
|
# Feeds
|
||||||
path("feeds/<str:feed_key>/all", feeds.AllBookmarksFeed(), name="feeds.all"),
|
path("feeds/<str:feed_key>/all", feeds.AllBookmarksFeed(), name="feeds.all"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from .assets import *
|
from .assets import *
|
||||||
from .auth import *
|
from .auth import *
|
||||||
from .bookmarks import *
|
from .bookmarks import *
|
||||||
|
from . import bundles
|
||||||
from .settings import *
|
from .settings import *
|
||||||
from .toasts import *
|
from .toasts import *
|
||||||
from .health import health
|
from .health import health
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.http import Http404
|
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
|
from bookmarks.type_defs import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +32,13 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
|
|||||||
raise Http404("Bookmark does not exist")
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_write(request: HttpRequest, bundle_id: int | str):
|
||||||
|
try:
|
||||||
|
return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
|
||||||
|
except BookmarkBundle.DoesNotExist:
|
||||||
|
raise Http404("Bundle does not exist")
|
||||||
|
|
||||||
|
|
||||||
def asset_read(request: HttpRequest, asset_id: int | str):
|
def asset_read(request: HttpRequest, asset_id: int | str):
|
||||||
try:
|
try:
|
||||||
asset = BookmarkAsset.objects.get(pk=asset_id)
|
asset = BookmarkAsset.objects.get(pk=asset_id)
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ def index(request: HttpRequest):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
return search_action(request)
|
return search_action(request)
|
||||||
|
|
||||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
search = BookmarkSearch.from_request(
|
||||||
tag_cloud = contexts.ActiveTagCloudContext(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(
|
bookmark_details = contexts.get_details_context(
|
||||||
request, contexts.ActiveBookmarkDetailsContext
|
request, contexts.ActiveBookmarkDetailsContext
|
||||||
)
|
)
|
||||||
@@ -54,6 +58,7 @@ def index(request: HttpRequest):
|
|||||||
{
|
{
|
||||||
"page_title": "Bookmarks - Linkding",
|
"page_title": "Bookmarks - Linkding",
|
||||||
"bookmark_list": bookmark_list,
|
"bookmark_list": bookmark_list,
|
||||||
|
"bundles": bundles,
|
||||||
"tag_cloud": tag_cloud,
|
"tag_cloud": tag_cloud,
|
||||||
"details": bookmark_details,
|
"details": bookmark_details,
|
||||||
},
|
},
|
||||||
@@ -65,8 +70,12 @@ def archived(request: HttpRequest):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
return search_action(request)
|
return search_action(request)
|
||||||
|
|
||||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
search = BookmarkSearch.from_request(
|
||||||
tag_cloud = contexts.ArchivedTagCloudContext(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(
|
bookmark_details = contexts.get_details_context(
|
||||||
request, contexts.ArchivedBookmarkDetailsContext
|
request, contexts.ArchivedBookmarkDetailsContext
|
||||||
)
|
)
|
||||||
@@ -77,6 +86,7 @@ def archived(request: HttpRequest):
|
|||||||
{
|
{
|
||||||
"page_title": "Archived bookmarks - Linkding",
|
"page_title": "Archived bookmarks - Linkding",
|
||||||
"bookmark_list": bookmark_list,
|
"bookmark_list": bookmark_list,
|
||||||
|
"bundles": bundles,
|
||||||
"tag_cloud": tag_cloud,
|
"tag_cloud": tag_cloud,
|
||||||
"details": bookmark_details,
|
"details": bookmark_details,
|
||||||
},
|
},
|
||||||
@@ -87,8 +97,11 @@ def shared(request: HttpRequest):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
return search_action(request)
|
return search_action(request)
|
||||||
|
|
||||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
search = BookmarkSearch.from_request(
|
||||||
tag_cloud = contexts.SharedTagCloudContext(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(
|
bookmark_details = contexts.get_details_context(
|
||||||
request, contexts.SharedBookmarkDetailsContext
|
request, contexts.SharedBookmarkDetailsContext
|
||||||
)
|
)
|
||||||
@@ -132,13 +145,13 @@ def search_action(request: HttpRequest):
|
|||||||
if "save" in request.POST:
|
if "save" in request.POST:
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return HttpResponseForbidden()
|
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.search_preferences = search.preferences_dict
|
||||||
request.user_profile.save()
|
request.user_profile.save()
|
||||||
|
|
||||||
# redirect to base url including new query params
|
# redirect to base url including new query params
|
||||||
search = BookmarkSearch.from_request(
|
search = BookmarkSearch.from_request(
|
||||||
request.POST, request.user_profile.search_preferences
|
request, request.POST, request.user_profile.search_preferences
|
||||||
)
|
)
|
||||||
base_url = request.path
|
base_url = request.path
|
||||||
query_params = search.query_params
|
query_params = search.query_params
|
||||||
@@ -248,7 +261,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index_action(request: HttpRequest):
|
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)
|
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
||||||
|
|
||||||
response = handle_action(request, query)
|
response = handle_action(request, query)
|
||||||
@@ -263,7 +278,9 @@ def index_action(request: HttpRequest):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def archived_action(request: HttpRequest):
|
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)
|
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||||
|
|
||||||
response = handle_action(request, query)
|
response = handle_action(request, query)
|
||||||
|
|||||||
109
bookmarks/views/bundles.py
Normal file
109
bookmarks/views/bundles.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.http import HttpRequest, HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
|
||||||
|
from bookmarks.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
|
||||||
|
bundle.delete()
|
||||||
|
messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
|
||||||
|
|
||||||
|
elif "move_bundle" in request.POST:
|
||||||
|
bundle_id = request.POST.get("move_bundle")
|
||||||
|
move_position = int(request.POST.get("move_position"))
|
||||||
|
bundle_to_move = access.bundle_write(request, bundle_id)
|
||||||
|
user_bundles = list(
|
||||||
|
BookmarkBundle.objects.filter(owner=request.user).order_by("order")
|
||||||
|
)
|
||||||
|
|
||||||
|
if move_position != user_bundles.index(bundle_to_move):
|
||||||
|
user_bundles.remove(bundle_to_move)
|
||||||
|
user_bundles.insert(move_position, bundle_to_move)
|
||||||
|
for bundle_index, bundle in enumerate(user_bundles):
|
||||||
|
bundle.order = bundle_index
|
||||||
|
|
||||||
|
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
||||||
|
|
||||||
|
return HttpResponseRedirect(reverse("linkding:bundles.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
|
||||||
|
form_data = request.POST if request.method == "POST" else None
|
||||||
|
form = BookmarkBundleForm(form_data, instance=bundle)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if form.is_valid():
|
||||||
|
instance = form.save(commit=False)
|
||||||
|
instance.owner = request.user
|
||||||
|
|
||||||
|
if bundle is None: # New bundle
|
||||||
|
max_order_result = BookmarkBundle.objects.filter(
|
||||||
|
owner=request.user
|
||||||
|
).aggregate(Max("order", default=-1))
|
||||||
|
instance.order = max_order_result["order__max"] + 1
|
||||||
|
|
||||||
|
instance.save()
|
||||||
|
messages.success(request, "Bundle saved successfully.")
|
||||||
|
return HttpResponseRedirect(reverse("linkding:bundles.index"))
|
||||||
|
|
||||||
|
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||||
|
bookmark_list = _get_bookmark_list_preview(request, bundle)
|
||||||
|
context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list}
|
||||||
|
|
||||||
|
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
|
||||||
|
):
|
||||||
|
if request.method == "GET" and bundle:
|
||||||
|
preview_bundle = bundle
|
||||||
|
else:
|
||||||
|
form_data = (
|
||||||
|
request.POST.copy() if request.method == "POST" else request.GET.copy()
|
||||||
|
)
|
||||||
|
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 (
|
from bookmarks.models import (
|
||||||
Bookmark,
|
Bookmark,
|
||||||
BookmarkAsset,
|
BookmarkAsset,
|
||||||
|
BookmarkBundle,
|
||||||
BookmarkSearch,
|
BookmarkSearch,
|
||||||
User,
|
User,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
@@ -178,15 +179,13 @@ class BookmarkItem:
|
|||||||
class BookmarkListContext:
|
class BookmarkListContext:
|
||||||
request_context = RequestContext
|
request_context = RequestContext
|
||||||
|
|
||||||
def __init__(self, request: HttpRequest) -> None:
|
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
|
||||||
request_context = self.request_context(request)
|
request_context = self.request_context(request)
|
||||||
user = request.user
|
user = request.user
|
||||||
user_profile = request.user_profile
|
user_profile = request.user_profile
|
||||||
|
|
||||||
self.request = request
|
self.request = request
|
||||||
self.search = BookmarkSearch.from_request(
|
self.search = search
|
||||||
self.request.GET, user_profile.search_preferences
|
|
||||||
)
|
|
||||||
|
|
||||||
query_set = request_context.get_bookmark_query_set(self.search)
|
query_set = request_context.get_bookmark_query_set(self.search)
|
||||||
page_number = request.GET.get("page")
|
page_number = request.GET.get("page")
|
||||||
@@ -219,6 +218,7 @@ class BookmarkListContext:
|
|||||||
self.show_preview_images = user_profile.enable_preview_images
|
self.show_preview_images = user_profile.enable_preview_images
|
||||||
self.show_notes = user_profile.permanent_notes
|
self.show_notes = user_profile.permanent_notes
|
||||||
self.collapse_side_panel = user_profile.collapse_side_panel
|
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||||
|
self.is_preview = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
||||||
@@ -315,14 +315,12 @@ class TagGroup:
|
|||||||
class TagCloudContext:
|
class TagCloudContext:
|
||||||
request_context = RequestContext
|
request_context = RequestContext
|
||||||
|
|
||||||
def __init__(self, request: HttpRequest) -> None:
|
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
|
||||||
request_context = self.request_context(request)
|
request_context = self.request_context(request)
|
||||||
user_profile = request.user_profile
|
user_profile = request.user_profile
|
||||||
|
|
||||||
self.request = request
|
self.request = request
|
||||||
self.search = BookmarkSearch.from_request(
|
self.search = search
|
||||||
self.request.GET, user_profile.search_preferences
|
|
||||||
)
|
|
||||||
|
|
||||||
query_set = request_context.get_tag_query_set(self.search)
|
query_set = request_context.get_tag_query_set(self.search)
|
||||||
tags = list(query_set)
|
tags = list(query_set)
|
||||||
@@ -371,6 +369,7 @@ class BookmarkAssetItem:
|
|||||||
self.asset_type = asset.asset_type
|
self.asset_type = asset.asset_type
|
||||||
self.file = asset.file
|
self.file = asset.file
|
||||||
self.file_size = asset.file_size
|
self.file_size = asset.file_size
|
||||||
|
self.content_type = asset.content_type
|
||||||
self.status = asset.status
|
self.status = asset.status
|
||||||
|
|
||||||
icon_classes = []
|
icon_classes = []
|
||||||
@@ -461,3 +460,23 @@ def get_details_context(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return context_type(request, bookmark)
|
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
|
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):
|
def active_bookmark_update(request):
|
||||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
search = BookmarkSearch.from_request(
|
||||||
tag_cloud = contexts.ActiveTagCloudContext(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(
|
details = contexts.get_details_context(
|
||||||
request, contexts.ActiveBookmarkDetailsContext
|
request, contexts.ActiveBookmarkDetailsContext
|
||||||
)
|
)
|
||||||
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
|
|||||||
|
|
||||||
|
|
||||||
def archived_bookmark_update(request):
|
def archived_bookmark_update(request):
|
||||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
search = BookmarkSearch.from_request(
|
||||||
tag_cloud = contexts.ArchivedTagCloudContext(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(
|
details = contexts.get_details_context(
|
||||||
request, contexts.ArchivedBookmarkDetailsContext
|
request, contexts.ArchivedBookmarkDetailsContext
|
||||||
)
|
)
|
||||||
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
|
|||||||
|
|
||||||
|
|
||||||
def shared_bookmark_update(request):
|
def shared_bookmark_update(request):
|
||||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
search = BookmarkSearch.from_request(
|
||||||
tag_cloud = contexts.SharedTagCloudContext(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(
|
details = contexts.get_details_context(
|
||||||
request, contexts.SharedBookmarkDetailsContext
|
request, contexts.SharedBookmarkDetailsContext
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,55 +1,57 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
import starlight from '@astrojs/starlight';
|
import starlight from "@astrojs/starlight";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: 'linkding',
|
title: "linkding",
|
||||||
logo: {
|
logo: {
|
||||||
src: './src/assets/logo.svg',
|
src: "./src/assets/logo.svg",
|
||||||
},
|
},
|
||||||
social: {
|
social: [
|
||||||
github: 'https://github.com/sissbruecker/linkding',
|
{
|
||||||
},
|
icon: "github",
|
||||||
sidebar: [
|
label: "GitHub",
|
||||||
{
|
href: "https://github.com/sissbruecker/linkding",
|
||||||
label: 'Getting Started',
|
},
|
||||||
items: [
|
],
|
||||||
{ label: 'Installation', slug: 'installation' },
|
sidebar: [
|
||||||
{ label: 'Options', slug: 'options' },
|
{
|
||||||
{ label: 'Managed Hosting', slug: 'managed-hosting' },
|
label: "Getting Started",
|
||||||
{ label: 'Browser Extension', slug: 'browser-extension' },
|
items: [
|
||||||
],
|
{ label: "Installation", slug: "installation" },
|
||||||
},
|
{ label: "Options", slug: "options" },
|
||||||
{
|
{ label: "Managed Hosting", slug: "managed-hosting" },
|
||||||
label: 'Guides',
|
{ label: "Browser Extension", slug: "browser-extension" },
|
||||||
items: [
|
],
|
||||||
{ label: 'Backups', slug: 'backups' },
|
},
|
||||||
{ label: 'Archiving', slug: 'archiving' },
|
{
|
||||||
{ label: 'Auto Tagging', slug: 'auto-tagging' },
|
label: "Guides",
|
||||||
{ label: 'Keyboard Shortcuts', slug: 'shortcuts' },
|
items: [
|
||||||
{ label: 'How To', slug: 'how-to' },
|
{ label: "Backups", slug: "backups" },
|
||||||
{ label: 'Troubleshooting', slug: 'troubleshooting' },
|
{ label: "Archiving", slug: "archiving" },
|
||||||
{ label: 'Admin', slug: 'admin' },
|
{ label: "Auto Tagging", slug: "auto-tagging" },
|
||||||
{ label: 'REST API', slug: 'api' },
|
{ label: "Keyboard Shortcuts", slug: "shortcuts" },
|
||||||
],
|
{ label: "How To", slug: "how-to" },
|
||||||
},
|
{ label: "Troubleshooting", slug: "troubleshooting" },
|
||||||
{
|
{ label: "Admin", slug: "admin" },
|
||||||
label: 'Resources',
|
{ label: "REST API", slug: "api" },
|
||||||
items: [
|
],
|
||||||
{ label: 'Community', slug: 'community' },
|
},
|
||||||
{ label: 'Acknowledgements', slug: 'acknowledgements' },
|
{
|
||||||
],
|
label: "Resources",
|
||||||
},
|
items: [
|
||||||
],
|
{ label: "Community", slug: "community" },
|
||||||
customCss: [
|
{ label: "Acknowledgements", slug: "acknowledgements" },
|
||||||
'./src/styles/custom.css',
|
],
|
||||||
],
|
},
|
||||||
editLink: {
|
],
|
||||||
baseUrl: 'https://github.com/sissbruecker/linkding/edit/master/docs/',
|
customCss: ["./src/styles/custom.css"],
|
||||||
},
|
editLink: {
|
||||||
}),
|
baseUrl: "https://github.com/sissbruecker/linkding/edit/master/docs/",
|
||||||
],
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
12
docs/package-lock.json
generated
12
docs/package-lock.json
generated
@@ -5584,9 +5584,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||||
"integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
|
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chownr": "^1.1.1",
|
"chownr": "^1.1.1",
|
||||||
@@ -6511,9 +6511,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-fs": {
|
"node_modules/tar-fs": {
|
||||||
"version": "3.0.8",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
|
||||||
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
|
"integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pump": "^3.0.0",
|
"pump": "^3.0.0",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ Parameters:
|
|||||||
- `q` - Filters results using a search phrase using the same logic as through the UI
|
- `q` - Filters results using a search phrase using the same logic as through the UI
|
||||||
- `limit` - Limits the max. number of results. Default is `100`.
|
- `limit` - Limits the max. number of results. Default is `100`.
|
||||||
- `offset` - Index from which to start returning results
|
- `offset` - Index from which to start returning results
|
||||||
|
- `modified_since` - Filter results to only include bookmarks modified after the specified date (format: ISO 8601, e.g. "2025-01-01T00:00:00Z")
|
||||||
|
- `added_since` - Filter results to only include bookmarks added after the specified date (format: ISO 8601, e.g. "2025-05-29T00:00:00Z")
|
||||||
|
|
||||||
Example response:
|
Example response:
|
||||||
|
|
||||||
@@ -353,6 +355,115 @@ Example payload:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bundles
|
||||||
|
|
||||||
|
**List**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/bundles/
|
||||||
|
```
|
||||||
|
|
||||||
|
List bundles.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- `limit` - Limits the max. number of results. Default is `100`.
|
||||||
|
- `offset` - Index from which to start returning results
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 3,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Work Resources",
|
||||||
|
"search": "productivity tools",
|
||||||
|
"any_tags": "work productivity",
|
||||||
|
"all_tags": "",
|
||||||
|
"excluded_tags": "personal",
|
||||||
|
"order": 0,
|
||||||
|
"date_created": "2020-09-26T09:46:23.006313Z",
|
||||||
|
"date_modified": "2020-09-26T16:01:14.275335Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Tech Articles",
|
||||||
|
"search": "",
|
||||||
|
"any_tags": "programming development",
|
||||||
|
"all_tags": "",
|
||||||
|
"excluded_tags": "outdated",
|
||||||
|
"order": 1,
|
||||||
|
"date_created": "2020-09-27T10:15:30.123456Z",
|
||||||
|
"date_modified": "2020-09-27T10:15:30.123456Z"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retrieve**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/bundles/<id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Retrieves a single bundle by ID.
|
||||||
|
|
||||||
|
**Create**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/bundles/
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new bundle. If no `order` is specified, the bundle will be automatically assigned the next available order position.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Bundle",
|
||||||
|
"search": "search terms",
|
||||||
|
"any_tags": "tag1 tag2",
|
||||||
|
"all_tags": "required-tag",
|
||||||
|
"excluded_tags": "excluded-tag",
|
||||||
|
"order": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update**
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/bundles/<id>/
|
||||||
|
PATCH /api/bundles/<id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates a bundle.
|
||||||
|
When using `PUT`, all fields except read-only ones should be provided.
|
||||||
|
When using `PATCH`, only the fields that should be updated need to be provided.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated Bundle Name",
|
||||||
|
"search": "updated search terms",
|
||||||
|
"any_tags": "new-tag1 new-tag2",
|
||||||
|
"order": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete**
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/bundles/<id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes a bundle by ID.
|
||||||
|
|
||||||
### User
|
### User
|
||||||
|
|
||||||
**Profile**
|
**Profile**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
|||||||
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
|
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
|
||||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||||
|
- [LinkBuddy](https://github.com/peterto/LinkBuddy) An open-source Android and iOS client for linkding, written in React Native. Android apk available on [github](https://github.com/peterto/LinkBuddy/releases) and iOS version on [Apple AppStore](https://apps.apple.com/us/app/linkbuddy-for-linkding/id6740408952). By [peterto](https://github.com/peterto).
|
||||||
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)
|
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)
|
||||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ The Docker image comes in several variants. To use a different image than the de
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
|
To install linkding using Docker you can just run the image from either [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding) or [GitHub Container Registry](https://github.com/sissbruecker/linkding/pkgs/container/linkding):
|
||||||
```shell
|
```shell
|
||||||
|
# Using Docker Hub
|
||||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||||
|
|
||||||
|
# Using GitHub Container Registry
|
||||||
|
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d ghcr.io/sissbruecker/linkding:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
|
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
|
||||||
|
|||||||
@@ -4,22 +4,14 @@
|
|||||||
--sl-text-xs: var(--sl-text-sm);
|
--sl-text-xs: var(--sl-text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colors (dark mode) */
|
||||||
:root,
|
:root,
|
||||||
::backdrop {
|
::backdrop {
|
||||||
/* Colors (dark mode) */
|
|
||||||
--sl-color-white: hsl(0, 0%, 97%); /* “white” */
|
|
||||||
--sl-color-gray-1: hsl(224, 20%, 94%);
|
|
||||||
--sl-color-gray-2: hsl(224, 6%, 85%);
|
|
||||||
--sl-color-gray-3: hsl(224, 6%, 70%);
|
|
||||||
--sl-color-gray-4: hsl(224, 7%, 36%);
|
|
||||||
--sl-color-gray-5: hsl(224, 10%, 23%);
|
|
||||||
--sl-color-gray-6: hsl(224, 14%, 16%);
|
|
||||||
--sl-color-black: hsl(224, 10%, 10%);
|
|
||||||
|
|
||||||
--sl-color-accent: hsl(241, 75%, 64%);
|
--sl-color-accent: hsl(241, 75%, 64%);
|
||||||
--sl-color-text-accent: hsl(241, 90%, 82%);
|
--sl-color-text-accent: hsl(241, 90%, 82%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colors (light mode) */
|
||||||
:root[data-theme='light'],
|
:root[data-theme='light'],
|
||||||
[data-theme='light'] ::backdrop {
|
[data-theme='light'] ::backdrop {
|
||||||
--sl-color-accent: hsl(241, 63%, 59%);
|
--sl-color-accent: hsl(241, 63%, 59%);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.40.0",
|
"version": "1.41.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ click==8.1.7
|
|||||||
# via black
|
# via black
|
||||||
coverage==7.6.1
|
coverage==7.6.1
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
django==5.1.9
|
django==5.1.10
|
||||||
# via django-debug-toolbar
|
# via django-debug-toolbar
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ cryptography==43.0.1
|
|||||||
# josepy
|
# josepy
|
||||||
# mozilla-django-oidc
|
# mozilla-django-oidc
|
||||||
# pyopenssl
|
# pyopenssl
|
||||||
django==5.1.9
|
django==5.1.10
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# django-registration
|
# django-registration
|
||||||
@@ -57,7 +57,7 @@ pyopenssl==24.2.1
|
|||||||
# via josepy
|
# via josepy
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
requests==2.32.3
|
requests==2.32.4
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# mozilla-django-oidc
|
# mozilla-django-oidc
|
||||||
@@ -72,7 +72,7 @@ sqlparse==0.5.1
|
|||||||
# via django
|
# via django
|
||||||
supervisor==4.2.5
|
supervisor==4.2.5
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
urllib3==2.2.3
|
urllib3==2.5.0
|
||||||
# via
|
# via
|
||||||
# requests
|
# requests
|
||||||
# waybackpy
|
# waybackpy
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.40.0
|
1.41.0
|
||||||
|
|||||||
Reference in New Issue
Block a user