Align form usages in templates (#1267)

This commit is contained in:
Sascha Ißbrücker
2026-01-05 05:33:59 +01:00
committed by GitHub
parent 7dfb8126c4
commit 65f3759444
38 changed files with 611 additions and 493 deletions

View File

@@ -1,10 +1,15 @@
from django import forms
from django.forms.utils import ErrorList
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from bookmarks.models import (
Bookmark,
BookmarkBundle,
BookmarkSearch,
GlobalSettings,
Tag,
UserProfile,
build_tag_string,
parse_tag_string,
sanitize_tag_name,
@@ -12,23 +17,29 @@ from bookmarks.models import (
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.type_defs import HttpRequest
from bookmarks.validators import BookmarkURLValidator
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
from bookmarks.widgets import (
FormCheckbox,
FormErrorList,
FormInput,
FormNumberInput,
FormSelect,
FormTextarea,
TagAutocomplete,
)
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
url = forms.CharField(validators=[BookmarkURLValidator()], widget=FormInput)
tag_string = forms.CharField(required=False, widget=TagAutocomplete)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
title = forms.CharField(max_length=512, required=False, widget=FormInput)
description = forms.CharField(required=False, widget=FormTextarea)
notes = forms.CharField(required=False, widget=FormTextarea)
unread = forms.BooleanField(required=False, widget=FormCheckbox)
shared = forms.BooleanField(required=False, widget=FormCheckbox)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
auto_close = forms.CharField(required=False, widget=forms.HiddenInput)
class Meta:
model = Bookmark
@@ -62,7 +73,7 @@ class BookmarkForm(forms.ModelForm):
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None
super().__init__(
data, instance=instance, initial=initial, error_class=CustomErrorList
data, instance=instance, initial=initial, error_class=FormErrorList
)
@property
@@ -111,12 +122,14 @@ def convert_tag_string(tag_string: str):
class TagForm(forms.ModelForm):
name = forms.CharField(widget=FormInput)
class Meta:
model = Tag
fields = ["name"]
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
super().__init__(*args, **kwargs, error_class=FormErrorList)
self.user = user
def clean_name(self):
@@ -146,11 +159,11 @@ class TagForm(forms.ModelForm):
class TagMergeForm(forms.Form):
target_tag = forms.CharField()
merge_tags = forms.CharField()
target_tag = forms.CharField(widget=TagAutocomplete)
merge_tags = forms.CharField(widget=TagAutocomplete)
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
super().__init__(*args, **kwargs, error_class=FormErrorList)
self.user = user
def clean_target_tag(self):
@@ -197,3 +210,156 @@ class TagMergeForm(forms.Form):
)
return merge_tags
class BookmarkBundleForm(forms.ModelForm):
name = forms.CharField(max_length=256, widget=FormInput)
search = forms.CharField(max_length=256, required=False, widget=FormInput)
any_tags = forms.CharField(required=False, widget=TagAutocomplete)
all_tags = forms.CharField(required=False, widget=TagAutocomplete)
excluded_tags = forms.CharField(required=False, widget=TagAutocomplete)
class Meta:
model = BookmarkBundle
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=FormErrorList)
class BookmarkSearchForm(forms.Form):
SORT_CHOICES = [
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
]
FILTER_SHARED_CHOICES = [
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
]
FILTER_UNREAD_CHOICES = [
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
]
q = forms.CharField()
user = forms.ChoiceField(required=False, widget=FormSelect)
bundle = forms.CharField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES, widget=FormSelect)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
modified_since = forms.CharField(required=False)
added_since = forms.CharField(required=False)
def __init__(
self,
search: BookmarkSearch,
editable_fields: list[str] = None,
users: list[User] = None,
):
super().__init__()
editable_fields = editable_fields or []
self.editable_fields = editable_fields
# set choices for user field if users are provided
if users:
user_choices = [(user.username, user.username) for user in users]
user_choices.insert(0, ("", "Everyone"))
self.fields["user"].choices = user_choices
for param in search.params:
# set initial values for modified params
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
# all necessary search options are kept when submitting the form.
if search.is_modified(param) and param not in editable_fields:
self.fields[param].widget = forms.HiddenInput()
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = [
"theme",
"bookmark_date_display",
"bookmark_description_display",
"bookmark_description_max_lines",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
"tag_grouping",
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"enable_preview_images",
"enable_automatic_html_snapshots",
"display_url",
"display_view_bookmark_action",
"display_edit_bookmark_action",
"display_archive_bookmark_action",
"display_remove_bookmark_action",
"permanent_notes",
"default_mark_unread",
"default_mark_shared",
"custom_css",
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
"hide_bundles",
"legacy_search",
]
widgets = {
"theme": FormSelect,
"bookmark_date_display": FormSelect,
"bookmark_description_display": FormSelect,
"bookmark_description_max_lines": FormNumberInput,
"bookmark_link_target": FormSelect,
"web_archive_integration": FormSelect,
"tag_search": FormSelect,
"tag_grouping": FormSelect,
"auto_tagging_rules": FormTextarea,
"custom_css": FormTextarea,
"items_per_page": FormNumberInput,
"display_url": FormCheckbox,
"permanent_notes": FormCheckbox,
"display_view_bookmark_action": FormCheckbox,
"display_edit_bookmark_action": FormCheckbox,
"display_archive_bookmark_action": FormCheckbox,
"display_remove_bookmark_action": FormCheckbox,
"sticky_pagination": FormCheckbox,
"collapse_side_panel": FormCheckbox,
"hide_bundles": FormCheckbox,
"legacy_search": FormCheckbox,
"enable_favicons": FormCheckbox,
"enable_preview_images": FormCheckbox,
"enable_sharing": FormCheckbox,
"enable_public_sharing": FormCheckbox,
"enable_automatic_html_snapshots": FormCheckbox,
"default_mark_unread": FormCheckbox,
"default_mark_shared": FormCheckbox,
}
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
widgets = {
"landing_page": FormSelect,
"guest_profile_user": FormSelect,
"enable_link_prefetch": FormCheckbox,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

View File

@@ -9,6 +9,7 @@ export class TagAutocomplete extends TurboLitElement {
inputId: { type: String, attribute: "input-id" },
inputName: { type: String, attribute: "input-name" },
inputValue: { type: String, attribute: "input-value" },
inputClass: { type: String, attribute: "input-class" },
inputPlaceholder: { type: String, attribute: "input-placeholder" },
inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" },
variant: { type: String },
@@ -160,7 +161,7 @@ export class TagAutocomplete extends TurboLitElement {
name="${this.inputName || nothing}"
.value="${this.inputValue || ""}"
placeholder="${this.inputPlaceholder || " "}"
class="form-input"
class="form-input ${this.inputClass || ""}"
type="text"
autocomplete="off"
autocapitalize="off"

View File

@@ -42,10 +42,10 @@ document.addEventListener("turbo:render", () => {
});
document.addEventListener("turbo:before-morph-element", (event) => {
if (event.target instanceof TurboLitElement) {
// Prevent Turbo from morphing Lit elements, which would remove rendered
// contents. For now this means that any Lit element / widget can not be
// updated from the server when using morphing.
const parent = event.target?.parentElement;
if (parent instanceof TurboLitElement) {
// Prevent Turbo from morphing Lit elements contents, which would remove
// elements rendered on the client side.
event.preventDefault();
}
});

View File

@@ -3,7 +3,6 @@ import hashlib
import logging
import os
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator
@@ -195,12 +194,6 @@ class BookmarkBundle(models.Model):
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"
@@ -323,64 +316,6 @@ class BookmarkSearch:
)
class BookmarkSearchForm(forms.Form):
SORT_CHOICES = [
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
]
FILTER_SHARED_CHOICES = [
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
]
FILTER_UNREAD_CHOICES = [
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
]
q = forms.CharField()
user = forms.ChoiceField(required=False)
bundle = forms.CharField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
modified_since = forms.CharField(required=False)
added_since = forms.CharField(required=False)
def __init__(
self,
search: BookmarkSearch,
editable_fields: list[str] = None,
users: list[User] = None,
):
super().__init__()
editable_fields = editable_fields or []
self.editable_fields = editable_fields
# set choices for user field if users are provided
if users:
user_choices = [(user.username, user.username) for user in users]
user_choices.insert(0, ("", "Everyone"))
self.fields["user"].choices = user_choices
for param in search.params:
# set initial values for modified params
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
# all necessary search options are kept when submitting the form.
if search.is_modified(param) and param not in editable_fields:
self.fields[param].widget = forms.HiddenInput()
class UserProfile(models.Model):
THEME_AUTO = "auto"
THEME_LIGHT = "light"
@@ -507,41 +442,6 @@ class UserProfile(models.Model):
super().save(*args, **kwargs)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = [
"theme",
"bookmark_date_display",
"bookmark_description_display",
"bookmark_description_max_lines",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
"tag_grouping",
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"enable_preview_images",
"enable_automatic_html_snapshots",
"display_url",
"display_view_bookmark_action",
"display_edit_bookmark_action",
"display_archive_bookmark_action",
"display_remove_bookmark_action",
"permanent_notes",
"default_mark_unread",
"default_mark_shared",
"custom_css",
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
"hide_bundles",
"legacy_search",
]
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
@@ -640,13 +540,3 @@ class GlobalSettings(models.Model):
if not self.pk and GlobalSettings.objects.exists():
raise Exception("There is already one instance of GlobalSettings")
return super().save(*args, **kwargs)
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

View File

@@ -39,7 +39,6 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"widget_tweaks",
"rest_framework",
"rest_framework.authtoken",
"huey.contrib.djhuey",

View File

@@ -31,6 +31,15 @@
outline: none;
}
}
&:has(.is-error) {
background: var(--error-color-shade);
border-color: var(--error-color);
&.is-focused {
outline-color: var(--error-color);
}
}
}
&.small {

View File

@@ -135,6 +135,10 @@ textarea.form-input {
.is-error + & {
color: var(--error-color);
}
&.is-error {
color: var(--error-color);
}
}
/* Form element: Select */

View File

@@ -1,38 +1,32 @@
{% load widget_tweaks %}
{% load static %}
{% load shared %}
<div class="bookmarks-form">
{% csrf_token %}
{{ form.auto_close|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.auto_close }}
<div class="form-group">
{% formlabel form.url "URL" %}
<div class="has-icon-right">
{{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
{% formfield form.url autofocus=True %}
<i class="form-icon loading"></i>
</div>
{% if form.url.errors %}<div class="form-input-hint">{{ form.url.errors }}</div>{% endif %}
{{ form.url.errors }}
<div class="form-input-hint bookmark-exists">
This URL is already bookmarked.
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
<ld-tag-autocomplete input-id="{{ form.tag_string.auto_id }}"
input-name="{{ form.tag_string.html_name }}"
input-value="{{ form.tag_string.value|default_if_none:'' }}"
input-aria-describedby="{{ form.tag_string.auto_id }}_help">
</ld-tag-autocomplete>
<div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
{% formlabel form.tag_string "Tags" %}
{% formfield form.tag_string has_help=True %}
{% formhelp form.tag_string %}
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
If a tag does not exist it will be automatically created.
</div>
{% endformhelp %}
<div class="form-input-hint auto-tags"></div>
{{ form.tag_string.errors }}
</div>
<div class="form-group">
<div class="d-flex justify-between align-baseline">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
{% formlabel form.title "Title" %}
<div class="flex">
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
<ld-clear-button data-for="{{ form.title.id_for_label }}">
@@ -40,18 +34,16 @@
</ld-clear-button>
</div>
</div>
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
{{ form.title.errors }}
{% formfield form.title %}
</div>
<div class="form-group">
<div class="d-flex justify-between align-baseline">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
{% formlabel form.description "Description" %}
<ld-clear-button data-for="{{ form.description.id_for_label }}">
<button class="btn btn-link suffix-button" type="button">Clear</button>
</ld-clear-button>
</div>
{{ form.description|add_class:"form-input"|attr:"rows:3" }}
{{ form.description.errors }}
{% formfield form.description rows="3" %}
</div>
<div class="form-group">
<details class="notes"{% if form.has_notes %} open{% endif %}>
@@ -59,35 +51,28 @@
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint">Additional notes, supports Markdown.</div>
{% formfield form.notes rows="8" has_help=True %}
{% formhelp form.notes %}
Additional notes, supports Markdown.
{% endformhelp %}
</details>
{{ form.notes.errors }}
</div>
<div class="form-group">
<div class="form-checkbox">
{{ form.unread|form_field:"help" }}
<i class="form-icon"></i>
<label for="{{ form.unread.id_for_label }}">Mark as unread</label>
</div>
<div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
{% formfield form.unread label="Mark as unread" has_help=True %}
{% formhelp form.unread %}
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div>
{% endformhelp %}
</div>
{% if request.user_profile.enable_sharing %}
<div class="form-group">
<div class="form-checkbox">
{{ form.shared|form_field:"help" }}
<i class="form-icon"></i>
<label for="{{ form.shared.id_for_label }}">Share</label>
</div>
<div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
{% formfield form.shared label="Share" has_help=True %}
{% formhelp form.shared %}
{% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users.
{% else %}
Share this bookmark with other registered users.
{% endif %}
</div>
{% endformhelp %}
</div>
{% endif %}
<div class="divider"></div>

View File

@@ -1,4 +1,4 @@
{% load static widget_tweaks %}
{% load static shared %}
<div class="search-container">
<form id="search" action="" method="get" role="search">
<ld-search-autocomplete input-name="q"
@@ -28,7 +28,7 @@
<div class="form-group">
<label for="{{ preferences_form.sort.id_for_label }}"
class="form-label{% if 'sort' in search.modified_params %} text-bold{% endif %}">Sort by</label>
{{ preferences_form.sort|add_class:"form-select select-sm" }}
{% formfield preferences_form.sort class='select-sm' %}
</div>
{% endif %}
{% if 'shared' in preferences_form.editable_fields %}

View File

@@ -1,4 +1,4 @@
{% load widget_tweaks %}
{% load shared %}
<section aria-labelledby="user-heading">
<div class="section-header">
<h2 id="user-heading">User</h2>
@@ -9,7 +9,7 @@
{% for hidden_field in user_list.form.hidden_fields %}{{ hidden_field }}{% endfor %}
<div class="form-group">
<div class="d-flex">
{% render_field user_list.form.user class+="form-select" data-submit-on-change="" %}
{% formfield user_list.form.user data_submit_on_change="" %}
<noscript>
<button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript>

View File

@@ -1,5 +1,4 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Edit bundle - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}

View File

@@ -1,38 +1,37 @@
{% 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 terms</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">All of these search terms must be present in a bookmark to match.</div>
{% load shared %}
<div class="form-group">
{% formlabel form.name "Name" %}
{% formfield form.name %}
{{ form.name.errors }}
</div>
<div class="form-group">
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
<ld-tag-autocomplete input-id="{{ form.any_tags.auto_id }}"
input-name="{{ form.any_tags.html_name }}"
input-value="{{ form.any_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">At least one of these tags must be present in a bookmark to match.</div>
{% formlabel form.search "Search terms" %}
{% formfield form.search has_help=True %}
{{ form.search.errors }}
{% formhelp form.search %}
All of these search terms must be present in a bookmark to match.
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
<ld-tag-autocomplete input-id="{{ form.all_tags.auto_id }}"
input-name="{{ form.all_tags.html_name }}"
input-value="{{ form.all_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">All of these tags must be present in a bookmark to match.</div>
{% formlabel form.any_tags "Tags" %}
{% formfield form.any_tags has_help=True %}
{% formhelp form.any_tags %}
At least one of these tags must be present in a bookmark to match.
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
<ld-tag-autocomplete input-id="{{ form.excluded_tags.auto_id }}"
input-name="{{ form.excluded_tags.html_name }}"
input-value="{{ form.excluded_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">None of these tags must be present in a bookmark to match.</div>
{% formlabel form.all_tags "Required tags" %}
{% formfield form.all_tags has_help=True %}
{% formhelp form.all_tags %}
All of these tags must be present in a bookmark to match.
{% endformhelp %}
</div>
<div class="form-group">
{% formlabel form.excluded_tags "Excluded tags" %}
{% formfield form.excluded_tags has_help=True %}
{% formhelp form.excluded_tags %}
None of these tags must be present in a bookmark to match.
{% endformhelp %}
</div>
<div class="form-footer d-flex mt-4">
<input type="submit"

View File

@@ -1,5 +1,4 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="New bundle - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Registration complete - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Registration - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
@@ -14,22 +13,22 @@
{% csrf_token %}
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
{{ form.username }}
<div class="form-input-hint">{{ form.errors.username }}</div>
</div>
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email|add_class:'form-input'|attr:"placeholder: " }}
{{ form.email }}
<div class="form-input-hint">{{ form.errors.email }}</div>
</div>
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1|add_class:'form-input'|attr:"placeholder: " }}
{{ form.password1 }}
<div class="form-input-hint">{{ form.errors.password1 }}</div>
</div>
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
{{ form.password2|add_class:'form-input'|attr:"placeholder: " }}
{{ form.password2 }}
<div class="form-input-hint">{{ form.errors.password2 }}</div>
</div>
<br />

View File

@@ -1,5 +1,5 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% load shared %}
{% block head %}
{% with page_title="Login - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
@@ -11,17 +11,15 @@
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{% if form.errors %}
<div class="form-group has-error">
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
</div>
<p class="form-input-hint is-error">Your username and password didn't match. Please try again.</p>
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
{% formlabel form.username 'Username' %}
{% formfield form.username class='form-input' %}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
{% formlabel form.password 'Password' %}
{% formfield form.password class='form-input' %}
</div>
<br />
<div class="d-flex justify-between">

View File

@@ -1,5 +1,4 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Password changed - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% load shared %}
{% block head %}
{% with page_title="Change password - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
@@ -10,20 +10,20 @@
</div>
<form method="post" action="{% url 'change_password' %}">
{% csrf_token %}
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
{% if form.old_password.errors %}<div class="form-input-hint">{{ form.old_password.errors }}</div>{% endif %}
<div class="form-group">
{% formlabel form.old_password 'Old password' %}
{% formfield form.old_password class='form-input' %}
{{ form.old_password.errors }}
</div>
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password1.errors %}<div class="form-input-hint">{{ form.new_password1.errors }}</div>{% endif %}
<div class="form-group">
{% formlabel form.new_password1 'New password' %}
{% formfield form.new_password1 class='form-input' %}
{{ form.new_password1.errors }}
</div>
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password2.errors %}<div class="form-input-hint">{{ form.new_password2.errors }}</div>{% endif %}
<div class="form-group">
{% formlabel form.new_password2 'Confirm new password' %}
{% formfield form.new_password2 class='form-input' %}
{{ form.new_password2.errors }}
</div>
<br />
<input type="submit"

View File

@@ -1,5 +1,5 @@
{% extends "shared/layout.html" %}
{% load widget_tweaks %}
{% load shared %}
{% block head %}
{% with page_title="Settings - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
@@ -20,154 +20,117 @@
data-turbo="false">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
{% formlabel form.theme "Theme" %}
{% formfield form.theme has_help=True class="width-25 width-sm-100" %}
{% formhelp form.theme %}
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}"
class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
{% formlabel form.bookmark_date_display "Bookmark date format" %}
{% formfield form.bookmark_date_display has_help=True class="width-25 width-sm-100" %}
{% formhelp form.bookmark_date_display %}
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can
be hidden.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.bookmark_description_display.id_for_label }}"
class="form-label">
Bookmark
description
</label>
{{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
{% formlabel form.bookmark_description_display "Bookmark description" %}
{% formfield form.bookmark_description_display has_help=True class="width-25 width-sm-100" %}
{% formhelp form.bookmark_description_display %}
Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
</div>
{% endformhelp %}
</div>
<div class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
<label for="{{ form.bookmark_description_max_lines.id_for_label }}"
class="form-label">
Bookmark description
max lines
</label>
{{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
<div class="form-input-hint">Limits the number of lines that are displayed for the bookmark description.</div>
{% formlabel form.bookmark_description_max_lines "Bookmark description max lines" %}
{% formfield form.bookmark_description_max_lines has_help=True class="width-25 width-sm-100" %}
{% formhelp form.bookmark_description_max_lines %}
Limits the number of lines that are displayed for the bookmark description.
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }}
<i class="form-icon"></i> Show bookmark URL
</label>
<div class="form-input-hint">When enabled, this setting displays the bookmark URL below the title.</div>
{% formfield form.display_url label="Show bookmark URL" has_help=True %}
{% formhelp form.display_url %}
When enabled, this setting displays the bookmark URL below the title.
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox">
{{ form.permanent_notes }}
<i class="form-icon"></i> Show notes permanently
</label>
<div class="form-input-hint">
{% formfield form.permanent_notes label="Show notes permanently" has_help=True %}
{% formhelp form.permanent_notes %}
Whether to show bookmark notes permanently, without having to toggle them individually.
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label class="form-label">Bookmark actions</label>
<label for="{{ form.display_view_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_view_bookmark_action }}
<i class="form-icon"></i> View
</label>
<label for="{{ form.display_edit_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_edit_bookmark_action }}
<i class="form-icon"></i> Edit
</label>
<label for="{{ form.display_archive_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_archive_bookmark_action }}
<i class="form-icon"></i> Archive
</label>
<label for="{{ form.display_remove_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_remove_bookmark_action }}
<i class="form-icon"></i> Remove
</label>
<span class="form-label">Bookmark actions</span>
{% formfield form.display_view_bookmark_action label="View" %}
{% formfield form.display_edit_bookmark_action label="Edit" %}
{% formfield form.display_archive_bookmark_action label="Archive" %}
{% formfield form.display_remove_bookmark_action label="Remove" %}
<div class="form-input-hint">Which actions to display for each bookmark.</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">Whether to open bookmarks a new page or in the same page.</div>
</div>
<div class="form-group{% if form.items_per_page.errors %} has-error{% endif %}">
<label for="{{ form.items_per_page.id_for_label }}" class="form-label">Items per page</label>
{{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }}
{% if form.items_per_page.errors %}
<div class="form-input-hint is-error">{{ form.items_per_page.errors }}</div>
{% else %}
{% endif %}
<div class="form-input-hint">The number of bookmarks to display per page.</div>
{% formlabel form.bookmark_link_target "Open bookmarks in" %}
{% formfield form.bookmark_link_target has_help=True class="width-25 width-sm-100" %}
{% formhelp form.bookmark_link_target %}
Whether to open bookmarks a new page or in the same page.
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox">
{{ form.sticky_pagination }}
<i class="form-icon"></i> Sticky pagination
</label>
<div class="form-input-hint">
{% formlabel form.items_per_page "Items per page" %}
{% formfield form.items_per_page has_help=True class="width-25 width-sm-100" min="10" %}
{{ form.items_per_page.errors }}
{% formhelp form.items_per_page %}
The number of bookmarks to display per page.
{% endformhelp %}
</div>
<div class="form-group">
{% formfield form.sticky_pagination label="Sticky pagination" has_help=True %}
{% formhelp form.sticky_pagination %}
When enabled, the pagination controls will stick to the bottom of the screen, so that they are always
visible without having to scroll to the end of the page first.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.collapse_side_panel.id_for_label }}"
class="form-checkbox">
{{ form.collapse_side_panel }}
<i class="form-icon"></i> Collapse side panel
</label>
<div class="form-input-hint">
{% formfield form.collapse_side_panel label="Collapse side panel" has_help=True %}
{% formhelp form.collapse_side_panel %}
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
Instead, the tags are shown in an expandable drawer.
</div>
{% endformhelp %}
</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>
{% formfield form.hide_bundles label="Hide bundles" has_help=True %}
{% formhelp form.hide_bundles %}
Allows to hide the bundles in the side panel if you don't intend to use them.
{% endformhelp %}
</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" }}
<div class="form-input-hint">
{% formlabel form.tag_search "Tag search" %}
{% formfield form.tag_search has_help=True class="width-25 width-sm-100" %}
{% formhelp form.tag_search %}
In strict mode, tags must be prefixed with a hash character (#).
In lax mode, tags can also be searched without the hash character.
Note that tags without the hash character are indistinguishable from search terms, which means the search
result will also include bookmarks where a search term matches otherwise.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.legacy_search.id_for_label }}" class="form-checkbox">
{{ form.legacy_search }}
<i class="form-icon"></i> Enable legacy search
</label>
<div class="form-input-hint">
{% formfield form.legacy_search label="Enable legacy search" has_help=True %}
{% formhelp form.legacy_search %}
Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not).
If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search.
Please report any issues you encounter with the new search on <a href="https://github.com/sissbruecker/linkding/issues"
target="_blank">GitHub</a> so they can be addressed.
This option will be removed in a future version.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
{% formlabel form.tag_grouping "Tag grouping" %}
{% formfield form.tag_grouping has_help=True class="width-25 width-sm-100" %}
{% formhelp form.tag_grouping %}
In alphabetical mode, tags will be grouped by the first letter.
If disabled, tags will not be grouped.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<details {% if form.auto_tagging_rules.value %}open{% endif %}>
@@ -176,21 +139,18 @@
</summary>
<label for="{{ form.auto_tagging_rules.id_for_label }}"
class="text-assistive">Auto Tagging</label>
<div>{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}</div>
<div>{% formfield form.auto_tagging_rules has_help=True class="monospace" rows="6" %}</div>
</details>
<div class="form-input-hint">
{% formhelp form.auto_tagging_rules %}
Automatically adds tags to bookmarks based on predefined rules.
Each line is a single rule that maps a URL to one or more tags. For example:
<pre>youtube.com video
reddit.com/r/Music music reddit</pre>
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
{{ form.enable_favicons }}
<i class="form-icon"></i> Enable Favicons
</label>
<div class="form-input-hint">
{% formfield form.enable_favicons label="Enable Favicons" has_help=True %}
{% formhelp form.enable_favicons %}
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
Enabling this feature automatically downloads all missing favicons.
By default, this feature uses a <b>Google service</b> to download favicons.
@@ -198,95 +158,68 @@ reddit.com/r/Music music reddit</pre>
<a href="https://linkding.link/options/#ld_favicon_provider"
target="_blank">options documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up.
</div>
{% endformhelp %}
{% if request.user_profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.enable_preview_images.id_for_label }}"
class="form-checkbox">
{{ form.enable_preview_images }}
<i class="form-icon"></i> Enable Preview Images
</label>
<div class="form-input-hint">
{% formfield form.enable_preview_images label="Enable Preview Images" has_help=True %}
{% formhelp form.enable_preview_images %}
Automatically loads preview images for bookmarked websites and displays them next to each bookmark.
Enabling this feature automatically downloads all missing preview images.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}"
class="form-label">
Internet Archive
integration
</label>
{{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
{% formlabel form.web_archive_integration "Internet Archive integration" %}
{% formfield form.web_archive_integration has_help=True class="width-25 width-sm-100" %}
{% formhelp form.web_archive_integration %}
Enabling this feature will automatically create snapshots of bookmarked websites on the
<a href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback Machine</a>.
This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified.
Please consider donating to the <a href="https://archive.org/donate" target="_blank" rel="noopener">Internet Archive</a> if you make use of this feature.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.enable_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_sharing }}
<i class="form-icon"></i> Enable bookmark sharing
</label>
<div class="form-input-hint">
{% formfield form.enable_sharing label="Enable bookmark sharing" has_help=True %}
{% formhelp form.enable_sharing %}
Allows to share bookmarks with other users, and to view shared bookmarks.
Disabling this feature will hide all previously shared bookmarks from other users.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.enable_public_sharing.id_for_label }}"
class="form-checkbox">
{{ form.enable_public_sharing }}
<i class="form-icon"></i> Enable public bookmark sharing
</label>
<div class="form-input-hint">
{% formfield form.enable_public_sharing label="Enable public bookmark sharing" has_help=True %}
{% formhelp form.enable_public_sharing %}
Makes shared bookmarks publicly accessible, without requiring a login.
That means that anyone with a link to this instance can view shared bookmarks via the <a href="{% url 'linkding:bookmarks.shared' %}">shared bookmarks page</a>.
</div>
{% endformhelp %}
</div>
{% if has_snapshot_support %}
<div class="form-group">
<label for="{{ form.enable_automatic_html_snapshots.id_for_label }}"
class="form-checkbox">
{{ form.enable_automatic_html_snapshots }}
<i class="form-icon"></i> Automatically create HTML snapshots
</label>
<div class="form-input-hint">
{% formfield form.enable_automatic_html_snapshots label="Automatically create HTML snapshots" has_help=True %}
{% formhelp form.enable_automatic_html_snapshots %}
Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be
created manually in the details view of a bookmark.
</div>
{% endformhelp %}
<button class="btn mt-2" name="create_missing_html_snapshots">Create missing HTML snapshots</button>
</div>
{% endif %}
<div class="form-group">
<label for="{{ form.default_mark_unread.id_for_label }}"
class="form-checkbox">
{{ form.default_mark_unread }}
<i class="form-icon"></i> Create bookmarks as unread by default
</label>
<div class="form-input-hint">
{% formfield form.default_mark_unread label="Create bookmarks as unread by default" has_help=True %}
{% formhelp form.default_mark_unread %}
Sets the default state for the "Mark as unread" option when creating a new bookmark.
Setting this option will make all new bookmarks default to unread.
This can be overridden when creating each new bookmark.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ form.default_mark_shared.id_for_label }}"
class="form-checkbox">
{{ form.default_mark_shared }}
<i class="form-icon"></i> Create bookmarks as shared by default
</label>
<div class="form-input-hint">
{% formfield form.default_mark_shared label="Create bookmarks as shared by default" has_help=True %}
{% formhelp form.default_mark_shared %}
Sets the default state for the "Share" option when creating a new bookmark.
Setting this option will make all new bookmarks default to shared.
This can be overridden when creating each new bookmark.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}>
@@ -294,9 +227,11 @@ reddit.com/r/Music music reddit</pre>
<span class="form-label d-inline-block">Custom CSS</span>
</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div>{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}</div>
<div>{% formfield form.custom_css has_help=True class="monospace" rows="6" %}</div>
</details>
<div class="form-input-hint">Allows to add custom CSS to the page.</div>
{% formhelp form.custom_css %}
Allows to add custom CSS to the page.
{% endformhelp %}
</div>
<div class="form-group">
<input type="submit"
@@ -316,34 +251,27 @@ reddit.com/r/Music music reddit</pre>
data-turbo="false">
{% csrf_token %}
<div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}"
class="form-label">Landing page</label>
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">The page that unauthenticated users are redirected to when accessing the root URL.</div>
{% formlabel global_settings_form.landing_page "Landing page" %}
{% formfield global_settings_form.landing_page has_help=True class="width-25 width-sm-100" %}
{% formhelp global_settings_form.landing_page %}
The page that unauthenticated users are redirected to when accessing the root URL.
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}"
class="form-label">
Guest user
profile
</label>
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
{% formlabel global_settings_form.guest_profile_user "Guest user profile" %}
{% formfield global_settings_form.guest_profile_user has_help=True class="width-25 width-sm-100" %}
{% formhelp global_settings_form.guest_profile_user %}
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<label for="{{ global_settings_form.enable_link_prefetch.id_for_label }}"
class="form-checkbox">
{{ global_settings_form.enable_link_prefetch }}
<i class="form-icon"></i> Enable prefetching links on hover
</label>
<div class="form-input-hint">
{% formfield global_settings_form.enable_link_prefetch label="Enable prefetching links on hover" has_help=True %}
{% formhelp global_settings_form.enable_link_prefetch %}
Prefetches internal links when hovering over them. This can improve the perceived performance when
navigating application, but also increases the load on the server as well as bandwidth usage.
</div>
{% endformhelp %}
</div>
<div class="form-group">
<input type="submit"
@@ -367,10 +295,13 @@ reddit.com/r/Music music reddit</pre>
{% csrf_token %}
<div class="form-group">
<label for="import_map_private_flag" class="form-checkbox">
<input type="checkbox" id="import_map_private_flag" name="map_private_flag">
<input type="checkbox"
id="import_map_private_flag"
name="map_private_flag"
aria-describedby="import_map_private_flag_help">
<i class="form-icon"></i> Import public bookmarks as shared
</label>
<div class="form-input-hint">
<div id="import_map_private_flag_help" class="form-input-hint">
When importing bookmarks from a service that supports marking bookmarks as public or private (using the
<code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not
private as shared bookmarks.

View File

@@ -2,7 +2,7 @@
{# Force rendering validation errors in English language to align with the rest of the app #}
{% language 'en-us' %}
{% if errors %}
<ul class="{{ error_class }}"
<ul class="{{ error_class }} form-input-hint is-error"
{% if errors.field_id %}id="{{ errors.field_id }}_error"{% endif %}>
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>

View File

@@ -1,9 +1,9 @@
{% 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: "|attr:"autofocus" }}
<div class="form-input-hint">
{% load shared %}
<div class="form-group">
{% formlabel form.name "Name" %}
{% formfield form.name has_help=True autofocus=True %}
{% formhelp form.name %}
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 %}
{% endformhelp %}
{{ form.name.errors }}
</div>

View File

@@ -1,4 +1,4 @@
{% load widget_tweaks %}
{% load shared %}
<turbo-frame id="tag-modal">
<form method="post"
action="{% url 'linkding:tags.merge' %}"
@@ -23,28 +23,22 @@
<li>The merged tags are deleted</li>
</ol>
</details>
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}">
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
<ld-tag-autocomplete input-id="{{ form.target_tag.auto_id }}"
input-name="{{ form.target_tag.html_name }}"
input-value="{{ form.target_tag.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">
<div class="form-group">
{% formlabel form.target_tag "Target tag" %}
{% formfield form.target_tag has_help=True %}
{% formhelp form.target_tag %}
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 %}
{% endformhelp %}
{{ form.target_tag.errors }}
</div>
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}">
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
<ld-tag-autocomplete input-id="{{ form.merge_tags.auto_id }}"
input-name="{{ form.merge_tags.html_name }}"
input-value="{{ form.merge_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete>
<div class="form-input-hint">
<div class="form-group">
{% formlabel form.merge_tags "Tags to merge" %}
{% formfield form.merge_tags has_help=True %}
{% formhelp form.merge_tags %}
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 %}
{% endformhelp %}
{{ form.merge_tags.errors }}
</div>
</div>
<div class="modal-footer d-flex justify-between">

View File

@@ -1,9 +1,7 @@
from django import template
from bookmarks.models import (
BookmarkSearch,
BookmarkSearchForm,
)
from bookmarks.forms import BookmarkSearchForm
from bookmarks.models import BookmarkSearch
register = template.Library()

View File

@@ -7,6 +7,7 @@ from django import template
from django.utils.safestring import mark_safe
from bookmarks import utils
from bookmarks.widgets import FormCheckbox
register = template.Library()
@@ -102,20 +103,60 @@ def append_attr(widget, attr, value):
attrs[attr] = value
@register.filter("form_field")
def form_field(field, modifier_string):
modifiers = modifier_string.split(",")
@register.simple_tag
def formlabel(field, label_text):
return mark_safe(
f'<label for="{field.id_for_label}" class="form-label">{label_text}</label>'
)
@register.simple_tag
def formfield(field, **kwargs):
widget = field.field.widget
label = kwargs.pop("label", None)
if label and isinstance(widget, FormCheckbox):
widget.label = label
if kwargs.pop("has_help", False):
append_attr(widget, "aria-describedby", field.auto_id + "_help")
has_errors = hasattr(field, "errors") and field.errors
if "validation" in modifiers and has_errors:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
if "help" in modifiers:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
# Some assistive technologies announce a field as invalid when it has the
# required attribute, even if the user has not interacted with the field
# yet. Set aria-invalid false to prevent this behavior.
if has_errors:
append_attr(widget, "class", "is-error")
append_attr(widget, "aria-describedby", field.auto_id + "_error")
if field.field.required and not has_errors:
append_attr(field.field.widget, "aria-invalid", "false")
append_attr(widget, "aria-invalid", "false")
return field
for attr, value in kwargs.items():
attr = attr.replace("_", "-")
if attr == "class":
append_attr(widget, "class", value)
else:
widget.attrs[attr] = value
return field.as_widget()
@register.tag
def formhelp(parser, token):
try:
tag_name, field_var = token.split_contents()
except ValueError:
raise template.TemplateSyntaxError(
f"{token.contents.split()[0]!r} tag requires a single argument (form field)"
) from None
nodelist = parser.parse(("endformhelp",))
parser.delete_first_token()
return FormHelpNode(nodelist, field_var)
class FormHelpNode(template.Node):
def __init__(self, nodelist, field_var):
self.nodelist = nodelist
self.field_var = template.Variable(field_var)
def render(self, context):
field = self.field_var.resolve(context)
content = self.nodelist.render(context)
return f'<div id="{field.auto_id}_help" class="form-input-hint">{content}</div>'

View File

@@ -113,7 +113,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
<input type="text" name="url" aria-invalid="false" autocomplete="off" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
""",
html,
)

View File

@@ -78,7 +78,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
"""
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
<input type="text" name="url" aria-invalid="false" autocomplete="off" autofocus class="form-input" required id="id_url" value="http://example.com">
""",
html,
)

View File

@@ -1,6 +1,7 @@
from django.test import TestCase
from bookmarks.models import BookmarkSearch, BookmarkSearchForm
from bookmarks.forms import BookmarkSearchForm
from bookmarks.models import BookmarkSearch
from bookmarks.tests.helpers import BookmarkFactoryMixin

View File

@@ -56,8 +56,8 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<input type="text" name="name" value="{bundle.name}"
autocomplete="off" placeholder=" " class="form-input"
maxlength="256" required id="id_name">
autocomplete="off" class="form-input"
maxlength="256" aria-invalid="false" required id="id_name">
""",
html,
)
@@ -65,8 +65,8 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<input type="text" name="search" value="{bundle.search}"
autocomplete="off" placeholder=" " class="form-input"
maxlength="256" id="id_search">
autocomplete="off" class="form-input"
maxlength="256" aria-describedby="id_search_help" id="id_search">
""",
html,
)
@@ -74,7 +74,7 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<ld-tag-autocomplete input-name="any_tags" input-value="{bundle.any_tags}"
input-id="id_any_tags">
input-aria-describedby="id_any_tags_help" input-id="id_any_tags">
""",
html,
)
@@ -82,7 +82,7 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<ld-tag-autocomplete input-name="all_tags" input-value="{bundle.all_tags}"
input-id="id_all_tags">
input-aria-describedby="id_all_tags_help" input-id="id_all_tags">
""",
html,
)
@@ -90,7 +90,7 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<ld-tag-autocomplete input-name="excluded_tags" input-value="{bundle.excluded_tags}"
input-id="id_excluded_tags">
input-aria-describedby="id_excluded_tags_help" input-id="id_excluded_tags">
""",
html,
)

View File

@@ -410,7 +410,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
"""
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
<input type="checkbox" name="enable_automatic_html_snapshots"
aria-describedby="id_enable_automatic_html_snapshots_help"
id="id_enable_automatic_html_snapshots" checked="">
""",
html,
count=0,
@@ -425,7 +427,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
"""
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
<input type="checkbox" name="enable_automatic_html_snapshots"
aria-describedby="id_enable_automatic_html_snapshots_help"
id="id_enable_automatic_html_snapshots" checked="">
""",
html,
count=1,

View File

@@ -29,6 +29,10 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return autocomplete_element.find_parent("div", class_="form-group")
return None
def get_autocomplete(self, response, input_name):
soup = self.make_soup(response.content.decode())
return soup.find("ld-tag-autocomplete", {"input-name": input_name})
def test_merge_tags(self):
target_tag = self.setup_tag(name="target_tag")
merge_tag1 = self.setup_tag(name="merge_tag1")
@@ -134,6 +138,9 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertIn("This field is required", self.get_text(target_tag_group))
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
autocomplete = self.get_autocomplete(response, "target_tag")
self.assertIn("is-error", autocomplete.get("input-class", ""))
def test_validate_missing_merge_tags(self):
self.setup_tag(name="target_tag")
@@ -145,6 +152,9 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn("This field is required", self.get_text(merge_tags_group))
autocomplete = self.get_autocomplete(response, "merge_tags")
self.assertIn("is-error", autocomplete.get("input-class", ""))
def test_validate_nonexistent_target_tag(self):
self.setup_tag(name="merge_tag")

View File

@@ -160,7 +160,15 @@ if settings.LD_ENABLE_OIDC:
# Registration
if settings.ALLOW_REGISTRATION:
urlpatterns.append(path("", include("django_registration.backends.one_step.urls")))
from django_registration.backends.one_step.views import RegistrationView
urlpatterns.append(
path(
"accounts/register/",
RegistrationView.as_view(),
name="django_registration_register",
)
)
# Context path
if settings.LD_CONTEXT_PATH:

View File

@@ -1,6 +1,8 @@
from django.conf import settings
from django.contrib.auth import views as auth_views
from bookmarks.widgets import FormErrorList
class LinkdingLoginView(auth_views.LoginView):
"""
@@ -8,6 +10,11 @@ class LinkdingLoginView(auth_views.LoginView):
Allows to override settings in tests
"""
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.error_class = FormErrorList
return form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -27,6 +34,11 @@ class LinkdingLoginView(auth_views.LoginView):
class LinkdingPasswordChangeView(auth_views.PasswordChangeView):
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.error_class = FormErrorList
return form
def form_invalid(self, form):
"""
Hotwired Turbo requires a non 2xx status code to handle failed form

View File

@@ -4,7 +4,8 @@ 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.forms import BookmarkBundleForm
from bookmarks.models import BookmarkBundle, BookmarkSearch
from bookmarks.queries import parse_query_string
from bookmarks.services import bundles
from bookmarks.views import access

View File

@@ -8,12 +8,12 @@ from django.http import Http404
from django.urls import reverse
from bookmarks import queries, utils
from bookmarks.forms import BookmarkSearchForm
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
BookmarkSearch,
BookmarkSearchForm,
Tag,
User,
UserProfile,

View File

@@ -13,13 +13,12 @@ from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from bookmarks.forms import GlobalSettingsForm, UserProfileForm
from bookmarks.models import (
ApiToken,
Bookmark,
FeedToken,
GlobalSettings,
GlobalSettingsForm,
UserProfileForm,
)
from bookmarks.services import exporter, importer, tasks
from bookmarks.type_defs import HttpRequest
@@ -301,10 +300,11 @@ def bookmark_export(request: HttpRequest):
return response
except Exception:
return render(
return general(
request,
"settings/general.html",
{"export_error": "An error occurred during bookmark export."},
context_overrides={
"export_error": "An error occurred during bookmark export."
},
)

83
bookmarks/widgets.py Normal file
View File

@@ -0,0 +1,83 @@
from django import forms
from django.forms.utils import ErrorList
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
class FormErrorList(ErrorList):
template_name = "shared/error_list.html"
class FormInput(forms.TextInput):
def __init__(self, attrs=None):
default_attrs = {"class": "form-input", "autocomplete": "off"}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
class FormNumberInput(forms.NumberInput):
def __init__(self, attrs=None):
default_attrs = {"class": "form-input"}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
class FormSelect(forms.Select):
def __init__(self, attrs=None):
default_attrs = {"class": "form-select"}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
class FormTextarea(forms.Textarea):
def __init__(self, attrs=None):
default_attrs = {"class": "form-input"}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
class FormCheckbox(forms.CheckboxInput):
def __init__(self, attrs=None):
super().__init__(attrs)
self.label = "Label"
def render(self, name, value, attrs=None, renderer=None):
checkbox_html = super().render(name, value, attrs, renderer)
input_id = attrs.get("id") if attrs else None
return format_html(
'<div class="form-checkbox">'
"{}"
'<i class="form-icon"></i>'
'<label for="{}">{}</label>'
"</div>",
checkbox_html,
input_id,
self.label,
)
class TagAutocomplete(forms.TextInput):
def __init__(self, attrs=None):
super().__init__(attrs)
def render(self, name, value, attrs=None, renderer=None):
# Merge self.attrs with passed attrs
final_attrs = self.build_attrs(self.attrs, attrs)
input_id = final_attrs.get("id")
input_value = escape(value) if value else ""
aria_describedby = final_attrs.get("aria-describedby")
input_class = final_attrs.get("class")
html = f'<ld-tag-autocomplete input-id="{input_id}" input-name="{name}" input-value="{input_value}"'
if aria_describedby:
html += f' input-aria-describedby="{aria_describedby}"'
if input_class:
html += f' input-class="{input_class}"'
html += "></ld-tag-autocomplete>"
return mark_safe(html)