mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-07 10:58:25 +02:00
Add bundles for organizing bookmarks (#1097)
* add bundle model and query logic * cleanup tests * add basic form * add success message * Add form tests * Add bundle list view * fix edit view * Add remove button * Add basic preview logic * Make pagination use absolute URLs * Hide bookmark edits when rendering preview * Render bookmark list in preview * Reorder bundles * Show bundles in bookmark view * Make bookmark search respect selected bundle * UI tweaks * Fix bookmark scope * Improve bundle preview * Skip preview if form is submitted * Show correct preview after invalid form submission * Add option to hide bundles * Merge new migrations * Add tests for bundle menu * Improve check for preview being removed
This commit is contained in:
@@ -50,7 +50,7 @@ class BookmarkViewSet(
|
||||
def get_queryset(self):
|
||||
# Provide filtered queryset for list actions
|
||||
user = self.request.user
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||
if self.action == "list":
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
elif self.action == "archived":
|
||||
|
@@ -77,6 +77,7 @@
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
|
||||
|
||||
close();
|
||||
}
|
||||
@@ -128,41 +129,41 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.1.9 on 2025-06-19 08:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0044_bookmark_latest_snapshot"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="hide_bundles",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookmarkBundle",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=256)),
|
||||
("search", models.CharField(blank=True, max_length=256)),
|
||||
("any_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("all_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("excluded_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("order", models.IntegerField(default=0)),
|
||||
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||
("date_modified", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@@ -2,6 +2,7 @@ import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property
|
||||
from typing import List
|
||||
|
||||
from django import forms
|
||||
@@ -157,6 +158,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||
|
||||
|
||||
class BookmarkBundle(models.Model):
|
||||
name = models.CharField(max_length=256, blank=False)
|
||||
search = models.CharField(max_length=256, blank=True)
|
||||
any_tags = models.CharField(max_length=1024, blank=True)
|
||||
all_tags = models.CharField(max_length=1024, blank=True)
|
||||
excluded_tags = models.CharField(max_length=1024, blank=True)
|
||||
order = models.IntegerField(null=False, default=0)
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||
date_modified = models.DateTimeField(auto_now=True, null=False)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BookmarkBundleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
SORT_ADDED_ASC = "added_asc"
|
||||
SORT_ADDED_DESC = "added_desc"
|
||||
@@ -171,11 +193,21 @@ class BookmarkSearch:
|
||||
FILTER_UNREAD_YES = "yes"
|
||||
FILTER_UNREAD_NO = "no"
|
||||
|
||||
params = ["q", "user", "sort", "shared", "unread", "modified_since", "added_since"]
|
||||
params = [
|
||||
"q",
|
||||
"user",
|
||||
"bundle",
|
||||
"sort",
|
||||
"shared",
|
||||
"unread",
|
||||
"modified_since",
|
||||
"added_since",
|
||||
]
|
||||
preferences = ["sort", "shared", "unread"]
|
||||
defaults = {
|
||||
"q": "",
|
||||
"user": "",
|
||||
"bundle": None,
|
||||
"sort": SORT_ADDED_DESC,
|
||||
"shared": FILTER_SHARED_OFF,
|
||||
"unread": FILTER_UNREAD_OFF,
|
||||
@@ -187,19 +219,23 @@ class BookmarkSearch:
|
||||
self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
bundle: BookmarkBundle = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
modified_since: str = None,
|
||||
added_since: str = None,
|
||||
preferences: dict = None,
|
||||
request: any = None,
|
||||
):
|
||||
if not preferences:
|
||||
preferences = {}
|
||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||
self.request = request
|
||||
|
||||
self.q = q or self.defaults["q"]
|
||||
self.user = user or self.defaults["user"]
|
||||
self.bundle = bundle or self.defaults["bundle"]
|
||||
self.sort = sort or self.defaults["sort"]
|
||||
self.shared = shared or self.defaults["shared"]
|
||||
self.unread = unread or self.defaults["unread"]
|
||||
@@ -232,7 +268,14 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
def query_params(self):
|
||||
return {param: self.__dict__[param] for param in self.modified_params}
|
||||
query_params = {}
|
||||
for param in self.modified_params:
|
||||
value = self.__dict__[param]
|
||||
if isinstance(value, models.Model):
|
||||
query_params[param] = value.id
|
||||
else:
|
||||
query_params[param] = value
|
||||
return query_params
|
||||
|
||||
@property
|
||||
def preferences_dict(self):
|
||||
@@ -241,14 +284,21 @@ class BookmarkSearch:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||
initial_values = {}
|
||||
for param in BookmarkSearch.params:
|
||||
value = query_dict.get(param)
|
||||
if value:
|
||||
initial_values[param] = value
|
||||
if param == "bundle":
|
||||
initial_values[param] = BookmarkBundle.objects.filter(
|
||||
owner=request.user, pk=value
|
||||
).first()
|
||||
else:
|
||||
initial_values[param] = value
|
||||
|
||||
return BookmarkSearch(**initial_values, preferences=preferences)
|
||||
return BookmarkSearch(
|
||||
**initial_values, preferences=preferences, request=request
|
||||
)
|
||||
|
||||
|
||||
class BookmarkSearchForm(forms.Form):
|
||||
@@ -271,6 +321,7 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
bundle = forms.CharField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
@@ -295,7 +346,11 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
for param in search.params:
|
||||
# set initial values for modified params
|
||||
self.fields[param].initial = search.__dict__[param]
|
||||
value = search.__dict__.get(param)
|
||||
if isinstance(value, models.Model):
|
||||
self.fields[param].initial = value.id
|
||||
else:
|
||||
self.fields[param].initial = value
|
||||
|
||||
# Mark non-editable modified fields as hidden. That way, templates
|
||||
# rendering a form can just loop over hidden_fields to ensure that
|
||||
@@ -416,6 +471,7 @@ class UserProfile(models.Model):
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
hide_bundles = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
@@ -456,6 +512,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
"hide_bundles",
|
||||
]
|
||||
|
||||
|
||||
|
@@ -7,12 +7,21 @@ from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharFiel
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
UserProfile,
|
||||
parse_tag_string,
|
||||
)
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
user: User,
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||
|
||||
@@ -36,8 +45,51 @@ def query_shared_bookmarks(
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
|
||||
# Search terms
|
||||
search_terms = parse_query_string(bundle.search)["search_terms"]
|
||||
for term in search_terms:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
# Any tags - at least one tag must match
|
||||
any_tags = parse_tag_string(bundle.any_tags, " ")
|
||||
if len(any_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in any_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
|
||||
query_set = query_set.filter(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
# All tags - all tags must match
|
||||
all_tags = parse_tag_string(bundle.all_tags, " ")
|
||||
for tag in all_tags:
|
||||
query_set = query_set.filter(tags__name__iexact=tag)
|
||||
|
||||
# Excluded tags - no tags must match
|
||||
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
|
||||
if len(exclude_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in exclude_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
query_set = query_set.exclude(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
return query_set
|
||||
|
||||
|
||||
def _base_bookmarks_query(
|
||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
@@ -102,6 +154,10 @@ def _base_bookmarks_query(
|
||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||
query_set = query_set.filter(shared=False)
|
||||
|
||||
# Filter by bundle
|
||||
if search.bundle:
|
||||
query_set = _filter_bundle(query_set, search.bundle)
|
||||
|
||||
# Sort
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
|
@@ -49,50 +49,9 @@
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .asset-text .filesize {
|
||||
& .filesize {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
& .asset-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .assets-actions {
|
||||
|
@@ -379,6 +379,26 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.bundle-menu {
|
||||
list-style-type: none;
|
||||
margin: 0 0 var(--unit-6);
|
||||
|
||||
.bundle-menu-item {
|
||||
margin: 0;
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.bundle-menu-item a {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.bundle-menu-item.selected a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
34
bookmarks/styles/bundles.css
Normal file
34
bookmarks/styles/bundles.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.bundles-page {
|
||||
h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
.list-item .list-item-icon {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.list-item.drag-start {
|
||||
--secondary-border-color: transparent;
|
||||
}
|
||||
|
||||
.list-item.dragging > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bundles-editor-page {
|
||||
&.grid {
|
||||
gap: var(--unit-9);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
background: var(--body-color);
|
||||
padding: var(--unit-3) 0;
|
||||
}
|
||||
}
|
@@ -60,3 +60,60 @@ span.confirmation {
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.message-list {
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
.toast {
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Item list */
|
||||
.item-list {
|
||||
& .list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .list-item-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .list-item-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .list-item-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -27,15 +27,3 @@ header {
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
@@ -28,3 +28,4 @@
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
||||
@import "bundles.css";
|
||||
|
@@ -242,6 +242,14 @@
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
@@ -30,16 +30,10 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -77,72 +77,76 @@
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% if not bookmark_list.is_preview %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
{% if not bookmark_list.is_preview %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
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>
|
||||
{% if details.assets %}
|
||||
<div class="assets">
|
||||
<div class="item-list assets">
|
||||
{% for asset in details.assets %}
|
||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
||||
<div class="asset-icon {{ asset.icon_classes }}">
|
||||
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||
{% include 'bookmarks/details/asset_icon.html' %}
|
||||
</div>
|
||||
<div class="asset-text {{ asset.text_classes }}">
|
||||
<div class="list-item-text {{ asset.text_classes }}">
|
||||
<span class="truncate">
|
||||
{{ asset.display_name }}
|
||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<div class="list-item-actions">
|
||||
{% if asset.file %}
|
||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||
{% endif %}
|
||||
|
@@ -32,16 +32,10 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -67,7 +67,7 @@
|
||||
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts">
|
||||
<div class="message-list">
|
||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<ul class="pagination">
|
||||
{% if prev_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
@@ -14,7 +14,7 @@
|
||||
{% for page_link in page_links %}
|
||||
{% if page_link %}
|
||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{% if next_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
||||
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
|
@@ -38,14 +38,7 @@
|
||||
<br>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
8
bookmarks/templates/bookmarks/tag_section.html
Normal file
8
bookmarks/templates/bookmarks/tag_section.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
33
bookmarks/templates/bundles/edit.html
Normal file
33
bookmarks/templates/bundles/edit.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit bundle - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bundles-editor-page grid columns-md-1">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bundle</h1>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'bundles/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="col-2" aria-labelledby="preview-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="preview-heading">Preview</h2>
|
||||
</div>
|
||||
|
||||
{% include 'bundles/preview.html' %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
91
bookmarks/templates/bundles/form.html
Normal file
91
bookmarks/templates/bundles/form.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
|
||||
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.name.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
|
||||
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.search.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.search.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-input-hint">
|
||||
Search terms to match bookmarks in this bundle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
At least one of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
|
||||
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
All of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
|
||||
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
None of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer d-flex mt-4">
|
||||
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
|
||||
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
|
||||
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundleForm = document.getElementById('bundle-form');
|
||||
const previewLink = document.getElementById('preview-link');
|
||||
|
||||
let pendingUpdate;
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingUpdate) {
|
||||
clearTimeout(pendingUpdate);
|
||||
}
|
||||
pendingUpdate = setTimeout(() => {
|
||||
// Ignore if link has been removed (e.g. form submit or navigation)
|
||||
if (!previewLink.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = previewLink.href.split('?')[0];
|
||||
const params = new URLSearchParams();
|
||||
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value.trim()) {
|
||||
params.set(input.name, input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||
previewLink.click();
|
||||
}, 500)
|
||||
}
|
||||
|
||||
bundleForm.addEventListener('input', scheduleUpdate);
|
||||
bundleForm.addEventListener('change', scheduleUpdate);
|
||||
})();
|
||||
</script>
|
124
bookmarks/templates/bundles/index.html
Normal file
124
bookmarks/templates/bundles/index.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Bundles - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="bundles-page" aria-labelledby="main-heading">
|
||||
<h1 id="main-heading">Bundles</h1>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
{% if bundles %}
|
||||
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="item-list bundles">
|
||||
{% for bundle in bundles %}
|
||||
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{{ bundle.name }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||
class="btn btn-link">Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||
<input type="hidden" name="move_position" value="">
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bundles yet</p>
|
||||
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundlesList = document.querySelector(".item-list.bundles");
|
||||
if (!bundlesList) return;
|
||||
|
||||
let draggedElement = null;
|
||||
|
||||
const listItems = bundlesList.querySelectorAll('.list-item');
|
||||
listItems.forEach((item) => {
|
||||
item.addEventListener('dragstart', handleDragStart);
|
||||
item.addEventListener('dragend', handleDragEnd);
|
||||
item.addEventListener('dragover', handleDragOver);
|
||||
item.addEventListener('dragenter', handleDragEnter);
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
this.classList.add('drag-start');
|
||||
setTimeout(() => {
|
||||
this.classList.remove('drag-start');
|
||||
this.classList.add('dragging');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
|
||||
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
||||
const movePositionInput = document.querySelector('input[name="move_position"]');
|
||||
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
||||
movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
|
||||
|
||||
const form = this.closest('form');
|
||||
form.requestSubmit(moveBundleInput);
|
||||
|
||||
draggedElement = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnter() {
|
||||
if (this !== draggedElement) {
|
||||
const listItems = Array.from(bundlesList.children);
|
||||
const draggedIndex = listItems.indexOf(draggedElement);
|
||||
const currentIndex = listItems.indexOf(this);
|
||||
|
||||
if (draggedIndex < currentIndex) {
|
||||
this.insertAdjacentElement('afterend', draggedElement);
|
||||
} else {
|
||||
this.insertAdjacentElement('beforebegin', draggedElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
33
bookmarks/templates/bundles/new.html
Normal file
33
bookmarks/templates/bundles/new.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="New bundle - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bundles-editor-page grid columns-md-1">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bundle</h1>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'bundles/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="col-2" aria-labelledby="preview-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="preview-heading">Preview</h2>
|
||||
</div>
|
||||
|
||||
{% include 'bundles/preview.html' %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
12
bookmarks/templates/bundles/preview.html
Normal file
12
bookmarks/templates/bundles/preview.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<turbo-frame id="preview">
|
||||
{% if bookmark_list.is_empty %}
|
||||
<div>
|
||||
No bookmarks match the current bundle.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4">
|
||||
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
|
||||
</div>
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
@@ -139,6 +139,15 @@
|
||||
Instead, the tags are shown in an expandable drawer.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
|
||||
{{ form.hide_bundles }}
|
||||
<i class="form-icon"></i> Hide bundles
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Allows to hide the bundles in the side panel if you don't intend to use them.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
|
9
bookmarks/templates/shared/messages.html
Normal file
9
bookmarks/templates/shared/messages.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% if messages %}
|
||||
<div class="message-list">
|
||||
{% for message in messages %}
|
||||
<div class="toast toast-{{ message.tags }}" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
@@ -13,18 +13,21 @@ register = template.Library()
|
||||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||
)
|
||||
def pagination(context, page: Page):
|
||||
request = context["request"]
|
||||
base_url = request.build_absolute_uri(request.path)
|
||||
|
||||
# remove page number and details from query parameters
|
||||
query_params = context["request"].GET.copy()
|
||||
query_params = request.GET.copy()
|
||||
query_params.pop("page", None)
|
||||
query_params.pop("details", None)
|
||||
|
||||
prev_link = (
|
||||
_generate_link(query_params, page.previous_page_number())
|
||||
_generate_link(base_url, query_params, page.previous_page_number())
|
||||
if page.has_previous()
|
||||
else None
|
||||
)
|
||||
next_link = (
|
||||
_generate_link(query_params, page.next_page_number())
|
||||
_generate_link(base_url, query_params, page.next_page_number())
|
||||
if page.has_next()
|
||||
else None
|
||||
)
|
||||
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
|
||||
if page_number == -1:
|
||||
page_links.append(None)
|
||||
else:
|
||||
link = _generate_link(query_params, page_number)
|
||||
link = _generate_link(base_url, query_params, page_number)
|
||||
page_links.append(
|
||||
{
|
||||
"active": page_number == page.number,
|
||||
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||
return reduce(append_page, visible_pages, [])
|
||||
|
||||
|
||||
def _generate_link(query_params: QueryDict, page_number: int) -> str:
|
||||
def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
|
||||
query_params = query_params.copy()
|
||||
query_params["page"] = page_number
|
||||
return query_params.urlencode()
|
||||
return f"{base_url}?{query_params.urlencode()}"
|
||||
|
@@ -17,7 +17,7 @@ from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
|
||||
|
||||
|
||||
class BookmarkFactoryMixin:
|
||||
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
return Bookmark.objects.get(title=title)
|
||||
|
||||
def setup_bundle(
|
||||
self,
|
||||
user: User = None,
|
||||
name: str = None,
|
||||
search: str = "",
|
||||
any_tags: str = "",
|
||||
all_tags: str = "",
|
||||
excluded_tags: str = "",
|
||||
order: int = 0,
|
||||
):
|
||||
if user is None:
|
||||
user = self.get_or_create_test_user()
|
||||
if not name:
|
||||
name = get_random_string(length=32)
|
||||
bundle = BookmarkBundle(
|
||||
name=name,
|
||||
owner=user,
|
||||
date_created=timezone.now(),
|
||||
search=search,
|
||||
any_tags=any_tags,
|
||||
all_tags=all_tags,
|
||||
excluded_tags=excluded_tags,
|
||||
order=order,
|
||||
)
|
||||
bundle.save()
|
||||
return bundle
|
||||
|
||||
def setup_asset(
|
||||
self,
|
||||
bookmark: Bookmark,
|
||||
@@ -239,7 +266,7 @@ class BookmarkFactoryMixin:
|
||||
user.profile.save()
|
||||
return user
|
||||
|
||||
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
|
||||
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
|
||||
all_tags = []
|
||||
for bookmark in bookmarks:
|
||||
all_tags = all_tags + list(bookmark.tags.all())
|
||||
|
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_index_action_bulk_select_across_respects_bundle(self):
|
||||
self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_respects_bundle(self):
|
||||
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_shared_action_bulk_select_across_not_supported(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
|
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
BookmarkListTestMixin,
|
||||
TagCloudTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="foo", archived=True
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="bar", archived=True
|
||||
)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
@@ -515,3 +550,20 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
def test_hide_bundles_when_enabled_in_profile(self):
|
||||
# visible by default
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||
|
||||
# hidden when disabled in profile
|
||||
user_profile = self.get_or_create_test_user().profile
|
||||
user_profile.hide_bundles = True
|
||||
user_profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||
|
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
asset_item = self.find_asset(asset_list, asset)
|
||||
self.assertIsNotNone(asset_item)
|
||||
|
||||
asset_icon = asset_item.select_one(".asset-icon svg")
|
||||
asset_icon = asset_item.select_one(".list-item-icon svg")
|
||||
self.assertIsNotNone(asset_icon)
|
||||
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
asset_text = asset_item.select_one(".list-item-text span")
|
||||
self.assertIsNotNone(asset_text)
|
||||
self.assertIn(asset.display_name, asset_text.text)
|
||||
|
||||
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, pending_asset)
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
asset_text = asset_item.select_one(".list-item-text span")
|
||||
self.assertIn("(queued)", asset_text.text)
|
||||
|
||||
asset_item = self.find_asset(soup, failed_asset)
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
asset_text = asset_item.select_one(".list-item-text span")
|
||||
self.assertIn("(failed)", asset_text.text)
|
||||
|
||||
def test_asset_file_size(self):
|
||||
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, asset1)
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
asset_text = asset_item.select_one(".list-item-text")
|
||||
self.assertEqual(asset_text.text.strip(), asset1.display_name)
|
||||
|
||||
asset_item = self.find_asset(soup, asset2)
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
asset_text = asset_item.select_one(".list-item-text")
|
||||
self.assertIn("53.4\xa0KB", asset_text.text)
|
||||
|
||||
asset_item = self.find_asset(soup, asset3)
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
asset_text = asset_item.select_one(".list-item-text")
|
||||
self.assertIn("11.0\xa0MB", asset_text.text)
|
||||
|
||||
def test_asset_actions_visibility(self):
|
||||
|
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertIsNotNone(form)
|
||||
self.assertEqual(form.attrs["action"], url)
|
||||
|
||||
def assertVisibleBundles(self, soup, bundles):
|
||||
bundle_list = soup.select_one("ul.bundle-menu")
|
||||
self.assertIsNotNone(bundle_list)
|
||||
|
||||
list_items = bundle_list.select("li.bundle-menu-item")
|
||||
self.assertEqual(len(list_items), len(bundles))
|
||||
|
||||
for index, list_item in enumerate(list_items):
|
||||
bundle = bundles[index]
|
||||
link = list_item.select_one("a")
|
||||
href = link.attrs["href"]
|
||||
|
||||
self.assertEqual(bundle.name, list_item.text.strip())
|
||||
self.assertEqual(f"?bundle={bundle.id}", href)
|
||||
|
||||
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
@@ -494,3 +542,43 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
def test_list_bundles(self):
|
||||
books = self.setup_bundle(name="Books bundle", order=3)
|
||||
music = self.setup_bundle(name="Music bundle", order=1)
|
||||
tools = self.setup_bundle(name="Tools bundle", order=2)
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertVisibleBundles(soup, [music, tools, books])
|
||||
|
||||
def test_list_bundles_only_shows_user_owned_bundles(self):
|
||||
user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
|
||||
other_user = self.setup_user()
|
||||
self.setup_bundle(user=other_user)
|
||||
self.setup_bundle(user=other_user)
|
||||
self.setup_bundle(user=other_user)
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertVisibleBundles(soup, user_bundles)
|
||||
|
||||
def test_hide_bundles_when_enabled_in_profile(self):
|
||||
# visible by default
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||
|
||||
# hidden when disabled in profile
|
||||
user_profile = self.get_or_create_test_user().profile
|
||||
user_profile.hide_bundles = True
|
||||
user_profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||
|
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
||||
form = BookmarkSearchForm(search)
|
||||
self.assertEqual(form["q"].initial, "")
|
||||
self.assertEqual(form["user"].initial, "")
|
||||
self.assertEqual(form["bundle"].initial, None)
|
||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
|
||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
|
||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
|
||||
# with params
|
||||
bundle = self.setup_bundle()
|
||||
search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
form = BookmarkSearchForm(search)
|
||||
self.assertEqual(form["q"].initial, "search query")
|
||||
self.assertEqual(form["user"].initial, "user123")
|
||||
self.assertEqual(form["bundle"].initial, bundle.id)
|
||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
|
||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
|
||||
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
|
||||
|
||||
# all modified params
|
||||
bundle = self.setup_bundle()
|
||||
search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
form = BookmarkSearchForm(search)
|
||||
self.assertCountEqual(
|
||||
form.hidden_fields(),
|
||||
[form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
|
||||
[
|
||||
form["q"],
|
||||
form["sort"],
|
||||
form["user"],
|
||||
form["bundle"],
|
||||
form["shared"],
|
||||
form["unread"],
|
||||
],
|
||||
)
|
||||
|
||||
# some modified params are editable fields
|
||||
|
@@ -2,16 +2,23 @@ from django.http import QueryDict
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.models import BookmarkSearch
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkSearchModelTest(TestCase):
|
||||
class MockRequest:
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
|
||||
class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
|
||||
def test_from_request(self):
|
||||
# no params
|
||||
query_dict = QueryDict()
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict)
|
||||
search = BookmarkSearch.from_request(None, query_dict)
|
||||
self.assertEqual(search.q, "")
|
||||
self.assertEqual(search.user, "")
|
||||
self.assertEqual(search.bundle, None)
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
|
||||
# some params
|
||||
query_dict = QueryDict("q=search query&user=user123")
|
||||
|
||||
bookmark_search = BookmarkSearch.from_request(query_dict)
|
||||
bookmark_search = BookmarkSearch.from_request(None, query_dict)
|
||||
self.assertEqual(bookmark_search.q, "search query")
|
||||
self.assertEqual(bookmark_search.user, "user123")
|
||||
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
|
||||
# all params
|
||||
bundle = self.setup_bundle()
|
||||
request = MockRequest(self.get_or_create_test_user())
|
||||
query_dict = QueryDict(
|
||||
"q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
|
||||
f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
|
||||
)
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict)
|
||||
search = BookmarkSearch.from_request(request, query_dict)
|
||||
self.assertEqual(search.q, "search query")
|
||||
self.assertEqual(search.user, "user123")
|
||||
self.assertEqual(search.bundle, bundle)
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
|
||||
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
|
||||
}
|
||||
query_dict = QueryDict("q=search query")
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict, preferences)
|
||||
search = BookmarkSearch.from_request(None, query_dict, preferences)
|
||||
self.assertEqual(search.q, "search query")
|
||||
self.assertEqual(search.user, "")
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
|
||||
}
|
||||
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
|
||||
|
||||
search = BookmarkSearch.from_request(query_dict, preferences)
|
||||
search = BookmarkSearch.from_request(None, query_dict, preferences)
|
||||
self.assertEqual(search.q, "")
|
||||
self.assertEqual(search.user, "")
|
||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
|
||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
|
||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||
|
||||
def test_from_request_ignores_invalid_bundle_param(self):
|
||||
self.setup_bundle()
|
||||
|
||||
# bundle does not exist
|
||||
request = MockRequest(self.get_or_create_test_user())
|
||||
query_dict = QueryDict("bundle=99999")
|
||||
search = BookmarkSearch.from_request(request, query_dict)
|
||||
self.assertIsNone(search.bundle)
|
||||
|
||||
# bundle belongs to another user
|
||||
other_user = self.setup_user()
|
||||
bundle = self.setup_bundle(user=other_user)
|
||||
query_dict = QueryDict(f"bundle={bundle.id}")
|
||||
search = BookmarkSearch.from_request(request, query_dict)
|
||||
self.assertIsNone(search.bundle)
|
||||
|
||||
def test_query_params(self):
|
||||
# no params
|
||||
search = BookmarkSearch()
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# params are default values
|
||||
search = BookmarkSearch(
|
||||
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
|
||||
)
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# some modified params
|
||||
search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
|
||||
self.assertEqual(
|
||||
search.query_params,
|
||||
{"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
|
||||
)
|
||||
|
||||
# all modified params
|
||||
bundle = self.setup_bundle()
|
||||
search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
self.assertEqual(
|
||||
search.query_params,
|
||||
{
|
||||
"q": "search query",
|
||||
"sort": BookmarkSearch.SORT_ADDED_ASC,
|
||||
"user": "user123",
|
||||
"bundle": bundle.id,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
|
||||
# preferences are not query params if they match default
|
||||
preferences = {
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
search = BookmarkSearch(preferences=preferences)
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# param is not a query param if it matches the preference
|
||||
preferences = {
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
search = BookmarkSearch(
|
||||
sort=BookmarkSearch.SORT_TITLE_ASC,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
preferences=preferences,
|
||||
)
|
||||
self.assertEqual(search.query_params, {})
|
||||
|
||||
# overriding preferences is a query param
|
||||
preferences = {
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
search = BookmarkSearch(
|
||||
sort=BookmarkSearch.SORT_TITLE_DESC,
|
||||
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
preferences=preferences,
|
||||
)
|
||||
self.assertEqual(
|
||||
search.query_params,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
def test_modified_params(self):
|
||||
# no params
|
||||
bookmark_search = BookmarkSearch()
|
||||
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
|
||||
self.assertCountEqual(modified_params, ["q", "sort"])
|
||||
|
||||
# all modified params
|
||||
bundle = self.setup_bundle()
|
||||
bookmark_search = BookmarkSearch(
|
||||
q="search query",
|
||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||
user="user123",
|
||||
bundle=bundle,
|
||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||
)
|
||||
modified_params = bookmark_search.modified_params
|
||||
self.assertCountEqual(
|
||||
modified_params, ["q", "sort", "user", "shared", "unread"]
|
||||
modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
|
||||
)
|
||||
|
||||
# preferences are not modified params
|
||||
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
|
||||
)
|
||||
|
||||
# only returns preferences
|
||||
bookmark_search = BookmarkSearch(q="search query", user="user123")
|
||||
bundle = self.setup_bundle()
|
||||
bookmark_search = BookmarkSearch(
|
||||
q="search query", user="user123", bundle=bundle
|
||||
)
|
||||
self.assertEqual(
|
||||
bookmark_search.preferences_dict,
|
||||
{
|
||||
|
@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
request.user_profile = self.get_or_create_test_user().profile
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(request, request.GET)
|
||||
context = RequestContext(
|
||||
request,
|
||||
{
|
||||
|
@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
self.authenticate()
|
||||
user = self.setup_user(enable_sharing=True)
|
||||
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, shared=True, user=user, prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
|
||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
self.authenticate()
|
||||
user1 = self.setup_user(enable_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
user3 = self.setup_user(enable_sharing=True)
|
||||
visible_tags = [
|
||||
self.setup_tag(user=user1),
|
||||
self.setup_tag(user=user2),
|
||||
self.setup_tag(user=user3),
|
||||
]
|
||||
invisible_tags = [
|
||||
self.setup_tag(user=user1),
|
||||
self.setup_tag(user=user2),
|
||||
self.setup_tag(user=user3),
|
||||
]
|
||||
|
||||
self.setup_bookmark(
|
||||
shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
|
||||
)
|
||||
self.setup_bookmark(
|
||||
shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
|
||||
)
|
||||
self.setup_bookmark(
|
||||
shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
|
||||
)
|
||||
|
||||
self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
|
||||
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
|
||||
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
|
||||
|
||||
bundle = self.setup_bundle(search="searchvalue")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
|
||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
|
@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
def test_list_bookmarks_should_filter_by_bundle(self):
|
||||
self.authenticate()
|
||||
search_value = self.get_random_string()
|
||||
bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
|
||||
self.setup_numbered_bookmarks(5)
|
||||
bundle = self.setup_bundle(search=search_value)
|
||||
|
||||
response = self.get(
|
||||
reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
def test_list_bookmarks_filter_unread(self):
|
||||
self.authenticate()
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
|
||||
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
|
||||
def test_list_archived_bookmarks_should_filter_by_bundle(self):
|
||||
self.authenticate()
|
||||
search_value = self.get_random_string()
|
||||
archived_bookmarks = self.setup_numbered_bookmarks(
|
||||
5, archived=True, prefix=search_value
|
||||
)
|
||||
self.setup_numbered_bookmarks(5, archived=True)
|
||||
bundle = self.setup_bundle(search=search_value)
|
||||
|
||||
response = self.get(
|
||||
reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
|
||||
def test_list_archived_bookmarks_should_respect_sort(self):
|
||||
self.authenticate()
|
||||
bookmarks = self.setup_numbered_bookmarks(5, archived=True)
|
||||
|
@@ -10,7 +10,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.middlewares import LinkdingMiddleware
|
||||
from bookmarks.models import Bookmark, UserProfile, User
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.views import contexts
|
||||
|
||||
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
{label_content}
|
||||
</a>
|
||||
<span>|</span>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
contexts.BookmarkListContext
|
||||
] = contexts.ActiveBookmarkListContext,
|
||||
user: User | AnonymousUser = None,
|
||||
is_preview: bool = False,
|
||||
) -> str:
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
||||
middleware(request)
|
||||
|
||||
bookmark_list_context = context_type(request)
|
||||
search = BookmarkSearch.from_request(request, request.GET)
|
||||
bookmark_list_context = context_type(request, search)
|
||||
if is_preview:
|
||||
bookmark_list_context.is_preview = True
|
||||
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
|
||||
|
||||
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
|
||||
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
soup = self.make_soup(html)
|
||||
bookmarks = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(10, len(bookmarks))
|
||||
|
||||
def test_no_actions_rendered_when_is_preview(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.date_added = timezone.now() - relativedelta(days=8)
|
||||
bookmark.web_archive_snapshot_url = "https://example.com"
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_template(is_preview=True)
|
||||
|
||||
# Verify no actions are rendered
|
||||
self.assertNoViewLink(html, bookmark)
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
self.assertNotesToggle(html, count=0)
|
||||
|
||||
# But date should still be rendered
|
||||
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
|
||||
|
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):
|
||||
href = href if href else "?page={0}".format(page_number)
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item">
|
||||
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else "?page={0}".format(page_number)
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item">
|
||||
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
href: str = None,
|
||||
):
|
||||
active_class = "active" if active else ""
|
||||
href = href if href else "?page={0}".format(page_number)
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item {1}">
|
||||
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
rendered_template = self.render_template(
|
||||
100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
|
||||
)
|
||||
self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
|
||||
self.assertPageLink(
|
||||
rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
|
||||
self.assertPrevLink(
|
||||
rendered_template,
|
||||
1,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
|
||||
rendered_template,
|
||||
1,
|
||||
False,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template,
|
||||
2,
|
||||
True,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=2",
|
||||
)
|
||||
self.assertNextLink(
|
||||
rendered_template,
|
||||
3,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=3",
|
||||
)
|
||||
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
|
||||
|
||||
def test_removes_details_parameter(self):
|
||||
rendered_template = self.render_template(
|
||||
100, 10, 2, url="/test?details=1&page=2"
|
||||
)
|
||||
self.assertPrevLink(rendered_template, 1, href="?page=1")
|
||||
self.assertPageLink(rendered_template, 1, False, href="?page=1")
|
||||
self.assertPageLink(rendered_template, 2, True, href="?page=2")
|
||||
self.assertNextLink(rendered_template, 3, href="?page=3")
|
||||
self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
|
||||
self.assertPageLink(
|
||||
rendered_template, 1, False, href="http://testserver/test?page=1"
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template, 2, True, href="http://testserver/test?page=2"
|
||||
)
|
||||
self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")
|
||||
|
@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
|
||||
]
|
||||
|
||||
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
|
||||
def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
|
||||
expected_items = []
|
||||
for item_list in item_lists:
|
||||
expected_items = expected_items + item_list
|
||||
@@ -1287,3 +1287,267 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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",
|
||||
"sticky_pagination": False,
|
||||
"collapse_side_panel": False,
|
||||
"hide_bundles": False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"items_per_page": "10",
|
||||
"sticky_pagination": True,
|
||||
"collapse_side_panel": True,
|
||||
"hide_bundles": True,
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("linkding:settings.update"), form_data, follow=True
|
||||
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
|
||||
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
|
@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from bookmarks.middlewares import LinkdingMiddleware
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.models import BookmarkSearch, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.views import contexts
|
||||
|
||||
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
middleware = LinkdingMiddleware(lambda r: HttpResponse())
|
||||
middleware(request)
|
||||
|
||||
tag_cloud_context = context_type(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
tag_cloud_context = context_type(request, search)
|
||||
context = RequestContext(request, {"tag_cloud": tag_cloud_context})
|
||||
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
|
||||
return template_to_render.render(context)
|
||||
|
@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
# Should render toasts container
|
||||
self.assertContains(response, '<div class="toasts">')
|
||||
self.assertContains(response, '<div class="message-list">')
|
||||
# Should render two toasts
|
||||
self.assertContains(response, '<div class="toast d-flex">', count=2)
|
||||
|
||||
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
# Should not render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
|
||||
self.assertContains(response, '<div class="message-list">', count=0)
|
||||
# Should not render toasts
|
||||
self.assertContains(response, '<div class="toast">', count=0)
|
||||
|
||||
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
# Should not render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
|
||||
self.assertContains(response, '<div class="message-list">', count=0)
|
||||
# Should not render toasts
|
||||
self.assertContains(response, '<div class="toast">', count=0)
|
||||
|
||||
|
@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
request.user_profile = self.get_or_create_test_user().profile
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(request, request.GET)
|
||||
context = RequestContext(
|
||||
request,
|
||||
{
|
||||
|
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]")
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
def count_bookmarks(self):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
return bookmark_tags.count()
|
||||
|
||||
def locate_details_modal(self):
|
||||
return self.page.locator(".modal.bookmark-details")
|
||||
|
||||
|
@@ -43,6 +43,12 @@ urlpatterns = [
|
||||
views.assets.read,
|
||||
name="assets.read",
|
||||
),
|
||||
# Bundles
|
||||
path("bundles", views.bundles.index, name="bundles.index"),
|
||||
path("bundles/action", views.bundles.action, name="bundles.action"),
|
||||
path("bundles/new", views.bundles.new, name="bundles.new"),
|
||||
path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
|
||||
path("bundles/preview", views.bundles.preview, name="bundles.preview"),
|
||||
# Settings
|
||||
path("settings", views.settings.general, name="settings.index"),
|
||||
path("settings/general", views.settings.general, name="settings.general"),
|
||||
|
@@ -1,6 +1,7 @@
|
||||
from .assets import *
|
||||
from .auth import *
|
||||
from .bookmarks import *
|
||||
from . import bundles
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
from .health import health
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from django.http import Http404
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Toast
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
|
||||
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):
|
||||
try:
|
||||
asset = BookmarkAsset.objects.get(pk=asset_id)
|
||||
|
@@ -42,8 +42,12 @@ def index(request: HttpRequest):
|
||||
if request.method == "POST":
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
|
||||
bundles = contexts.BundlesContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request, search)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.ActiveBookmarkDetailsContext
|
||||
)
|
||||
@@ -54,6 +58,7 @@ def index(request: HttpRequest):
|
||||
{
|
||||
"page_title": "Bookmarks - Linkding",
|
||||
"bookmark_list": bookmark_list,
|
||||
"bundles": bundles,
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
},
|
||||
@@ -65,8 +70,12 @@ def archived(request: HttpRequest):
|
||||
if request.method == "POST":
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
|
||||
bundles = contexts.BundlesContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.ArchivedBookmarkDetailsContext
|
||||
)
|
||||
@@ -77,6 +86,7 @@ def archived(request: HttpRequest):
|
||||
{
|
||||
"page_title": "Archived bookmarks - Linkding",
|
||||
"bookmark_list": bookmark_list,
|
||||
"bundles": bundles,
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
},
|
||||
@@ -87,8 +97,11 @@ def shared(request: HttpRequest):
|
||||
if request.method == "POST":
|
||||
return search_action(request)
|
||||
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request, search)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.SharedBookmarkDetailsContext
|
||||
)
|
||||
@@ -132,13 +145,13 @@ def search_action(request: HttpRequest):
|
||||
if "save" in request.POST:
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseForbidden()
|
||||
search = BookmarkSearch.from_request(request.POST)
|
||||
search = BookmarkSearch.from_request(request, request.POST)
|
||||
request.user_profile.search_preferences = search.preferences_dict
|
||||
request.user_profile.save()
|
||||
|
||||
# redirect to base url including new query params
|
||||
search = BookmarkSearch.from_request(
|
||||
request.POST, request.user_profile.search_preferences
|
||||
request, request.POST, request.user_profile.search_preferences
|
||||
)
|
||||
base_url = request.path
|
||||
query_params = search.query_params
|
||||
@@ -248,7 +261,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
|
||||
|
||||
@login_required
|
||||
def index_action(request: HttpRequest):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
||||
|
||||
response = handle_action(request, query)
|
||||
@@ -263,7 +278,9 @@ def index_action(request: HttpRequest):
|
||||
|
||||
@login_required
|
||||
def archived_action(request: HttpRequest):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||
|
||||
response = handle_action(request, query)
|
||||
|
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 (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
User,
|
||||
UserProfile,
|
||||
@@ -178,15 +179,13 @@ class BookmarkItem:
|
||||
class BookmarkListContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
|
||||
request_context = self.request_context(request)
|
||||
user = request.user
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
self.search = BookmarkSearch.from_request(
|
||||
self.request.GET, user_profile.search_preferences
|
||||
)
|
||||
self.search = search
|
||||
|
||||
query_set = request_context.get_bookmark_query_set(self.search)
|
||||
page_number = request.GET.get("page")
|
||||
@@ -219,6 +218,7 @@ class BookmarkListContext:
|
||||
self.show_preview_images = user_profile.enable_preview_images
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||
self.is_preview = False
|
||||
|
||||
@staticmethod
|
||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
||||
@@ -315,14 +315,12 @@ class TagGroup:
|
||||
class TagCloudContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
|
||||
request_context = self.request_context(request)
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
self.search = BookmarkSearch.from_request(
|
||||
self.request.GET, user_profile.search_preferences
|
||||
)
|
||||
self.search = search
|
||||
|
||||
query_set = request_context.get_tag_query_set(self.search)
|
||||
tags = list(query_set)
|
||||
@@ -461,3 +459,23 @@ def get_details_context(
|
||||
return None
|
||||
|
||||
return context_type(request, bookmark)
|
||||
|
||||
|
||||
class BundlesContext:
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
self.request = request
|
||||
self.user = request.user
|
||||
self.user_profile = request.user_profile
|
||||
|
||||
self.bundles = (
|
||||
BookmarkBundle.objects.filter(owner=self.user).order_by("order").all()
|
||||
)
|
||||
self.is_empty = len(self.bundles) == 0
|
||||
|
||||
selected_bundle_id = (
|
||||
int(request.GET.get("bundle")) if request.GET.get("bundle") else None
|
||||
)
|
||||
self.selected_bundle = next(
|
||||
(bundle for bundle in self.bundles if bundle.id == selected_bundle_id),
|
||||
None,
|
||||
)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
from bookmarks.models import BookmarkSearch
|
||||
from bookmarks.views import contexts, turbo
|
||||
|
||||
|
||||
@@ -14,8 +15,11 @@ def render_bookmark_update(request, bookmark_list, tag_cloud, details):
|
||||
|
||||
|
||||
def active_bookmark_update(request):
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request, search)
|
||||
details = contexts.get_details_context(
|
||||
request, contexts.ActiveBookmarkDetailsContext
|
||||
)
|
||||
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
|
||||
|
||||
|
||||
def archived_bookmark_update(request):
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
|
||||
details = contexts.get_details_context(
|
||||
request, contexts.ArchivedBookmarkDetailsContext
|
||||
)
|
||||
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
|
||||
|
||||
|
||||
def shared_bookmark_update(request):
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||
search = BookmarkSearch.from_request(
|
||||
request, request.GET, request.user_profile.search_preferences
|
||||
)
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request, search)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request, search)
|
||||
details = contexts.get_details_context(
|
||||
request, contexts.SharedBookmarkDetailsContext
|
||||
)
|
||||
|
Reference in New Issue
Block a user