Compare commits

...

6 Commits

Author SHA1 Message Date
Alexander Lehmann
dfbba20275 Make init command initialize data dir (#1292) 2026-01-25 18:27:11 +01:00
Sascha Ißbrücker
f67c4605fd Fix URL not updating on tag search 2026-01-25 11:44:48 +01:00
Sascha Ißbrücker
1f0a2201ba Preserve page and scroll position when editing tags (#1291) 2026-01-25 11:07:30 +01:00
Sascha Ißbrücker
d52caefe2c Add dev tool for quickly switching profile settings 2026-01-11 22:40:06 +01:00
Sascha Ißbrücker
c998dd35b7 Update docs 2026-01-07 21:04:04 +01:00
Sascha Ißbrücker
397eb6d316 Update CHANGELOG.md 2026-01-06 21:35:04 +01:00
20 changed files with 511 additions and 64 deletions

View File

@@ -1,5 +1,58 @@
# Changelog
## v1.45.0 (06/01/2026)
### What's Changed
* API token management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1248
* Add option to disable login form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1269
* Turn scheme-less URLs into HTTPS instead of HTTP links by @Maaxxs in https://github.com/sissbruecker/linkding/pull/1225
* Disable bulk execute button when no bookmarks selected by @emanuelebeffa in https://github.com/sissbruecker/linkding/pull/1241
* Add option to run supervisor as main process by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1270
* Allow setting date_added and date_modified for new bookmarks through REST API by @jmason in https://github.com/sissbruecker/linkding/pull/1063
* Download PDF instead of creating HTML snapshot if URL points at PDF by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1271
* Allow sandboxed scripts when viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1252
* Allow viewing video assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1259
* Remove absolute URIs from settings page by @packrat386 in https://github.com/sissbruecker/linkding/pull/1261
* Move tag management forms into dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1253
* Move bulk edit checkboxes into bookmark list container by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1257
* Remove registration switch by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1268
* Add linkdinger to community projects by @lmmendes in https://github.com/sissbruecker/linkding/pull/1266
### New Contributors
* @packrat386 made their first contribution in https://github.com/sissbruecker/linkding/pull/1261
* @lmmendes made their first contribution in https://github.com/sissbruecker/linkding/pull/1266
* @Maaxxs made their first contribution in https://github.com/sissbruecker/linkding/pull/1225
* @emanuelebeffa made their first contribution in https://github.com/sissbruecker/linkding/pull/1241
* @jmason made their first contribution in https://github.com/sissbruecker/linkding/pull/1063
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.2...v1.45.0
---
## v1.44.2 (13/12/2025)
### What's Changed
> [!WARNING]
> *This resolves a [security vulnerability](https://github.com/sissbruecker/linkding/security/advisories/GHSA-3pf9-5cjv-2w7q) in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
* Use sandbox CSP for viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1245
* Fix devcontainer by @m3eno in https://github.com/sissbruecker/linkding/pull/1208
* Fix tag cloud highlighting first char when tags are not grouped by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1209
* Bump supervisor to 4.3.0 to fix warning by @simonhammes in https://github.com/sissbruecker/linkding/pull/1216
* Added Javascript client and library for Linkding REST API by @vbsampath in https://github.com/sissbruecker/linkding/pull/1195
* Add Komrade project to community resources by @dev-inside in https://github.com/sissbruecker/linkding/pull/1236
### New Contributors
* @m3eno made their first contribution in https://github.com/sissbruecker/linkding/pull/1208
* @vbsampath made their first contribution in https://github.com/sissbruecker/linkding/pull/1195
* @dev-inside made their first contribution in https://github.com/sissbruecker/linkding/pull/1236
* @simonhammes made their first contribution in https://github.com/sissbruecker/linkding/pull/1216
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.1...v1.44.2
---
## v1.44.1 (11/10/2025)
### What's Changed

View File

@@ -2,6 +2,7 @@
init:
uv sync
[ -d data ] || mkdir data data/assets data/favicons data/previews
uv run manage.py migrate
npm install
@@ -34,4 +35,4 @@ e2e:
uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
frontend:
npm run dev
npm run dev

View File

@@ -0,0 +1,257 @@
import { LitElement, html, css } from "lit";
class DevTool extends LitElement {
static properties = {
profile: { type: Object, state: true },
formAction: { type: String, attribute: "data-form-action" },
csrfToken: { type: String, attribute: "data-csrf-token" },
isOpen: { type: Boolean, state: true },
};
static styles = css`
:host {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 10000;
}
.button {
background: var(--btn-primary-bg-color);
color: var(--btn-primary-text-color);
border: none;
padding: var(--unit-2);
border-radius: var(--border-radius);
box-shadow: var(--btn-box-shadow);
cursor: pointer;
height: auto;
line-height: 0;
}
.overlay {
display: none;
position: absolute;
bottom: 100%;
right: 0;
background: var(--body-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--unit-2);
margin-bottom: var(--unit-2);
min-width: 220px;
box-shadow: var(--box-shadow-lg);
font-size: var(--font-size-sm);
}
:host([open]) .overlay {
display: block;
}
h3 {
margin: 0 0 var(--unit-2) 0;
}
label {
display: flex;
align-items: center;
gap: var(--unit-1);
cursor: pointer;
}
label:has(select) {
margin-bottom: var(--unit-1);
}
label:has(select) span {
min-width: 100px;
}
hr {
margin: var(--unit-2) 0;
border: none;
border-top: 1px solid var(--border-color);
}
`;
static fields = [
{
type: "select",
key: "theme",
label: "Theme",
options: [
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
],
},
{
type: "select",
key: "bookmark_date_display",
label: "Date",
options: [
{ value: "relative", label: "Relative" },
{ value: "absolute", label: "Absolute" },
{ value: "hidden", label: "Hidden" },
],
},
{
type: "select",
key: "bookmark_description_display",
label: "Description",
options: [
{ value: "inline", label: "Inline" },
{ value: "separate", label: "Separate" },
],
},
{ type: "checkbox", key: "enable_favicons", label: "Favicons" },
{ type: "checkbox", key: "enable_preview_images", label: "Preview images" },
{ type: "checkbox", key: "display_url", label: "Display URL" },
{ type: "checkbox", key: "permanent_notes", label: "Permanent notes" },
{ type: "checkbox", key: "collapse_side_panel", label: "Collapse sidebar" },
{ type: "checkbox", key: "sticky_pagination", label: "Sticky pagination" },
{ type: "checkbox", key: "hide_bundles", label: "Hide bundles" },
];
constructor() {
super();
this.isOpen = false;
this.profile = {};
this._onOutsideClick = this._onOutsideClick.bind(this);
}
connectedCallback() {
super.connectedCallback();
const profileData = document.getElementById("json_profile");
this.profile = JSON.parse(profileData.textContent || "{}");
document.addEventListener("click", this._onOutsideClick);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("click", this._onOutsideClick);
}
_onOutsideClick(e) {
if (!this.contains(e.target) && this.isOpen) {
this.isOpen = false;
this.removeAttribute("open");
}
}
_toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.setAttribute("open", "");
} else {
this.removeAttribute("open");
}
}
_handleChange(key, value) {
this.profile = { ...this.profile, [key]: value };
if (key === "theme") {
const themeLinks = document.head.querySelectorAll('link[href*="theme"]');
themeLinks.forEach((link) => link.remove());
}
this._submitForm();
}
_renderField(field) {
switch (field.type) {
case "checkbox":
return html`
<label>
<input
type="checkbox"
.checked=${this.profile[field.key] || false}
@change=${(e) => this._handleChange(field.key, e.target.checked)}
/>
${field.label}
</label>
`;
case "select":
return html`
<label>
<span>${field.label}:</span>
<select
@change=${(e) => this._handleChange(field.key, e.target.value)}
>
${field.options.map(
(opt) => html`
<option
value=${opt.value}
?selected=${this.profile[field.key] === opt.value}
>
${opt.label}
</option>
`,
)}
</select>
</label>
`;
case "divider":
return html`<hr />`;
default:
return null;
}
}
async _submitForm() {
const formData = new FormData();
formData.append("csrfmiddlewaretoken", this.csrfToken);
// Profile fields
for (const [key, value] of Object.entries(this.profile)) {
if (typeof value === "boolean" && value) {
formData.append(key, "on");
} else if (typeof value !== "boolean") {
formData.append(key, value);
}
}
// Submit button name that settings.update expects
formData.append("update_profile", "1");
await fetch(this.formAction, {
method: "POST",
body: formData,
});
const url = new URL(window.location);
url.searchParams.set("ts", Date.now().toString());
window.history.replaceState({}, "", url);
Turbo.visit(url.toString());
}
render() {
return html`
<button class="button" @click=${() => this._toggle()}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065"
/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>
</button>
<div class="overlay">
<h3>Dev Tools</h3>
${DevTool.fields.map((field) => this._renderField(field))}
</div>
`;
}
}
customElements.define("ld-dev-tool", DevTool);

View File

@@ -3,6 +3,7 @@ import "./components/bookmark-page.js";
import "./components/clear-button.js";
import "./components/confirm-dropdown.js";
import "./components/details-modal.js";
import "./components/dev-tool.js";
import "./components/dropdown.js";
import "./components/filter-drawer.js";
import "./components/form.js";

View File

@@ -3,6 +3,8 @@
<div>No bookmarks match the current bundle.</div>
{% else %}
<div class="mb-4">Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.</div>
{% include 'bookmarks/bookmark_list.html' %}
{% with pagination_frame="preview" %}
{% include 'bookmarks/bookmark_list.html' %}
{% endwith %}
{% endif %}
</turbo-frame>

View File

@@ -0,0 +1,9 @@
{% load shared %}
{% if debug and request.user.is_authenticated %}
{{ request.user.profile|model_to_dict|json_script:"json_profile" }}
<ld-dev-tool id="dev-tool"
data-csrf-token="{{ csrf_token }}"
data-form-action="{% url 'linkding:settings.update' %}"
data-turbo-permanent>
</ld-dev-tool>
{% endif %}

View File

@@ -46,5 +46,6 @@
<div class="modals">
{% block overlays %}{% endblock %}
</div>
{% include 'shared/dev_tool.html' %}
</body>
</html>

View File

@@ -2,7 +2,9 @@
<ul class="pagination">
{% if prev_link %}
<li class="page-item">
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
<a href="{{ prev_link }}"
tabindex="-1"
data-turbo-frame="{{ pagination_frame }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
@@ -12,7 +14,7 @@
{% for page_link in page_links %}
{% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}">
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
<a href="{{ page_link.link }}" data-turbo-frame="{{ pagination_frame }}">{{ page_link.number }}</a>
</li>
{% else %}
<li class="page-item">
@@ -22,7 +24,9 @@
{% endfor %}
{% if next_link %}
<li class="page-item">
<a href="{{ next_link }}" tabindex="-1">Next</a>
<a href="{{ next_link }}"
tabindex="-1"
data-turbo-frame="{{ pagination_frame }}">Next</a>
</li>
{% else %}
<li class="page-item disabled">

View File

@@ -1,11 +1,11 @@
<turbo-frame id="tag-modal">
<form method="post"
action="{% url 'linkding:tags.edit' tag.id %}"
data-turbo-frame="_top"
action="{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}"
data-turbo-frame="tag-main"
novalidate>
{% csrf_token %}
<ld-modal class="modal tag-edit-modal active"
data-close-url="{% url 'linkding:tags.index' %}"
data-close-url="{% url 'linkding:tags.index' %}?{{ request.GET.urlencode }}"
data-turbo-frame="tag-modal">
<div class="modal-overlay" data-close-modal></div>
<div class="modal-container" role="dialog" aria-modal="true">

View File

@@ -5,6 +5,7 @@
{% endblock %}
{% block content %}
<div class="tags-page crud-page">
<turbo-frame id="tag-main">
<main aria-labelledby="main-heading">
<div class="crud-header">
<h1 id="main-heading">Tags</h1>
@@ -21,7 +22,7 @@
{# Filters #}
<div class="crud-filters">
<ld-form data-form-reset>
<form method="get" class="mb-2">
<form method="get" class="mb-2" data-turbo-frame="_top">
<div class="form-group">
<label class="form-label text-assistive" for="search">Search tags</label>
<div class="input-group">
@@ -97,7 +98,7 @@
</td>
<td class="actions">
<a class="btn btn-link"
href="{% url 'linkding:tags.edit' tag.id %}"
href="{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}"
data-turbo-frame="tag-modal">Edit</a>
<button type="submit"
name="delete_tag"
@@ -123,6 +124,7 @@
</div>
{% endif %}
</main>
<turbo-frame id="tag-modal"></turbo-frame>
</turbo-frame>
</div>
<turbo-frame id="tag-modal"></turbo-frame>
{% endblock %}

View File

@@ -12,6 +12,7 @@ register = template.Library()
@register.inclusion_tag("shared/pagination.html", name="pagination", takes_context=True)
def pagination(context, page: Page):
request = context["request"]
pagination_frame = context.get("pagination_frame", "_top")
base_url = request.path
# remove page number and details from query parameters
@@ -51,6 +52,7 @@ def pagination(context, page: Page):
"prev_link": prev_link,
"next_link": next_link,
"page_links": page_links,
"pagination_frame": pagination_frame,
}

View File

@@ -5,6 +5,7 @@ import markdown
from bleach.linkifier import DEFAULT_CALLBACKS, Linker
from bleach_allowlist import markdown_attrs, markdown_tags
from django import template
from django.forms.models import model_to_dict
from django.utils.safestring import mark_safe
from bookmarks import utils
@@ -60,6 +61,12 @@ def humanize_relative_date(value):
return utils.humanize_relative_date(value)
@register.filter(name="model_to_dict")
def model_to_dict_filter(value):
result = model_to_dict(value)
return result
@register.tag
def htmlmin(parser, token):
nodelist = parser.parse(("endhtmlmin",))

View File

@@ -7,7 +7,12 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class PaginationTagTest(TestCase, BookmarkFactoryMixin):
def render_template(
self, num_items: int, page_size: int, current_page: int, url: str = "/test"
self,
num_items: int,
page_size: int,
current_page: int,
url: str = "/test",
frame: str = None,
) -> str:
rf = RequestFactory()
request = rf.get(url)
@@ -16,7 +21,11 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
paginator = Paginator(range(0, num_items), page_size)
page = paginator.page(current_page)
context = RequestContext(request, {"page": page})
context_dict = {"page": page}
if frame:
context_dict["pagination_frame"] = frame
context = RequestContext(request, context_dict)
template_to_render = Template("{% load pagination %}{% pagination page %}")
return template_to_render.render(context)
@@ -30,12 +39,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
html,
)
def assertPrevLink(self, html: str, page_number: int, href: str = None):
def assertPrevLink(
self, html: str, page_number: int, href: str = None, frame: str = "_top"
):
href = href if href else f"/test?page={page_number}"
self.assertInHTML(
f"""
<li class="page-item">
<a href="{href}" tabindex="-1">Previous</a>
<a href="{href}" tabindex="-1" data-turbo-frame="{frame}">Previous</a>
</li>
""",
html,
@@ -51,12 +62,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
html,
)
def assertNextLink(self, html: str, page_number: int, href: str = None):
def assertNextLink(
self, html: str, page_number: int, href: str = None, frame: str = "_top"
):
href = href if href else f"/test?page={page_number}"
self.assertInHTML(
f"""
<li class="page-item">
<a href="{href}" tabindex="-1">Next</a>
<a href="{href}" tabindex="-1" data-turbo-frame="{frame}">Next</a>
</li>
""",
html,
@@ -69,13 +82,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
active: bool,
count: int = 1,
href: str = None,
frame: str = "_top",
):
active_class = "active" if active else ""
href = href if href else f"/test?page={page_number}"
self.assertInHTML(
f"""
<li class="page-item {active_class}">
<a href="{href}">{page_number}</a>
<a href="{href}" data-turbo-frame="{frame}">{page_number}</a>
</li>
""",
html,
@@ -188,3 +202,10 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
self.assertPageLink(rendered_template, 1, False, href="/test?page=1")
self.assertPageLink(rendered_template, 2, True, href="/test?page=2")
self.assertNextLink(rendered_template, 3, href="/test?page=3")
def test_respects_pagination_frame(self):
rendered_template = self.render_template(100, 10, 2, frame="my_frame")
self.assertPrevLink(rendered_template, 1, frame="my_frame")
self.assertPageLink(rendered_template, 1, False, frame="my_frame")
self.assertPageLink(rendered_template, 2, True, frame="my_frame")
self.assertNextLink(rendered_template, 3, frame="my_frame")

View File

@@ -90,20 +90,17 @@ class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag2.refresh_from_db()
self.assertEqual(tag2.name, "tag1")
def test_update_shows_success_message(self):
def test_update_tag_preserves_query_parameters(self):
tag = self.setup_tag(name="old_name")
response = self.client.post(
reverse("linkding:tags.edit", args=[tag.id]),
{"name": "new_name"},
follow=True,
url = (
reverse("linkding:tags.edit", args=[tag.id])
+ "?search=search&unused=true&page=2&sort=name-desc"
)
response = self.client.post(url, {"name": "new_name"})
self.assertInHTML(
"""
<div class="toast toast-success" role="alert">
Tag "new_name" updated successfully.
</div>
""",
response.content.decode(),
expected_redirect = (
reverse("linkding:tags.index")
+ "?search=search&unused=true&page=2&sort=name-desc"
)
self.assertRedirects(response, expected_redirect)

View File

@@ -153,24 +153,6 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertRedirects(response, reverse("linkding:tags.index"))
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
def test_tag_delete_action_shows_success_message(self):
tag = self.setup_tag(name="tag_to_delete")
response = self.client.post(
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertInHTML(
"""
<div class="toast toast-success" role="alert">
Tag "tag_to_delete" deleted successfully.
</div>
""",
response.content.decode(),
)
def test_tag_delete_action_preserves_query_parameters(self):
tag = self.setup_tag(name="search_tag")

View File

@@ -113,9 +113,6 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
# Verify modal is closed
expect(modal).not_to_be_visible()
# Verify the success message is shown
self.verify_success_message('Tag "new-name" updated successfully.')
# Verify the updated tag is shown in the list
expect(self.locate_tag_row("new-name")).to_be_visible()
expect(self.locate_tag_row("old-name")).not_to_be_visible()
@@ -157,6 +154,89 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
tag.refresh_from_db()
self.assertEqual(tag.name, "tag-to-edit")
def test_edit_tag_preserves_query_and_scroll_position(self):
# Create enough tags to have multiple pages (50 per page)
for i in range(70):
self.setup_tag(name=f"test-tag-{i:02d}")
# Open tags page 2 with search query
url = reverse("linkding:tags.index") + "?search=test&page=2"
self.open(url)
# Verify we're on page 2
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
# Scroll down
self.page.evaluate("window.scrollTo(0, 300)")
initial_scroll = self.page.evaluate("window.scrollY")
self.assertGreater(initial_scroll, 0)
# Edit tag
tag_row = self.locate_tag_row("test-tag-55")
tag_row.get_by_role("link", name="Edit").click()
modal = self.locate_tag_modal()
name_input = modal.get_by_label("Name")
name_input.fill("test-tag-55-edited")
modal.get_by_text("Save").click()
expect(modal).not_to_be_visible()
# Verify query parameters and scroll position are preserved
current_url = self.page.url
self.assertIn("search=test", current_url)
self.assertIn("page=2", current_url)
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-55-edited")).to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
final_scroll = self.page.evaluate("window.scrollY")
self.assertEqual(initial_scroll, final_scroll)
def test_delete_tag_preserves_query_and_scroll_position(self):
# Create enough tags to have multiple pages (50 per page)
for i in range(70):
self.setup_tag(name=f"test-tag-{i:02d}")
# Open tags page 2 with search query
url = reverse("linkding:tags.index") + "?search=test&page=2"
self.open(url)
# Verify we're on page 2
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-55")).to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
# Scroll down
self.page.evaluate("window.scrollTo(0, 300)")
initial_scroll = self.page.evaluate("window.scrollY")
self.assertGreater(initial_scroll, 0)
# Delete tag
tag_row = self.locate_tag_row("test-tag-55")
tag_row.get_by_role("button", name="Remove").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Verify query parameters and scroll position are preserved
current_url = self.page.url
self.assertIn("search=test", current_url)
self.assertIn("page=2", current_url)
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
expect(self.locate_tag_row("test-tag-55")).not_to_be_visible()
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
final_scroll = self.page.evaluate("window.scrollY")
self.assertEqual(initial_scroll, final_scroll)
def test_merge_tags(self):
target_tag = self.setup_tag(name="target-tag")
merge_tag1 = self.setup_tag(name="merge-tag1")
@@ -254,3 +334,28 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
self.assertEqual(
Tag.objects.filter(owner=self.get_or_create_test_user()).count(), 2
)
def test_search_updates_url_query_params(self):
self.setup_tag(name="python")
self.setup_tag(name="javascript")
self.setup_tag(name="typescript")
self.open(reverse("linkding:tags.index"))
# Verify all tags are visible initially
expect(self.locate_tag_row("python")).to_be_visible()
expect(self.locate_tag_row("javascript")).to_be_visible()
expect(self.locate_tag_row("typescript")).to_be_visible()
# Enter search term and submit
search_input = self.page.get_by_placeholder("Search tags...")
search_input.fill("script")
self.page.get_by_role("button", name="Search").click()
# Wait for filtered results to appear
expect(self.locate_tag_row("python")).not_to_be_visible()
expect(self.locate_tag_row("javascript")).to_be_visible()
expect(self.locate_tag_row("typescript")).to_be_visible()
# Verify URL contains search query param
self.assertIn("search=script", self.page.url)

View File

@@ -10,6 +10,7 @@ from django.urls import reverse
from bookmarks.forms import TagForm, TagMergeForm
from bookmarks.models import Bookmark, Tag
from bookmarks.type_defs import HttpRequest
from bookmarks.utils import redirect_with_query
from bookmarks.views import turbo
@@ -18,15 +19,8 @@ def tags_index(request: HttpRequest):
if request.method == "POST" and "delete_tag" in request.POST:
tag_id = request.POST.get("delete_tag")
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
tag_name = tag.name
tag.delete()
messages.success(request, f'Tag "{tag_name}" deleted successfully.')
redirect_url = reverse("linkding:tags.index")
if request.GET:
redirect_url += "?" + request.GET.urlencode()
return HttpResponseRedirect(redirect_url)
return redirect_with_query(request, reverse("linkding:tags.index"))
search = request.GET.get("search", "").strip()
unused_only = request.GET.get("unused", "") == "true"
@@ -100,8 +94,7 @@ def tag_edit(request: HttpRequest, tag_id: int):
if request.method == "POST":
if form.is_valid():
form.save()
messages.success(request, f'Tag "{tag.name}" updated successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index"))
return redirect_with_query(request, reverse("linkding:tags.index"))
else:
return turbo.stream(
turbo.replace(

View File

@@ -11,9 +11,7 @@ Linkding can automatically create HTML snapshots whenever a bookmark is added. T
The snapshots are created using [singlefile-cli](https://github.com/gildas-lormeau/single-file-cli), which effectively runs a headless Chromium instance on the server to convert the web page into a single HTML file. Linkding will also load the [uBlock Origin Lite extension](https://github.com/uBlockOrigin/uBOL-home) into Chromium to attempt to block ads and other unwanted content.
<!--
When bookmarking a URL that points directly to a PDF file, linkding will download the PDF instead of creating an HTML snapshot. This happens automatically based on the content type of the URL, and the downloaded PDF will be stored as an asset alongside the bookmark, just like HTML snapshots.
-->
This method is fairly easy to set up, but also has several downsides:
- The Docker image is significantly larger than the base image, as it includes a Chromium installation.

View File

@@ -3,6 +3,17 @@ import terser from '@rollup/plugin-terser';
const production = !process.env.ROLLUP_WATCH;
// Custom plugin to exclude dev-tool.js from production builds
const excludeDevTool = {
name: 'exclude-dev-tool',
load(id) {
if (production && id.endsWith('dev-tool.js')) {
return '';
}
return null;
},
};
export default {
input: 'bookmarks/frontend/index.js',
output: {
@@ -13,6 +24,7 @@ export default {
file: 'bookmarks/static/bundle.js',
},
plugins: [
excludeDevTool,
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —

2
uv.lock generated
View File

@@ -455,7 +455,7 @@ wheels = [
[[package]]
name = "linkding"
version = "1.44.2"
version = "1.45.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },