Repository: sissbruecker/linkding
Pull request #1112 (2025-06-26 23:33:16)

Add Pocket migration to to community page
Updates the community page to include a link to [Pocket2Linkding](https://github.com/hkclark/Pocket2Linkding/).  With Mozilla Pocket shutting down on July 8, 2025 (the export service will be available through Oct 8, 2025), this could be a helpful tool for people looking to find a "home" after Pocket is dead.


Patch:
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index e20603d..dd87363 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -26,7 +26,7 @@ from bookmarks.models import (
     User,
     BookmarkBundle,
 )
-from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
+from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
 from bookmarks.type_defs import HttpRequest
 from bookmarks.views import access
 
@@ -290,6 +290,9 @@ class BookmarkBundleViewSet(
     def get_serializer_context(self):
         return {"user": self.request.user}
 
+    def perform_destroy(self, instance):
+        bundles.delete_bundle(instance)
+
 
 # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
 # Instead create separate routers for each view set and manually register them in urls.py
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index 7acca98..ecc9b03 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -11,7 +11,7 @@ from bookmarks.models import (
     UserProfile,
     BookmarkBundle,
 )
-from bookmarks.services import bookmarks
+from bookmarks.services import bookmarks, bundles
 from bookmarks.services.tags import get_or_create_tag
 from bookmarks.services.wayback import generate_fallback_webarchive_url
 from bookmarks.utils import app_version
@@ -55,17 +55,9 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
         ]
 
     def create(self, validated_data):
-        # Set owner to the authenticated user
-        validated_data["owner"] = self.context["user"]
-
-        # Set order to the next available position if not provided
-        if "order" not in validated_data:
-            max_order = BookmarkBundle.objects.filter(
-                owner=self.context["user"]
-            ).aggregate(Max("order", default=-1))["order__max"]
-            validated_data["order"] = max_order + 1
-
-        return super().create(validated_data)
+        bundle = BookmarkBundle(**validated_data)
+        bundle.order = validated_data["order"] if "order" in validated_data else None
+        return bundles.create_bundle(bundle, self.context["user"])
 
 
 class BookmarkSerializer(serializers.ModelSerializer):
diff --git a/bookmarks/forms.py b/bookmarks/forms.py
index bdd15c0..5aa3efc 100644
--- a/bookmarks/forms.py
+++ b/bookmarks/forms.py
@@ -1,4 +1,5 @@
 from django import forms
+from django.forms.utils import ErrorList
 
 from bookmarks.models import Bookmark, build_tag_string
 from bookmarks.validators import BookmarkURLValidator
@@ -6,6 +7,10 @@ from bookmarks.type_defs import HttpRequest
 from bookmarks.services.bookmarks import create_bookmark, update_bookmark
 
 
+class CustomErrorList(ErrorList):
+    template_name = "shared/error_list.html"
+
+
 class BookmarkForm(forms.ModelForm):
     # Use URLField for URL
     url = forms.CharField(validators=[BookmarkURLValidator()])
@@ -48,7 +53,9 @@ class BookmarkForm(forms.ModelForm):
         if instance is not None and request.method == "GET":
             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)
+        super().__init__(
+            data, instance=instance, initial=initial, error_class=CustomErrorList
+        )
 
     @property
     def is_auto_close(self):
diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js
index 7221582..1e0c245 100644
--- a/bookmarks/frontend/behaviors/tag-autocomplete.js
+++ b/bookmarks/frontend/behaviors/tag-autocomplete.js
@@ -19,6 +19,7 @@ class TagAutocomplete extends Behavior {
         name: input.name,
         value: input.value,
         placeholder: input.getAttribute("placeholder") || "",
+        ariaDescribedBy: input.getAttribute("aria-describedby") || "",
         variant: input.getAttribute("variant"),
       },
     });
diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte
index 8214eca..7a7b95d 100644
--- a/bookmarks/frontend/components/TagAutocomplete.svelte
+++ b/bookmarks/frontend/components/TagAutocomplete.svelte
@@ -6,6 +6,7 @@
   export let name;
   export let value;
   export let placeholder;
+  export let ariaDescribedBy;
   export let variant = 'default';
 
   let isFocus = false;
@@ -110,6 +111,7 @@
     <!-- autocomplete real input box -->
     <input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
            class="form-input" type="text" autocomplete="off" autocapitalize="off"
+           aria-describedby="{ariaDescribedBy}"
            on:input={handleInput} on:keydown={handleKeyDown}
            on:focus={handleFocus} on:blur={handleBlur}>
   </div>
diff --git a/bookmarks/services/bundles.py b/bookmarks/services/bundles.py
new file mode 100644
index 0000000..402723e
--- /dev/null
+++ b/bookmarks/services/bundles.py
@@ -0,0 +1,37 @@
+from django.db.models import Max
+
+from bookmarks.models import BookmarkBundle, User
+
+
+def create_bundle(bundle: BookmarkBundle, current_user: User):
+    bundle.owner = current_user
+    if bundle.order is None:
+        max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
+            Max("order", default=-1)
+        )
+        bundle.order = max_order_result["order__max"] + 1
+    bundle.save()
+    return bundle
+
+
+def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
+    user_bundles = list(
+        BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
+    )
+
+    if new_order != user_bundles.index(bundle_to_move):
+        user_bundles.remove(bundle_to_move)
+        user_bundles.insert(new_order, bundle_to_move)
+        for bundle_index, bundle in enumerate(user_bundles):
+            bundle.order = bundle_index
+
+        BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
+
+
+def delete_bundle(bundle: BookmarkBundle):
+    bundle.delete()
+
+    user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
+    for index, user_bundle in enumerate(user_bundles):
+        user_bundle.order = index
+    BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
diff --git a/bookmarks/settings/base.py b/bookmarks/settings/base.py
index 559ef8d..563337d 100644
--- a/bookmarks/settings/base.py
+++ b/bookmarks/settings/base.py
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
 
 USE_I18N = True
 
-USE_L10N = True
-
 USE_TZ = True
 
 # Static files (CSS, JavaScript, Images)
diff --git a/bookmarks/styles/theme/forms.css b/bookmarks/styles/theme/forms.css
index 0a5b6ae..6be8773 100644
--- a/bookmarks/styles/theme/forms.css
+++ b/bookmarks/styles/theme/forms.css
@@ -224,12 +224,13 @@ textarea.form-input {
   position: relative;
 
   input {
-    clip: rect(0, 0, 0, 0);
-    height: 1px;
-    margin: -1px;
-    overflow: hidden;
+    opacity: 0;
     position: absolute;
-    width: 1px;
+    top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
+    left: 0;
+    height: var(--control-icon-size);
+    width: var(--control-icon-size);
+    cursor: pointer;
 
     &:focus-visible + .form-icon {
       outline: var(--focus-outline);
@@ -243,9 +244,9 @@ textarea.form-input {
   }
 
   .form-icon {
+    pointer-events: none;
     border: var(--border-width) solid var(--checkbox-border-color);
     box-shadow: var(--input-box-shadow);
-    cursor: pointer;
     display: inline-block;
     position: absolute;
     transition:
diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html
index 309dfc5..542ea59 100644
--- a/bookmarks/templates/bookmarks/form.html
+++ b/bookmarks/templates/bookmarks/form.html
@@ -1,5 +1,6 @@
 {% load widget_tweaks %}
 {% load static %}
+{% load shared %}
 
 <div class="bookmarks-form">
   {% csrf_token %}
@@ -7,7 +8,7 @@
   <div class="form-group {% if form.url.errors %}has-error{% endif %}">
     <label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
     <div class="has-icon-right">
-      {{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
+      {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
       <i class="form-icon loading"></i>
     </div>
     {% if form.url.errors %}
@@ -22,8 +23,8 @@
   </div>
   <div class="form-group" ld-tag-autocomplete>
     <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
-    {{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
-    <div class="form-input-hint">
+    {{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+    <div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
       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>
@@ -35,7 +36,8 @@
       <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
       <div class="flex">
         <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
-        <button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button"
+        <button ld-clear-button data-for="{{ form.title.id_for_label }}"
+                class="ml-2 btn btn-link suffix-button clear-button"
                 type="button">Clear
         </button>
       </div>
@@ -60,31 +62,31 @@
         <span class="form-label d-inline-block">Notes</span>
       </summary>
       <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
-      {{ form.notes|add_class:"form-input"|attr:"rows:8" }}
-      <div class="form-input-hint">
+      {{ 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>
     </details>
     {{ form.notes.errors }}
   </div>
   <div class="form-group">
-    <label for="{{ form.unread.id_for_label }}" class="form-checkbox">
-      {{ form.unread }}
+    <div class="form-checkbox">
+      {{ form.unread|form_field:"help" }}
       <i class="form-icon"></i>
-      <span>Mark as unread</span>
-    </label>
-    <div class="form-input-hint">
+      <label for="{{ form.unread.id_for_label }}">Mark as unread</label>
+    </div>
+    <div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
       Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
     </div>
   </div>
   {% if request.user_profile.enable_sharing %}
     <div class="form-group">
-      <label for="{{ form.shared.id_for_label }}" class="form-checkbox">
-        {{ form.shared }}
+      <div class="form-checkbox">
+        {{ form.shared|form_field:"help" }}
         <i class="form-icon"></i>
-        <span>Share</span>
-      </label>
-      <div class="form-input-hint">
+        <label for="{{ form.shared.id_for_label }}">Share</label>
+      </div>
+      <div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
         {% if request.user_profile.enable_public_sharing %}
           Share this bookmark with other registered users and anonymous users.
         {% else %}
@@ -100,7 +102,7 @@
     {% else %}
       <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
     {% endif %}
-    <a href="{{ return_url }}" class="btn">Nevermind</a>
+    <a href="{{ return_url }}" class="btn">Cancel</a>
   </div>
   <script type="application/javascript">
     /**
@@ -227,6 +229,7 @@
             }
           });
       }
+
       refreshButton.addEventListener('click', refreshMetadata);
 
       // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
diff --git a/bookmarks/templates/shared/error_list.html b/bookmarks/templates/shared/error_list.html
new file mode 100644
index 0000000..13e75ac
--- /dev/null
+++ b/bookmarks/templates/shared/error_list.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+
+{# Force rendering validation errors in English language to align with the rest of the app #}
+{% language 'en-us' %}
+{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
+{% endlanguage %}
\ No newline at end of file
diff --git a/bookmarks/templatetags/shared.py b/bookmarks/templatetags/shared.py
index 297c453..b067232 100644
--- a/bookmarks/templatetags/shared.py
+++ b/bookmarks/templatetags/shared.py
@@ -145,3 +145,30 @@ def render_markdown(context, markdown_text):
     linkified_html = bleach.linkify(sanitized_html)
 
     return mark_safe(linkified_html)
+
+
+def append_attr(widget, attr, value):
+    attrs = widget.attrs
+    if attrs.get(attr):
+        attrs[attr] += " " + value
+    else:
+        attrs[attr] = value
+
+
+@register.filter("form_field")
+def form_field(field, modifier_string):
+    modifiers = modifier_string.split(",")
+    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 field.field.required and not has_errors:
+        append_attr(field.field.widget, "aria-invalid", "false")
+
+    return field
diff --git a/bookmarks/views/bundles.py b/bookmarks/views/bundles.py
index 062db8d..52ae2a4 100644
--- a/bookmarks/views/bundles.py
+++ b/bookmarks/views/bundles.py
@@ -1,11 +1,11 @@
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
-from django.db.models import Max
 from django.http import HttpRequest, HttpResponseRedirect
 from django.shortcuts import render
 from django.urls import reverse
 
 from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
+from bookmarks.services import bundles
 from bookmarks.views import access
 from bookmarks.views.contexts import ActiveBookmarkListContext
 
@@ -23,24 +23,14 @@ def action(request: HttpRequest):
         remove_bundle_id = request.POST.get("remove_bundle")
         bundle = access.bundle_write(request, remove_bundle_id)
         bundle_name = bundle.name
-        bundle.delete()
+        bundles.delete_bundle(bundle)
         messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
 
     elif "move_bundle" in request.POST:
         bundle_id = request.POST.get("move_bundle")
-        move_position = int(request.POST.get("move_position"))
         bundle_to_move = access.bundle_write(request, bundle_id)
-        user_bundles = list(
-            BookmarkBundle.objects.filter(owner=request.user).order_by("order")
-        )
-
-        if move_position != user_bundles.index(bundle_to_move):
-            user_bundles.remove(bundle_to_move)
-            user_bundles.insert(move_position, bundle_to_move)
-            for bundle_index, bundle in enumerate(user_bundles):
-                bundle.order = bundle_index
-
-            BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
+        move_position = int(request.POST.get("move_position"))
+        bundles.move_bundle(bundle_to_move, move_position)
 
     return HttpResponseRedirect(reverse("linkding:bundles.index"))
 
@@ -52,15 +42,13 @@ def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = N
     if request.method == "POST":
         if form.is_valid():
             instance = form.save(commit=False)
-            instance.owner = request.user
 
-            if bundle is None:  # New bundle
-                max_order_result = BookmarkBundle.objects.filter(
-                    owner=request.user
-                ).aggregate(Max("order", default=-1))
-                instance.order = max_order_result["order__max"] + 1
+            if bundle is None:
+                instance.order = None
+                bundles.create_bundle(instance, request.user)
+            else:
+                instance.save()
 
-            instance.save()
             messages.success(request, "Bundle saved successfully.")
             return HttpResponseRedirect(reverse("linkding:bundles.index"))
 
diff --git a/docs/src/content/docs/community.md b/docs/src/content/docs/community.md
index c7b5deb..593761d 100644
--- a/docs/src/content/docs/community.md
+++ b/docs/src/content/docs/community.md
@@ -29,5 +29,6 @@ This section lists community projects around using linkding, in alphabetical ord
 - [linktiles](https://github.com/haondt/linktiles) A web app that displays your links as tiles in a configurable mosaic. By [haondt](https://github.com/haondt)
 - [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
 - [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
+- [Pocket2Linkding](https://github.com/hkclark/Pocket2Linkding/) A tool to migrate from Mozilla Pocket to lingding. Preserves the date the link was added to pocket and any tags.
 - [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
 - [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen)
diff --git a/docs/src/content/docs/managed-hosting.md b/docs/src/content/docs/managed-hosting.md
index 1dd30e9..7c1fddb 100644
--- a/docs/src/content/docs/managed-hosting.md
+++ b/docs/src/content/docs/managed-hosting.md
@@ -10,6 +10,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
 The following services provide fully managed hosting for linkding, including automatic updates and backups:
 
 - [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](/acknowledgements#pikapods))
+- [CloudBreak](https://cloudbreak.app/products/linkding/?utm_medium=referral&utm_source=linkding-docs&utm_content=managed-hosting&rby=linkding-docs-managed-hosting) - Managed hosting for linkding, US regions available.
 
 ## Self-Managed
 
diff --git a/requirements.dev.txt b/requirements.dev.txt
index 74f1c29..92aa7aa 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -12,9 +12,9 @@ click==8.1.7
     # via black
 coverage==7.6.1
     # via -r requirements.dev.in
-django==5.1.10
+django==5.2.3
     # via django-debug-toolbar
-django-debug-toolbar==4.4.6
+django-debug-toolbar==5.2.0
     # via -r requirements.dev.in
 execnet==2.1.1
     # via pytest-xdist
diff --git a/requirements.txt b/requirements.txt
index 9379244..f95aa0e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
-#    pip-compile requirements.in
+#    pip-compile
 #
 asgiref==3.8.1
     # via django
@@ -27,7 +27,7 @@ cryptography==43.0.1
     #   josepy
     #   mozilla-django-oidc
     #   pyopenssl
-django==5.1.10
+django==5.2.3
     # via
     #   -r requirements.in
     #   django-registration


Test patch:
diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py
index 0275442..bec1ee4 100644
--- a/bookmarks/tests/test_bookmark_edit_view.py
+++ b/bookmarks/tests/test_bookmark_edit_view.py
@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             f"""
-            <input type="text" name="url" value="{bookmark.url}" placeholder=" "
-                    autofocus class="form-input" required id="id_url">   
-        """,
+            <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
+            """,
             html,
         )
 
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
         self.assertInHTML(
             f"""
             <input type="text" name="tag_string" value="{tag_string}" 
-                    autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
+                    autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string" aria-describedby="id_tag_string_help">
         """,
             html,
         )
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             f"""
-            <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
+            <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">
                 {bookmark.notes}
             </textarea>
         """,
@@ -259,12 +258,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>
+            """,
             html,
             count=0,
         )
@@ -278,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>
+            """,
             html,
             count=1,
         )
diff --git a/bookmarks/tests/test_bookmark_new_view.py b/bookmarks/tests/test_bookmark_new_view.py
index 5e73d83..c4eaca3 100644
--- a/bookmarks/tests/test_bookmark_new_view.py
+++ b/bookmarks/tests/test_bookmark_new_view.py
@@ -78,9 +78,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="text" name="url" value="http://example.com" '
-            'placeholder=" " autofocus class="form-input" required '
-            'id="id_url">',
+            """
+            <input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
+            """,
             html,
         )
 
@@ -117,9 +117,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="text" name="tag_string" value="tag1 tag2 tag3" '
-            'class="form-input" autocomplete="off" autocapitalize="off" '
-            'id="id_tag_string">',
+            """
+            <input type="text" name="tag_string" value="tag1 tag2 tag3" 
+                aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
+            """,
             html,
         )
 
@@ -137,8 +138,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
                     <span class="form-label d-inline-block">Notes</span>
                 </summary>
                 <label for="id_notes" class="text-assistive">Notes</label>
-                <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
-                <div class="form-input-hint">
+                <textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">**Find** more info [here](http://example.com)</textarea>
+                <div id="id_notes_help" class="form-input-hint">
                     Additional notes, supports Markdown.
                 </div>
             </details>
@@ -196,12 +197,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>          
+            """,
             html,
             count=0,
         )
@@ -213,12 +214,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertInHTML(
             """
-            <label for="id_shared" class="form-checkbox">
-              <input type="checkbox" name="shared" id="id_shared">
-              <i class="form-icon"></i>
-              <span>Share</span>
-            </label>            
-        """,
+            <div class="form-checkbox">
+                <input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
+                <i class="form-icon"></i>
+                <label for="id_shared">Share</label>
+            </div>              
+            """,
             html,
             count=1,
         )
@@ -231,10 +232,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
         self.assertInHTML(
             """
-          <div class="form-input-hint">
-              Share this bookmark with other registered users.
-          </div>
-        """,
+              <div id="id_shared_help" class="form-input-hint">
+                  Share this bookmark with other registered users.
+              </div>
+            """,
             html,
         )
 
@@ -245,10 +246,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
         self.assertInHTML(
             """
-          <div class="form-input-hint">
-              Share this bookmark with other registered users and anonymous users.
-          </div>
-        """,
+              <div id="id_shared_help" class="form-input-hint">
+                  Share this bookmark with other registered users and anonymous users.
+              </div>
+            """,
             html,
         )
 
@@ -265,7 +266,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="checkbox" name="unread" id="id_unread">',
+            '<input type="checkbox" name="unread" id="id_unread" aria-describedby="id_unread_help">',
             html,
         )
 
@@ -277,6 +278,6 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
         html = response.content.decode()
 
         self.assertInHTML(
-            '<input type="checkbox" name="unread" id="id_unread" checked="">',
+            '<input type="checkbox" name="unread" id="id_unread" checked="" aria-describedby="id_unread_help">',
             html,
         )
diff --git a/bookmarks/tests/test_bundles_api.py b/bookmarks/tests/test_bundles_api.py
index 3a14b01..2446f27 100644
--- a/bookmarks/tests/test_bundles_api.py
+++ b/bookmarks/tests/test_bundles_api.py
@@ -269,6 +269,24 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
 
         self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
 
+    def test_delete_bundle_updates_order(self):
+        self.authenticate()
+
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+        bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle2.id})
+        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
+
+        self.assertFalse(BookmarkBundle.objects.filter(id=bundle2.id).exists())
+
+        # Check that the remaining bundles have updated orders
+        bundle1.refresh_from_db()
+        bundle3.refresh_from_db()
+        self.assertEqual(bundle1.order, 0)
+        self.assertEqual(bundle3.order, 1)
+
     def test_delete_bundle_only_allows_own_bundles(self):
         self.authenticate()
 
diff --git a/bookmarks/tests/test_bundles_index_view.py b/bookmarks/tests/test_bundles_index_view.py
index 57d19f3..e4dff8e 100644
--- a/bookmarks/tests/test_bundles_index_view.py
+++ b/bookmarks/tests/test_bundles_index_view.py
@@ -100,6 +100,18 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
 
         self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
 
+    def test_remove_bundle_updates_order(self):
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+        bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+        self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(bundle2.id)},
+        )
+
+        self.assertBundleOrder([bundle1, bundle3])
+
     def test_remove_other_user_bundle(self):
         other_user = self.setup_user(name="otheruser")
         other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
diff --git a/bookmarks/tests/test_settings_export_view.py b/bookmarks/tests/test_settings_export_view.py
index 4837025..9593bd9 100644
--- a/bookmarks/tests/test_settings_export_view.py
+++ b/bookmarks/tests/test_settings_export_view.py
@@ -79,10 +79,12 @@ class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
 
     def test_filename_includes_date_and_time(self):
         self.setup_bookmark()
-        
+
         # Mock timezone.now to return a fixed datetime for predictable filename
-        fixed_time = datetime.datetime(2023, 5, 15, 14, 30, 45, tzinfo=datetime.timezone.utc)
-        
+        fixed_time = datetime.datetime(
+            2023, 5, 15, 14, 30, 45, tzinfo=datetime.timezone.utc
+        )
+
         with patch("bookmarks.views.settings.timezone.now", return_value=fixed_time):
             response = self.client.get(reverse("linkding:settings.export"), follow=True)
 
diff --git a/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py b/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py
index c36e56f..4aee919 100644
--- a/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py
+++ b/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py
@@ -124,7 +124,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
             # Cancel edit, verify return to details url
             details_url = url + f"&details={bookmark.id}"
             with self.page.expect_navigation(url=self.live_server_url + details_url):
-                self.page.get_by_text("Nevermind").click()
+                self.page.get_by_text("Cancel").click()
 
     def test_delete(self):
         bookmark = self.setup_bookmark()