mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-16 05:59:44 +02:00
Add basic tag management (#1175)
This commit is contained in:
@@ -1,11 +1,18 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms.utils import ErrorList
|
from django.forms.utils import ErrorList
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, build_tag_string
|
from bookmarks.models import (
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
Bookmark,
|
||||||
from bookmarks.type_defs import HttpRequest
|
Tag,
|
||||||
|
build_tag_string,
|
||||||
|
parse_tag_string,
|
||||||
|
sanitize_tag_name,
|
||||||
|
)
|
||||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||||
|
from bookmarks.type_defs import HttpRequest
|
||||||
from bookmarks.utils import normalize_url
|
from bookmarks.utils import normalize_url
|
||||||
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
|
|
||||||
|
|
||||||
class CustomErrorList(ErrorList):
|
class CustomErrorList(ErrorList):
|
||||||
@@ -105,3 +112,88 @@ def convert_tag_string(tag_string: str):
|
|||||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||||
# strings
|
# strings
|
||||||
return tag_string.replace(" ", ",")
|
return tag_string.replace(" ", ",")
|
||||||
|
|
||||||
|
|
||||||
|
class TagForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Tag
|
||||||
|
fields = ["name"]
|
||||||
|
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs, error_class=CustomErrorList)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
name = self.cleaned_data.get("name", "").strip()
|
||||||
|
|
||||||
|
name = sanitize_tag_name(name)
|
||||||
|
|
||||||
|
queryset = Tag.objects.filter(name__iexact=name, owner=self.user)
|
||||||
|
if self.instance.pk:
|
||||||
|
queryset = queryset.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
|
if queryset.exists():
|
||||||
|
raise forms.ValidationError(f'Tag "{name}" already exists.')
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
tag = super().save(commit=False)
|
||||||
|
if not self.instance.pk:
|
||||||
|
tag.owner = self.user
|
||||||
|
tag.date_added = timezone.now()
|
||||||
|
else:
|
||||||
|
tag.date_modified = timezone.now()
|
||||||
|
if commit:
|
||||||
|
tag.save()
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
class TagMergeForm(forms.Form):
|
||||||
|
target_tag = forms.CharField()
|
||||||
|
merge_tags = forms.CharField()
|
||||||
|
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs, error_class=CustomErrorList)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def clean_target_tag(self):
|
||||||
|
target_tag_name = self.cleaned_data.get("target_tag", "")
|
||||||
|
|
||||||
|
target_tag_names = parse_tag_string(target_tag_name, " ")
|
||||||
|
if len(target_tag_names) != 1:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"Please enter only one tag name for the target tag."
|
||||||
|
)
|
||||||
|
|
||||||
|
target_tag_name = target_tag_names[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.')
|
||||||
|
|
||||||
|
return target_tag
|
||||||
|
|
||||||
|
def clean_merge_tags(self):
|
||||||
|
merge_tags_string = self.cleaned_data.get("merge_tags", "")
|
||||||
|
|
||||||
|
merge_tag_names = parse_tag_string(merge_tags_string, " ")
|
||||||
|
if not merge_tag_names:
|
||||||
|
raise forms.ValidationError("Please enter at least one tag to merge.")
|
||||||
|
|
||||||
|
merge_tags = []
|
||||||
|
for tag_name in merge_tag_names:
|
||||||
|
try:
|
||||||
|
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
|
||||||
|
merge_tags.append(tag)
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
raise forms.ValidationError(f'Tag "{tag_name}" does not exist.')
|
||||||
|
|
||||||
|
target_tag = self.cleaned_data.get("target_tag")
|
||||||
|
if target_tag and target_tag in merge_tags:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"The target tag cannot be selected for merging."
|
||||||
|
)
|
||||||
|
|
||||||
|
return merge_tags
|
||||||
|
@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
|
|||||||
<div class="modal-container" role="dialog" aria-modal="true">
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Filters</h2>
|
<h2>Filters</h2>
|
||||||
<button class="close" aria-label="Close dialog">
|
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
@@ -39,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||||
|
// Useful for filter forms where navigating back would otherwise still show
|
||||||
|
// values from after the form submission, which means the filters would be out
|
||||||
|
// of sync with the URL.
|
||||||
|
class FormResetBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.controls = this.element.querySelectorAll("input, select");
|
||||||
|
this.controls.forEach((control) => {
|
||||||
|
if (control.type === "checkbox" || control.type === "radio") {
|
||||||
|
control.__initialValue = control.checked;
|
||||||
|
} else {
|
||||||
|
control.__initialValue = control.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.controls.forEach((control) => {
|
||||||
|
if (control.type === "checkbox" || control.type === "radio") {
|
||||||
|
control.checked = control.__initialValue;
|
||||||
|
} else {
|
||||||
|
control.value = control.__initialValue;
|
||||||
|
}
|
||||||
|
delete control.__initialValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UploadButton extends Behavior {
|
class UploadButton extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
@@ -75,4 +105,5 @@ class UploadButton extends Behavior {
|
|||||||
|
|
||||||
registerBehavior("ld-form-submit", FormSubmit);
|
registerBehavior("ld-form-submit", FormSubmit);
|
||||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||||
|
registerBehavior("ld-form-reset", FormResetBehavior);
|
||||||
registerBehavior("ld-upload-button", UploadButton);
|
registerBehavior("ld-upload-button", UploadButton);
|
||||||
|
@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
|
|||||||
.bookmark-pagination {
|
.bookmark-pagination {
|
||||||
margin-top: var(--unit-4);
|
margin-top: var(--unit-4);
|
||||||
|
|
||||||
/* Remove left padding from first pagination link */
|
|
||||||
|
|
||||||
& .page-item:first-child a {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sticky {
|
&.sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
@@ -1,20 +1,15 @@
|
|||||||
.bundles-page {
|
.bundles-page {
|
||||||
h1 {
|
.crud-table {
|
||||||
font-size: var(--font-size-lg);
|
svg {
|
||||||
margin-bottom: var(--unit-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-list {
|
|
||||||
.list-item .list-item-icon {
|
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item.drag-start {
|
tr.drag-start {
|
||||||
--secondary-border-color: transparent;
|
--secondary-border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item.dragging > * {
|
tr.dragging > * {
|
||||||
visibility: hidden;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
65
bookmarks/styles/crud.css
Normal file
65
bookmarks/styles/crud.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
.crud-page {
|
||||||
|
.crud-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--unit-6);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crud-filters {
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: solid 1px var(--secondary-border-color);
|
||||||
|
padding: var(--unit-3);
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
|
||||||
|
& .form-group {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.form-input,
|
||||||
|
&.form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-group:has(.form-checkbox) {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crud-table {
|
||||||
|
.btn.btn-link {
|
||||||
|
padding: 0;
|
||||||
|
height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
max-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.actions,
|
||||||
|
td.actions {
|
||||||
|
width: 1%;
|
||||||
|
max-width: 150px;
|
||||||
|
|
||||||
|
*:not(:last-child) {
|
||||||
|
margin-right: var(--unit-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
bookmarks/styles/tags.css
Normal file
6
bookmarks/styles/tags.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.tags-editor-page {
|
||||||
|
main {
|
||||||
|
max-width: 550px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
@@ -22,6 +22,7 @@
|
|||||||
@import "responsive.css";
|
@import "responsive.css";
|
||||||
@import "layout.css";
|
@import "layout.css";
|
||||||
@import "components.css";
|
@import "components.css";
|
||||||
|
@import "crud.css";
|
||||||
@import "bookmark-details.css";
|
@import "bookmark-details.css";
|
||||||
@import "bookmark-form.css";
|
@import "bookmark-form.css";
|
||||||
@import "bookmark-page.css";
|
@import "bookmark-page.css";
|
||||||
@@ -29,3 +30,4 @@
|
|||||||
@import "reader-mode.css";
|
@import "reader-mode.css";
|
||||||
@import "settings.css";
|
@import "settings.css";
|
||||||
@import "bundles.css";
|
@import "bundles.css";
|
||||||
|
@import "tags.css";
|
||||||
|
@@ -119,6 +119,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Button no border */
|
||||||
|
&.btn-noborder {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Button Link */
|
/* Button Link */
|
||||||
|
|
||||||
&.btn-link {
|
&.btn-link {
|
||||||
|
@@ -430,13 +430,21 @@ textarea.form-input {
|
|||||||
/* Form element: Input groups */
|
/* Form element: Input groups */
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--input-box-shadow);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.input-group-addon {
|
.input-group-addon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
background: var(--body-color);
|
background: var(--body-color);
|
||||||
border: var(--border-width) solid var(--input-border-color);
|
border: var(--border-width) solid var(--input-border-color);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
line-height: var(--line-height);
|
line-height: var(--line-height);
|
||||||
padding: var(--control-padding-y) var(--control-padding-x);
|
padding: 0 var(--control-padding-x);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.addon-sm {
|
&.addon-sm {
|
||||||
|
@@ -80,17 +80,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
& .close {
|
& .close {
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 0;
|
height: auto;
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.85;
|
|
||||||
color: var(--secondary-text-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -33,6 +33,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:first-child a {
|
||||||
|
/* Remove left padding from first pagination link */
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
& a {
|
& a {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
|
@@ -5,22 +5,19 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
/* Scrollable tables */
|
td,
|
||||||
|
th {
|
||||||
&.table-scroll {
|
border-bottom: var(--border-width) solid var(--secondary-border-color);
|
||||||
display: block;
|
padding: var(--unit-2) var(--unit-2);
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& td,
|
th {
|
||||||
& th {
|
font-weight: 500;
|
||||||
border-bottom: var(--border-width) solid var(--border-color);
|
border-bottom-color: var(--border-color);
|
||||||
padding: var(--unit-3) var(--unit-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& th {
|
th:first-child,
|
||||||
border-bottom-width: var(--border-width-lg);
|
td:first-child {
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -242,6 +242,36 @@
|
|||||||
margin-top: var(--unit-4) !important;
|
margin-top: var(--unit-4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-6 {
|
||||||
|
margin: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-6 {
|
||||||
|
margin-left: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-6 {
|
||||||
|
margin-right: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-6 {
|
||||||
|
margin-top: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-6 {
|
||||||
|
margin-left: var(--unit-6) !important;
|
||||||
|
margin-right: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-6 {
|
||||||
|
margin-bottom: var(--unit-6) !important;
|
||||||
|
margin-top: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-auto {
|
.ml-auto {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -291,6 +321,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Flex */
|
/* Flex */
|
||||||
|
.flex-column {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.align-baseline {
|
.align-baseline {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
@@ -302,3 +336,7 @@
|
|||||||
.justify-between {
|
.justify-between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
<div class="section-header no-wrap">
|
<div class="section-header no-wrap">
|
||||||
<h2 id="bundles-heading">Bundles</h2>
|
<h2 id="bundles-heading">Bundles</h2>
|
||||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||||
<button class="btn dropdown-toggle" aria-label="Bundles menu">
|
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
<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">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
@@ -4,7 +4,7 @@
|
|||||||
<div class="modal-container" role="dialog" aria-modal="true">
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
||||||
<button class="close" aria-label="Close dialog">
|
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
@@ -1,6 +1,22 @@
|
|||||||
<section aria-labelledby="tags-heading">
|
<section aria-labelledby="tags-heading">
|
||||||
<div class="section-header">
|
<div class="section-header no-wrap">
|
||||||
<h2 id="tags-heading">Tags</h2>
|
<h2 id="tags-heading">Tags</h2>
|
||||||
|
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||||
|
<button class="btn btn-noborder dropdown-toggle" aria-label="Tabs menu">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path d="M4 6l16 0"/>
|
||||||
|
<path d="M4 12l16 0"/>
|
||||||
|
<path d="M4 18l16 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tag-cloud-container">
|
<div id="tag-cloud-container">
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
|
@@ -7,41 +7,55 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="bundles-page" aria-labelledby="main-heading">
|
<main class="bundles-page crud-page" aria-labelledby="main-heading">
|
||||||
<h1 id="main-heading">Bundles</h1>
|
<div class="crud-header">
|
||||||
|
<h1 id="main-heading">Bundles</h1>
|
||||||
|
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% include 'shared/messages.html' %}
|
{% include 'shared/messages.html' %}
|
||||||
|
|
||||||
{% if bundles %}
|
{% if bundles %}
|
||||||
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="item-list bundles">
|
<table class="table crud-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="actions">
|
||||||
|
<span class="text-assistive">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{% for bundle in bundles %}
|
{% for bundle in bundles %}
|
||||||
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
|
<tr data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||||
<div class="list-item-icon text-secondary">
|
<td>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
<div class="d-flex align-center">
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
<path d="M9 5m-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="M9 12m-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="M9 19m-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 5m-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"/>
|
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
</svg>
|
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
</div>
|
</svg>
|
||||||
<div class="list-item-text">
|
<span>{{ bundle.name }}</span>
|
||||||
<span class="truncate">{{ bundle.name }}</span>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
<div class="list-item-actions">
|
<td class="actions">
|
||||||
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
<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 }}"
|
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||||
class="btn btn-link">Remove
|
class="btn btn-link">Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<input type="submit" name="move_bundle" value="" class="d-none">
|
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||||
<input type="hidden" name="move_position" value="">
|
<input type="hidden" name="move_position" value="">
|
||||||
</form>
|
</form>
|
||||||
@@ -51,21 +65,17 @@
|
|||||||
<p class="empty-subtitle">Create your first bundle to get started</p>
|
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function init() {
|
(function init() {
|
||||||
const bundlesList = document.querySelector(".item-list.bundles");
|
const tableBody = document.querySelector(".crud-table tbody");
|
||||||
if (!bundlesList) return;
|
if (!tableBody) return;
|
||||||
|
|
||||||
let draggedElement = null;
|
let draggedElement = null;
|
||||||
|
|
||||||
const listItems = bundlesList.querySelectorAll('.list-item');
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
listItems.forEach((item) => {
|
rows.forEach((item) => {
|
||||||
item.addEventListener('dragstart', handleDragStart);
|
item.addEventListener('dragstart', handleDragStart);
|
||||||
item.addEventListener('dragend', handleDragEnd);
|
item.addEventListener('dragend', handleDragEnd);
|
||||||
item.addEventListener('dragover', handleDragOver);
|
item.addEventListener('dragover', handleDragOver);
|
||||||
@@ -91,7 +101,7 @@
|
|||||||
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
||||||
const movePositionInput = document.querySelector('input[name="move_position"]');
|
const movePositionInput = document.querySelector('input[name="move_position"]');
|
||||||
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
||||||
movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
|
movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
|
||||||
|
|
||||||
const form = this.closest('form');
|
const form = this.closest('form');
|
||||||
form.requestSubmit(moveBundleInput);
|
form.requestSubmit(moveBundleInput);
|
||||||
@@ -108,7 +118,7 @@
|
|||||||
|
|
||||||
function handleDragEnter() {
|
function handleDragEnter() {
|
||||||
if (this !== draggedElement) {
|
if (this !== draggedElement) {
|
||||||
const listItems = Array.from(bundlesList.children);
|
const listItems = Array.from(tableBody.children);
|
||||||
const draggedIndex = listItems.indexOf(draggedElement);
|
const draggedIndex = listItems.indexOf(draggedElement);
|
||||||
const currentIndex = listItems.indexOf(this);
|
const currentIndex = listItems.indexOf(this);
|
||||||
|
|
||||||
|
@@ -394,17 +394,17 @@ reddit.com/r/Music music reddit</pre>
|
|||||||
<td>{{ version_info }}</td>
|
<td>{{ version_info }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="3" style="vertical-align: top">Links</td>
|
<td style="vertical-align: top">Links</td>
|
||||||
<td><a href="https://github.com/sissbruecker/linkding/"
|
<td>
|
||||||
target="_blank">GitHub</a></td>
|
<div class="d-flex flex-column gap-2">
|
||||||
</tr>
|
<a href="https://github.com/sissbruecker/linkding/"
|
||||||
<tr>
|
target="_blank">GitHub</a>
|
||||||
<td><a href="https://linkding.link/"
|
<a href="https://linkding.link/"
|
||||||
target="_blank">Documentation</a></td>
|
target="_blank">Documentation</a>
|
||||||
</tr>
|
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||||
<tr>
|
target="_blank">Changelog</a>
|
||||||
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
</div>
|
||||||
target="_blank">Changelog</a></td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
23
bookmarks/templates/tags/edit.html
Normal file
23
bookmarks/templates/tags/edit.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Edit tag - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-editor-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">Edit tag</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'tags/form.html' %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
19
bookmarks/templates/tags/form.html
Normal file
19
bookmarks/templates/tags/form.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||||
|
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
|
||||||
|
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||||
|
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.name.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="form-group d-flex justify-between">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||||
|
</div>
|
125
bookmarks/templates/tags/index.html
Normal file
125
bookmarks/templates/tags/index.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
{% load pagination %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Tags - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-page crud-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="crud-header">
|
||||||
|
<h1 id="main-heading">Tags</h1>
|
||||||
|
<div class="d-flex gap-2 ml-auto">
|
||||||
|
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
|
||||||
|
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shared/messages.html' %}
|
||||||
|
|
||||||
|
{# Filters #}
|
||||||
|
<div class="crud-filters">
|
||||||
|
<form method="get" class="mb-2" ld-form-reset>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label text-assistive" for="search">Search tags</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
|
||||||
|
class="form-input">
|
||||||
|
<button type="submit" class="btn input-group-btn">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label text-assistive" for="sort">Sort by</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-addon text-secondary">
|
||||||
|
<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="M3 9l4 -4l4 4m-4 -4v14"/><path
|
||||||
|
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
|
||||||
|
</span>
|
||||||
|
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||||
|
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
|
||||||
|
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
|
||||||
|
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
|
||||||
|
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
|
||||||
|
<i class="form-icon"></i> Show only unused tags
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Tags count #}
|
||||||
|
<p class="text-secondary text-small m-0">
|
||||||
|
{% if search or unused_only %}
|
||||||
|
Showing {{ page.paginator.count }} of {{ total_tags }} tags
|
||||||
|
{% else %}
|
||||||
|
{{ total_tags }} tags total
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tags List #}
|
||||||
|
{% if page.object_list %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<table class="table crud-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style="width: 25%">Bookmarks</th>
|
||||||
|
<th class="actions">
|
||||||
|
<span class="text-assistive">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag in page.object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ tag.name }}
|
||||||
|
</td>
|
||||||
|
<td style="width: 25%">
|
||||||
|
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
|
||||||
|
{{ tag.bookmark_count }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
|
||||||
|
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
|
||||||
|
ld-confirm-button>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% pagination page %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
{% if search or unused_only %}
|
||||||
|
<p class="empty-title h5">No tags found</p>
|
||||||
|
<p class="empty-subtitle">Try adjusting your search or filters</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-title h5">You have no tags yet</p>
|
||||||
|
<p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
68
bookmarks/templates/tags/merge.html
Normal file
68
bookmarks/templates/tags/merge.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Merge tags - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-editor-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">Merge tags</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mb-4">
|
||||||
|
<summary>
|
||||||
|
<span class="text-bold mb-1">How to merge tags</span>
|
||||||
|
</summary>
|
||||||
|
<ol>
|
||||||
|
<li>Enter the name of the tag you want to keep</li>
|
||||||
|
<li>Enter the names of tags to merge into the target tag</li>
|
||||||
|
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
|
||||||
|
<li>The merged tags are deleted</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
|
||||||
|
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
|
||||||
|
</div>
|
||||||
|
{% if form.target_tag.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.target_tag.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
|
||||||
|
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||||
|
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
|
||||||
|
tags will be deleted after merging.
|
||||||
|
</div>
|
||||||
|
{% if form.merge_tags.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.merge_tags.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="form-group d-flex justify-between">
|
||||||
|
<button type="submit" class="btn btn-primary">Merge Tags</button>
|
||||||
|
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
23
bookmarks/templates/tags/new.html
Normal file
23
bookmarks/templates/tags/new.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Add tag - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-editor-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">New tag</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'tags/form.html' %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@@ -25,27 +25,27 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
for bundle in bundles:
|
for bundle in bundles:
|
||||||
expected_list_item = f"""
|
expected_list_item = f"""
|
||||||
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
|
<tr data-bundle-id="{bundle.id}" draggable="true">
|
||||||
<div class="list-item-icon text-secondary">
|
<td>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
<div class="d-flex align-center">
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
<path d="M9 5m-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="M9 12m-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="M9 19m-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 5m-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"/>
|
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
</svg>
|
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
</div>
|
</svg>
|
||||||
<div class="list-item-text">
|
<span>{ bundle.name }</span>
|
||||||
<span class="truncate">{bundle.name}</span>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
<div class="list-item-actions">
|
<td class="actions">
|
||||||
<a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
|
<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>
|
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.assertInHTML(expected_list_item, html)
|
self.assertInHTML(expected_list_item, html)
|
||||||
@@ -61,7 +61,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
|
self.assertInHTML(f"<span>{user_bundle.name}</span>", html)
|
||||||
self.assertNotIn(other_user_bundle.name, html)
|
self.assertNotIn(other_user_bundle.name, html)
|
||||||
|
|
||||||
def test_empty_state(self):
|
def test_empty_state(self):
|
||||||
@@ -83,7 +83,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
|
f'<a href="{reverse("linkding:bundles.new")}" class="btn">Add bundle</a>',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
113
bookmarks/tests/test_tags_edit_view.py
Normal file
113
bookmarks/tests/test_tags_edit_view.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_update_tag(self):
|
||||||
|
tag = self.setup_tag(name="old_name")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
tag.refresh_from_db()
|
||||||
|
self.assertEqual(tag.name, "new_name")
|
||||||
|
|
||||||
|
def test_allow_case_changes(self):
|
||||||
|
tag = self.setup_tag(name="tag")
|
||||||
|
|
||||||
|
self.client.post(reverse("linkding:tags.edit", args=[tag.id]), {"name": "TAG"})
|
||||||
|
|
||||||
|
tag.refresh_from_db()
|
||||||
|
self.assertEqual(tag.name, "TAG")
|
||||||
|
|
||||||
|
def test_can_only_edit_own_tags(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
tag = self.setup_tag(user=other_user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
tag.refresh_from_db()
|
||||||
|
self.assertNotEqual(tag.name, "new_name")
|
||||||
|
|
||||||
|
def test_show_error_for_empty_name(self):
|
||||||
|
tag = self.setup_tag(name="tag1")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag.id]), {"name": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, "This field is required", status_code=422)
|
||||||
|
tag.refresh_from_db()
|
||||||
|
self.assertEqual(tag.name, "tag1")
|
||||||
|
|
||||||
|
def test_show_error_for_duplicate_name(self):
|
||||||
|
tag1 = self.setup_tag(name="tag1")
|
||||||
|
self.setup_tag(name="tag2")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(
|
||||||
|
response, "Tag "tag2" already exists", status_code=422
|
||||||
|
)
|
||||||
|
tag1.refresh_from_db()
|
||||||
|
self.assertEqual(tag1.name, "tag1")
|
||||||
|
|
||||||
|
def test_show_error_for_duplicate_name_different_casing(self):
|
||||||
|
tag1 = self.setup_tag(name="tag1")
|
||||||
|
self.setup_tag(name="tag2")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(
|
||||||
|
response, "Tag "TAG2" already exists", status_code=422
|
||||||
|
)
|
||||||
|
tag1.refresh_from_db()
|
||||||
|
self.assertEqual(tag1.name, "tag1")
|
||||||
|
|
||||||
|
def test_no_error_for_duplicate_name_different_user(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
self.setup_tag(name="tag1", user=other_user)
|
||||||
|
|
||||||
|
tag2 = self.setup_tag(name="tag2")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag2.id]), {"name": "tag1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
tag2.refresh_from_db()
|
||||||
|
self.assertEqual(tag2.name, "tag1")
|
||||||
|
|
||||||
|
def test_update_shows_success_message(self):
|
||||||
|
tag = self.setup_tag(name="old_name")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.edit", args=[tag.id]),
|
||||||
|
{"name": "new_name"},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<div class="toast toast-success" role="alert">
|
||||||
|
Tag "new_name" updated successfully.
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
response.content.decode(),
|
||||||
|
)
|
281
bookmarks/tests/test_tags_index_view.py
Normal file
281
bookmarks/tests/test_tags_index_view.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import Tag
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def get_rows(self, response):
|
||||||
|
html = response.content.decode()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
return soup.select(".crud-table tbody tr")
|
||||||
|
|
||||||
|
def find_row(self, rows, tag):
|
||||||
|
for row in rows:
|
||||||
|
if tag.name in row.get_text():
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
def assertRows(self, response, tags):
|
||||||
|
rows = self.get_rows(response)
|
||||||
|
self.assertEqual(len(rows), len(tags))
|
||||||
|
for tag in tags:
|
||||||
|
row = self.find_row(rows, tag)
|
||||||
|
self.assertIsNotNone(row, f"Tag '{tag.name}' not found in table")
|
||||||
|
|
||||||
|
def assertOrderedRows(self, response, tags):
|
||||||
|
rows = self.get_rows(response)
|
||||||
|
self.assertEqual(len(rows), len(tags))
|
||||||
|
for index, tag in enumerate(tags):
|
||||||
|
row = rows[index]
|
||||||
|
self.assertIn(
|
||||||
|
tag.name,
|
||||||
|
row.get_text(),
|
||||||
|
f"Tag '{tag.name}' not found at index {index}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_tags(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
tag3 = self.setup_tag()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertRows(response, [tag1, tag2, tag3])
|
||||||
|
|
||||||
|
def test_show_user_owned_tags(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
tag3 = self.setup_tag()
|
||||||
|
|
||||||
|
other_user = self.setup_user()
|
||||||
|
self.setup_tag(user=other_user)
|
||||||
|
self.setup_tag(user=other_user)
|
||||||
|
self.setup_tag(user=other_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
self.assertRows(response, [tag1, tag2, tag3])
|
||||||
|
|
||||||
|
def test_search_tags(self):
|
||||||
|
tag1 = self.setup_tag(name="programming")
|
||||||
|
self.setup_tag(name="python")
|
||||||
|
self.setup_tag(name="django")
|
||||||
|
self.setup_tag(name="design")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?search=prog")
|
||||||
|
|
||||||
|
self.assertRows(response, [tag1])
|
||||||
|
|
||||||
|
def test_filter_unused_tags(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
tag3 = self.setup_tag()
|
||||||
|
|
||||||
|
self.setup_bookmark(tags=[tag1])
|
||||||
|
self.setup_bookmark(tags=[tag3])
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
|
||||||
|
|
||||||
|
self.assertRows(response, [tag2])
|
||||||
|
|
||||||
|
def test_rows_have_links_to_filtered_bookmarks(self):
|
||||||
|
tag1 = self.setup_tag(name="python")
|
||||||
|
tag2 = self.setup_tag(name="django-framework")
|
||||||
|
|
||||||
|
self.setup_bookmark(tags=[tag1])
|
||||||
|
self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
rows = self.get_rows(response)
|
||||||
|
|
||||||
|
tag1_row = self.find_row(rows, tag1)
|
||||||
|
view_link = tag1_row.find("a", string=lambda s: s and s.strip() == "2")
|
||||||
|
expected_url = reverse("linkding:bookmarks.index") + "?q=%23python"
|
||||||
|
self.assertEqual(view_link["href"], expected_url)
|
||||||
|
|
||||||
|
tag2_row = self.find_row(rows, tag2)
|
||||||
|
view_link = tag2_row.find("a", string=lambda s: s and s.strip() == "1")
|
||||||
|
expected_url = reverse("linkding:bookmarks.index") + "?q=%23django-framework"
|
||||||
|
self.assertEqual(view_link["href"], expected_url)
|
||||||
|
|
||||||
|
def test_shows_tag_total(self):
|
||||||
|
tag1 = self.setup_tag(name="python")
|
||||||
|
tag2 = self.setup_tag(name="javascript")
|
||||||
|
tag3 = self.setup_tag(name="design")
|
||||||
|
self.setup_tag(name="unused-tag")
|
||||||
|
|
||||||
|
self.setup_bookmark(tags=[tag1])
|
||||||
|
self.setup_bookmark(tags=[tag2])
|
||||||
|
self.setup_bookmark(tags=[tag3])
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
self.assertContains(response, "4 tags total")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?search=python")
|
||||||
|
self.assertContains(response, "Showing 1 of 4 tags")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
|
||||||
|
self.assertContains(response, "Showing 1 of 4 tags")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("linkding:tags.index") + "?search=nonexistent"
|
||||||
|
)
|
||||||
|
self.assertContains(response, "Showing 0 of 4 tags")
|
||||||
|
|
||||||
|
def test_pagination(self):
|
||||||
|
tags = []
|
||||||
|
for i in range(75):
|
||||||
|
tags.append(self.setup_tag())
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
rows = self.get_rows(response)
|
||||||
|
self.assertEqual(len(rows), 50)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?page=2")
|
||||||
|
rows = self.get_rows(response)
|
||||||
|
self.assertEqual(len(rows), 25)
|
||||||
|
|
||||||
|
def test_delete_action(self):
|
||||||
|
tag = self.setup_tag(name="tag_to_delete")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.index"), {"delete_tag": tag.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
|
||||||
|
|
||||||
|
def test_tag_delete_action_shows_success_message(self):
|
||||||
|
tag = self.setup_tag(name="tag_to_delete")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<div class="toast toast-success" role="alert">
|
||||||
|
Tag "tag_to_delete" deleted successfully.
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
response.content.decode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tag_delete_action_preserves_query_parameters(self):
|
||||||
|
tag = self.setup_tag(name="search_tag")
|
||||||
|
|
||||||
|
url = (
|
||||||
|
reverse("linkding:tags.index")
|
||||||
|
+ "?search=search&unused=true&page=2&sort=name-desc"
|
||||||
|
)
|
||||||
|
response = self.client.post(url, {"delete_tag": tag.id})
|
||||||
|
|
||||||
|
self.assertRedirects(response, url)
|
||||||
|
|
||||||
|
def test_tag_delete_action_only_deletes_own_tags(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
other_tag = self.setup_tag(user=other_user, name="other_user_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.index"), {"delete_tag": other_tag.id}, follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_sort_by_name_ascending(self):
|
||||||
|
tag_c = self.setup_tag(name="c_tag")
|
||||||
|
tag_a = self.setup_tag(name="a_tag")
|
||||||
|
tag_b = self.setup_tag(name="b_tag")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-asc")
|
||||||
|
|
||||||
|
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
|
||||||
|
|
||||||
|
def test_sort_by_name_descending(self):
|
||||||
|
tag_c = self.setup_tag(name="c_tag")
|
||||||
|
tag_a = self.setup_tag(name="a_tag")
|
||||||
|
tag_b = self.setup_tag(name="b_tag")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
|
||||||
|
|
||||||
|
self.assertOrderedRows(response, [tag_c, tag_b, tag_a])
|
||||||
|
|
||||||
|
def test_sort_by_bookmark_count_ascending(self):
|
||||||
|
tag_few = self.setup_tag(name="few_bookmarks")
|
||||||
|
tag_many = self.setup_tag(name="many_bookmarks")
|
||||||
|
tag_none = self.setup_tag(name="no_bookmarks")
|
||||||
|
|
||||||
|
self.setup_bookmark(tags=[tag_few])
|
||||||
|
self.setup_bookmark(tags=[tag_many])
|
||||||
|
self.setup_bookmark(tags=[tag_many])
|
||||||
|
self.setup_bookmark(tags=[tag_many])
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-asc")
|
||||||
|
|
||||||
|
self.assertOrderedRows(response, [tag_none, tag_few, tag_many])
|
||||||
|
|
||||||
|
def test_sort_by_bookmark_count_descending(self):
|
||||||
|
tag_few = self.setup_tag(name="few_bookmarks")
|
||||||
|
tag_many = self.setup_tag(name="many_bookmarks")
|
||||||
|
tag_none = self.setup_tag(name="no_bookmarks")
|
||||||
|
|
||||||
|
self.setup_bookmark(tags=[tag_few])
|
||||||
|
self.setup_bookmark(tags=[tag_many])
|
||||||
|
self.setup_bookmark(tags=[tag_many])
|
||||||
|
self.setup_bookmark(tags=[tag_many])
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-desc")
|
||||||
|
|
||||||
|
self.assertOrderedRows(response, [tag_many, tag_few, tag_none])
|
||||||
|
|
||||||
|
def test_default_sort_is_name_ascending(self):
|
||||||
|
tag_c = self.setup_tag(name="c_tag")
|
||||||
|
tag_a = self.setup_tag(name="a_tag")
|
||||||
|
tag_b = self.setup_tag(name="b_tag")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
|
||||||
|
|
||||||
|
def test_sort_select_has_correct_options_and_selection(self):
|
||||||
|
self.setup_tag()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||||
|
<option value="name-asc" selected>Name A-Z</option>
|
||||||
|
<option value="name-desc">Name Z-A</option>
|
||||||
|
<option value="count-asc">Fewest bookmarks</option>
|
||||||
|
<option value="count-desc">Most bookmarks</option>
|
||||||
|
</select>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||||
|
<option value="name-asc">Name A-Z</option>
|
||||||
|
<option value="name-desc" selected>Name Z-A</option>
|
||||||
|
<option value="count-asc">Fewest bookmarks</option>
|
||||||
|
<option value="count-desc">Most bookmarks</option>
|
||||||
|
</select>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
219
bookmarks/tests/test_tags_merge_view.py
Normal file
219
bookmarks/tests/test_tags_merge_view.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def get_form_group(self, response, input_name):
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
input_element = soup.find("input", {"name": input_name})
|
||||||
|
if input_element:
|
||||||
|
return input_element.find_parent("div", class_="form-group")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_merge_tags(self):
|
||||||
|
target_tag = self.setup_tag(name="target_tag")
|
||||||
|
merge_tag1 = self.setup_tag(name="merge_tag1")
|
||||||
|
merge_tag2 = self.setup_tag(name="merge_tag2")
|
||||||
|
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
|
||||||
|
bookmark3 = self.setup_bookmark(tags=[target_tag])
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
self.assertEqual(Tag.objects.count(), 1)
|
||||||
|
self.assertFalse(Tag.objects.filter(id=merge_tag1.id).exists())
|
||||||
|
self.assertFalse(Tag.objects.filter(id=merge_tag2.id).exists())
|
||||||
|
|
||||||
|
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
|
||||||
|
|
||||||
|
def test_merge_tags_complex(self):
|
||||||
|
target_tag = self.setup_tag(name="target_tag")
|
||||||
|
merge_tag1 = self.setup_tag(name="merge_tag1")
|
||||||
|
merge_tag2 = self.setup_tag(name="merge_tag2")
|
||||||
|
other_tag = self.setup_tag(name="other_tag")
|
||||||
|
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
|
||||||
|
bookmark3 = self.setup_bookmark(tags=[target_tag])
|
||||||
|
bookmark4 = self.setup_bookmark(
|
||||||
|
tags=[merge_tag1, merge_tag2]
|
||||||
|
) # both merge tags
|
||||||
|
bookmark5 = self.setup_bookmark(
|
||||||
|
tags=[merge_tag2, target_tag]
|
||||||
|
) # already has target tag
|
||||||
|
bookmark6 = self.setup_bookmark(
|
||||||
|
tags=[merge_tag1, merge_tag2, target_tag]
|
||||||
|
) # both merge tags and target
|
||||||
|
bookmark7 = self.setup_bookmark(tags=[other_tag]) # unrelated tag
|
||||||
|
bookmark8 = self.setup_bookmark(
|
||||||
|
tags=[other_tag, merge_tag1]
|
||||||
|
) # merge and unrelated tag
|
||||||
|
bookmark9 = self.setup_bookmark(
|
||||||
|
tags=[other_tag, target_tag]
|
||||||
|
) # merge and target tag
|
||||||
|
bookmark10 = self.setup_bookmark(tags=[]) # no tags
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 10)
|
||||||
|
self.assertEqual(Tag.objects.count(), 2)
|
||||||
|
self.assertEqual(Bookmark.tags.through.objects.count(), 11)
|
||||||
|
|
||||||
|
self.assertCountEqual(list(Tag.objects.all()), [target_tag, other_tag])
|
||||||
|
|
||||||
|
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark4.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark5.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark6.tags.all()), [target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark7.tags.all()), [other_tag])
|
||||||
|
self.assertCountEqual(list(bookmark8.tags.all()), [other_tag, target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark9.tags.all()), [other_tag, target_tag])
|
||||||
|
self.assertCountEqual(list(bookmark10.tags.all()), [])
|
||||||
|
|
||||||
|
def test_can_only_merge_own_tags(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
self.setup_tag(name="target_tag", user=other_user)
|
||||||
|
self.setup_tag(name="merge_tag", user=other_user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": "merge_tag"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
|
target_tag_group = self.get_form_group(response, "target_tag")
|
||||||
|
self.assertIn('Tag "target_tag" does not exist', target_tag_group.get_text())
|
||||||
|
|
||||||
|
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||||
|
self.assertIn('Tag "merge_tag" does not exist', merge_tags_group.get_text())
|
||||||
|
|
||||||
|
def test_validate_missing_target_tag(self):
|
||||||
|
merge_tag = self.setup_tag(name="merge_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "", "merge_tags": "merge_tag"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
|
target_tag_group = self.get_form_group(response, "target_tag")
|
||||||
|
self.assertIn("This field is required", target_tag_group.get_text())
|
||||||
|
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
|
||||||
|
|
||||||
|
def test_validate_missing_merge_tags(self):
|
||||||
|
self.setup_tag(name="target_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||||
|
self.assertIn("This field is required", merge_tags_group.get_text())
|
||||||
|
|
||||||
|
def test_validate_nonexistent_target_tag(self):
|
||||||
|
merge_tag = self.setup_tag(name="merge_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
|
target_tag_group = self.get_form_group(response, "target_tag")
|
||||||
|
self.assertIn(
|
||||||
|
'Tag "nonexistent_tag" does not exist', target_tag_group.get_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_nonexistent_merge_tag(self):
|
||||||
|
self.setup_tag(name="target_tag")
|
||||||
|
self.setup_tag(name="merge_tag1")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||||
|
self.assertIn(
|
||||||
|
'Tag "nonexistent_tag" does not exist', merge_tags_group.get_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_multiple_target_tags(self):
|
||||||
|
self.setup_tag(name="target_tag1")
|
||||||
|
self.setup_tag(name="target_tag2")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
|
target_tag_group = self.get_form_group(response, "target_tag")
|
||||||
|
self.assertIn(
|
||||||
|
"Please enter only one tag name for the target tag",
|
||||||
|
target_tag_group.get_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_target_tag_in_merge_list(self):
|
||||||
|
self.setup_tag(name="target_tag")
|
||||||
|
self.setup_tag(name="merge_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
|
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||||
|
self.assertIn(
|
||||||
|
"The target tag cannot be selected for merging", merge_tags_group.get_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merge_shows_success_message(self):
|
||||||
|
self.setup_tag(name="target_tag")
|
||||||
|
self.setup_tag(name="merge_tag1")
|
||||||
|
self.setup_tag(name="merge_tag2")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.merge"),
|
||||||
|
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<div class="toast toast-success" role="alert">
|
||||||
|
Successfully merged 2 tags (merge_tag1, merge_tag2) into "target_tag".
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
response.content.decode(),
|
||||||
|
)
|
79
bookmarks/tests/test_tags_new_view.py
Normal file
79
bookmarks/tests/test_tags_new_view.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import Tag
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_create_tag(self):
|
||||||
|
response = self.client.post(reverse("linkding:tags.new"), {"name": "new_tag"})
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
self.assertEqual(Tag.objects.count(), 1)
|
||||||
|
self.assertTrue(Tag.objects.filter(name="new_tag", owner=self.user).exists())
|
||||||
|
|
||||||
|
def test_show_error_for_empty_name(self):
|
||||||
|
response = self.client.post(reverse("linkding:tags.new"), {"name": ""})
|
||||||
|
|
||||||
|
self.assertContains(response, "This field is required", status_code=422)
|
||||||
|
self.assertEqual(Tag.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_show_error_for_duplicate_name(self):
|
||||||
|
self.setup_tag(name="existing_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.new"), {"name": "existing_tag"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(
|
||||||
|
response, "Tag "existing_tag" already exists", status_code=422
|
||||||
|
)
|
||||||
|
self.assertEqual(Tag.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_show_error_for_duplicate_name_different_casing(self):
|
||||||
|
self.setup_tag(name="existing_tag")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.new"), {"name": "existing_TAG"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(
|
||||||
|
response, "Tag "existing_TAG" already exists", status_code=422
|
||||||
|
)
|
||||||
|
self.assertEqual(Tag.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_no_error_for_duplicate_name_different_user(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
self.setup_tag(name="existing_tag", user=other_user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.new"), {"name": "existing_tag"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
|
self.assertEqual(Tag.objects.count(), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
Tag.objects.filter(name="existing_tag", owner=self.user).count(), 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
Tag.objects.filter(name="existing_tag", owner=other_user).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_shows_success_message(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:tags.new"), {"name": "new_tag"}, follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<div class="toast toast-success" role="alert">
|
||||||
|
Tag "new_tag" created successfully.
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
response.content.decode(),
|
||||||
|
)
|
@@ -49,6 +49,11 @@ urlpatterns = [
|
|||||||
path("bundles/new", views.bundles.new, name="bundles.new"),
|
path("bundles/new", views.bundles.new, name="bundles.new"),
|
||||||
path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
|
path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
|
||||||
path("bundles/preview", views.bundles.preview, name="bundles.preview"),
|
path("bundles/preview", views.bundles.preview, name="bundles.preview"),
|
||||||
|
# Tags
|
||||||
|
path("tags", views.tags.tags_index, name="tags.index"),
|
||||||
|
path("tags/new", views.tags.tag_new, name="tags.new"),
|
||||||
|
path("tags/<int:tag_id>/edit", views.tags.tag_edit, name="tags.edit"),
|
||||||
|
path("tags/merge", views.tags.tag_merge, name="tags.merge"),
|
||||||
# Settings
|
# Settings
|
||||||
path("settings", views.settings.general, name="settings.index"),
|
path("settings", views.settings.general, name="settings.index"),
|
||||||
path("settings/general", views.settings.general, name="settings.general"),
|
path("settings/general", views.settings.general, name="settings.general"),
|
||||||
|
@@ -2,6 +2,7 @@ from .assets import *
|
|||||||
from .auth import *
|
from .auth import *
|
||||||
from .bookmarks import *
|
from .bookmarks import *
|
||||||
from . import bundles
|
from . import bundles
|
||||||
|
from . import tags
|
||||||
from .settings import *
|
from .settings import *
|
||||||
from .toasts import *
|
from .toasts import *
|
||||||
from .health import health
|
from .health import health
|
||||||
|
151
bookmarks/views/tags.py
Normal file
151
bookmarks/views/tags.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.forms import TagForm, TagMergeForm
|
||||||
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
from bookmarks.type_defs import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tags_index(request: HttpRequest):
|
||||||
|
if request.method == "POST" and "delete_tag" in request.POST:
|
||||||
|
tag_id = request.POST.get("delete_tag")
|
||||||
|
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
|
||||||
|
tag_name = tag.name
|
||||||
|
tag.delete()
|
||||||
|
messages.success(request, f'Tag "{tag_name}" deleted successfully.')
|
||||||
|
|
||||||
|
redirect_url = reverse("linkding:tags.index")
|
||||||
|
if request.GET:
|
||||||
|
redirect_url += "?" + request.GET.urlencode()
|
||||||
|
|
||||||
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
||||||
|
search = request.GET.get("search", "").strip()
|
||||||
|
unused_only = request.GET.get("unused", "") == "true"
|
||||||
|
sort = request.GET.get("sort", "name-asc")
|
||||||
|
|
||||||
|
tags_queryset = Tag.objects.filter(owner=request.user).annotate(
|
||||||
|
bookmark_count=Count("bookmark")
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort == "name-desc":
|
||||||
|
tags_queryset = tags_queryset.order_by("-name")
|
||||||
|
elif sort == "count-asc":
|
||||||
|
tags_queryset = tags_queryset.order_by("bookmark_count", "name")
|
||||||
|
elif sort == "count-desc":
|
||||||
|
tags_queryset = tags_queryset.order_by("-bookmark_count", "name")
|
||||||
|
else: # Default: name-asc
|
||||||
|
tags_queryset = tags_queryset.order_by("name")
|
||||||
|
total_tags = tags_queryset.count()
|
||||||
|
|
||||||
|
if search:
|
||||||
|
tags_queryset = tags_queryset.filter(name__icontains=search)
|
||||||
|
|
||||||
|
if unused_only:
|
||||||
|
tags_queryset = tags_queryset.filter(bookmark_count=0)
|
||||||
|
|
||||||
|
paginator = Paginator(tags_queryset, 50)
|
||||||
|
page_number = request.GET.get("page")
|
||||||
|
page = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"page": page,
|
||||||
|
"search": search,
|
||||||
|
"unused_only": unused_only,
|
||||||
|
"sort": sort,
|
||||||
|
"total_tags": total_tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "tags/index.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tag_new(request: HttpRequest):
|
||||||
|
form_data = request.POST if request.method == "POST" else None
|
||||||
|
form = TagForm(user=request.user, data=form_data)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if form.is_valid():
|
||||||
|
tag = form.save()
|
||||||
|
messages.success(request, f'Tag "{tag.name}" created successfully.')
|
||||||
|
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||||
|
return render(request, "tags/new.html", {"form": form}, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tag_edit(request: HttpRequest, tag_id: int):
|
||||||
|
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
|
||||||
|
form_data = request.POST if request.method == "POST" else None
|
||||||
|
form = TagForm(user=request.user, data=form_data, instance=tag)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f'Tag "{tag.name}" updated successfully.')
|
||||||
|
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||||
|
context = {
|
||||||
|
"tag": tag,
|
||||||
|
"form": form,
|
||||||
|
}
|
||||||
|
return render(request, "tags/edit.html", context, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tag_merge(request: HttpRequest):
|
||||||
|
form_data = request.POST if request.method == "POST" else None
|
||||||
|
form = TagMergeForm(user=request.user, data=form_data)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if form.is_valid():
|
||||||
|
target_tag = form.cleaned_data["target_tag"]
|
||||||
|
merge_tags = form.cleaned_data["merge_tags"]
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
BookmarkTag = Bookmark.tags.through
|
||||||
|
|
||||||
|
# Get all bookmarks that have any of the merge tags, but do not
|
||||||
|
# already have the target tag
|
||||||
|
bookmark_ids = list(
|
||||||
|
Bookmark.objects.filter(tags__in=merge_tags)
|
||||||
|
.exclude(tags=target_tag)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new relationships to the target tag
|
||||||
|
new_relationships = [
|
||||||
|
BookmarkTag(tag_id=target_tag.id, bookmark_id=bookmark_id)
|
||||||
|
for bookmark_id in bookmark_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
if new_relationships:
|
||||||
|
BookmarkTag.objects.bulk_create(new_relationships)
|
||||||
|
|
||||||
|
# Bulk delete all relationships for merge tags
|
||||||
|
merge_tag_ids = [tag.id for tag in merge_tags]
|
||||||
|
BookmarkTag.objects.filter(tag_id__in=merge_tag_ids).delete()
|
||||||
|
|
||||||
|
# Delete the merged tags
|
||||||
|
tag_names = [tag.name for tag in merge_tags]
|
||||||
|
Tag.objects.filter(id__in=merge_tag_ids).delete()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f'Successfully merged {len(merge_tags)} tags ({", ".join(tag_names)}) into "{target_tag.name}".',
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
||||||
|
|
||||||
|
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||||
|
return render(request, "tags/merge.html", {"form": form}, status=status)
|
Reference in New Issue
Block a user