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:
Sascha Ißbrücker
2025-06-19 16:47:29 +02:00
committed by GitHub
parent 8be72a5d1f
commit 1672dc0152
59 changed files with 2290 additions and 267 deletions

View File

@@ -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":

View File

@@ -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();
}

View File

@@ -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,
),
),
],
),
]

View File

@@ -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:
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",
]

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;

View 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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -28,3 +28,4 @@
@import "markdown.css";
@import "reader-mode.css";
@import "settings.css";
@import "bundles.css";

View File

@@ -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;

View File

@@ -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 %}

View File

@@ -77,8 +77,11 @@
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
{% if not bookmark_list.is_preview %}
<span>|</span>
{% endif %}
{% endif %}
{% 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"
@@ -144,6 +147,7 @@
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>
{% if bookmark_list.show_preview_images %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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 %}

View 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>

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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>

View File

@@ -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" }}

View 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 %}

View File

@@ -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()}"

View File

@@ -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())

View File

@@ -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()

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
{

View File

@@ -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,
{

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View 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)

View 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())

View 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)

View 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)

View File

@@ -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")

View File

@@ -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])

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
{

View 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()

View File

@@ -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")

View File

@@ -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"),

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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

View File

@@ -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,
)

View File

@@ -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
)