Compare commits

..

19 Commits

Author SHA1 Message Date
Sascha Ißbrücker
cfe4ff113d Bump version 2025-02-22 19:28:47 +01:00
Sascha Ißbrücker
757dc56277 Bump base images 2025-02-19 16:14:34 +01:00
Sascha Ißbrücker
dfbb367857 Fix auth proxy logout (#994) 2025-02-19 07:27:04 +01:00
Sascha Ißbrücker
2276832465 Return web archive fallback URL from REST API (#993) 2025-02-19 06:44:21 +01:00
Chris M
9d61bdce52 Add note about OIDC and LD_SUPERUSER_NAME combination (#992)
* docs: add note about OIDC and LD_SUPERUSER_NAME combination

Resolves #988

* tweak text

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-02-18 22:45:26 +01:00
Sascha Ißbrücker
1274a9ae0a Try limit uwsgi memory usage by configuring file descriptor limit (#990) 2025-02-15 08:49:58 +01:00
Sascha Ißbrücker
5e7172d17e Remove preview image when bookmark is deleted (#989) 2025-02-15 08:26:58 +01:00
Sascha Ißbrücker
78608135d9 Update CHANGELOG.md 2025-02-09 10:47:02 +01:00
Sascha Ißbrücker
51acd1da3f add build script 2025-02-08 18:20:15 +01:00
Sascha Ißbrücker
016ff2da66 Bump version 2025-02-08 10:54:46 +01:00
Sascha Ißbrücker
77d7e6e66a Add RSS link to shared bookmarks page (#984) 2025-02-08 10:51:17 +01:00
Sascha Ißbrücker
c5a300a435 Convert tag modal into drawer (#977)
* change tag modal into drawer

* improve scroll handling

* teleport all side bar content

* improve naming

* fix focus trap in filter drawer
2025-02-02 20:42:28 +01:00
Sascha Ißbrücker
0d4c47eb81 Add option to collapse side panel (#975) 2025-02-02 11:28:35 +01:00
Sascha Ißbrücker
17442eeb9a Improve accessibility of modal dialogs (#974)
* improve details modal accessibility

* improve tag modal accessibility

* fix overlays in archive and shared pages

* update tests

* use buttons for closing dialogs

* replace description list

* hide preview image from screen readers

* update tests
2025-02-02 00:28:17 +01:00
Kyuuk
2973812626 Allow customizing username when creating user through OIDC (#971)
* add ability to cutomize claim user for username generation on oidc login

* update documentation with new OIDC options

* oidc: also normalize custom claim as username

* improve tests

* improve docs

* some more cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-01-30 03:40:52 +01:00
Josh Dick
fc48b266a8 Add Additional iOS Shortcut to community section (#968) 2025-01-28 13:29:44 +01:00
Sascha Ißbrücker
7b42241026 Fix nav menu closing on mousedown in Safari (#965) 2025-01-27 09:05:19 +01:00
Sascha Ißbrücker
9c648dc67f Update CHANGELOG.md 2025-01-27 00:02:14 +01:00
Sascha Ißbrücker
1624128132 Bump versions to make Docker build 2025-01-26 23:52:36 +01:00
52 changed files with 996 additions and 346 deletions

26
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: build
on: workflow_dispatch
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Build and push
uses: docker/build-push-action@v6
with:
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: false
tags: sissbruecker/linkding:test
target: linkding

View File

@@ -1,5 +1,53 @@
# Changelog
## v1.38.0 (09/02/2025)
### What's Changed
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
### New Contributors
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
---
## v1.37.0 (26/01/2025)
### What's Changed
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
### New Contributors
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
---
## v1.36.0 (02/10/2024)
### What's Changed

View File

@@ -10,6 +10,7 @@ from bookmarks.services.bookmarks import (
enhance_with_website_metadata,
)
from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
class TagListField(serializers.ListField):
@@ -59,9 +60,10 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Custom tag_names field to allow passing a list of tag names to create/update
tag_names = TagListField(required=False)
# Custom fields to return URLs for favicon and preview image
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField()
web_archive_snapshot_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = serializers.SerializerMethodField()
website_description = serializers.SerializerMethodField()
@@ -82,6 +84,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url
def get_web_archive_snapshot_url(self, obj: Bookmark):
if obj.web_archive_snapshot_url:
return obj.web_archive_snapshot_url
return generate_fallback_webarchive_url(obj.url, obj.date_added)
def get_website_title(self, obj: Bookmark):
return None

View File

@@ -0,0 +1,48 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
def setUp(self) -> None:
super().setUp()
def assertSidePanelIsVisible(self):
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
expect(
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).not_to_be_visible()
def assertSidePanelIsHidden(self):
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
expect(
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).to_be_visible()
def test_side_panel_should_be_visible_by_default(self):
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
self.assertSidePanelIsVisible()
def test_side_panel_should_be_hidden_when_collapsed(self):
user = self.get_or_create_test_user()
user.profile.collapse_side_panel = True
user.profile.save()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
self.assertSidePanelIsHidden()

View File

@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
def test_show_modal_close_modal(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
# open drawer
drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Filters"
)
modal_trigger.click()
drawer_trigger.click()
# verify modal is visible
modal = page.locator(".modal")
expect(modal).to_be_visible()
expect(modal.locator("h2")).to_have_text("Tags")
# verify drawer is visible
drawer = page.locator(".modal.drawer.filter-drawer")
expect(drawer).to_be_visible()
expect(drawer.locator("h2")).to_have_text("Filters")
# close with close button
modal.locator("button.close").click()
expect(modal).to_be_hidden()
drawer.locator("button.close").click()
expect(drawer).to_be_hidden()
# open modal again
modal_trigger.click()
# open drawer again
drawer_trigger.click()
# close with backdrop
backdrop = modal.locator(".modal-overlay")
backdrop = drawer.locator(".modal-overlay")
backdrop.click(position={"x": 0, "y": 0})
expect(modal).to_be_hidden()
expect(drawer).to_be_hidden()
def test_select_tag(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Filters"
)
modal_trigger.click()
drawer_trigger.click()
# verify tags are displayed
modal = page.locator(".modal")
unselected_tags = modal.locator(".unselected-tags")
drawer = page.locator(".modal.drawer.filter-drawer")
unselected_tags = drawer.locator(".unselected-tags")
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
# select tag
unselected_tags.get_by_text("cooking").click()
# open modal again
modal_trigger.click()
# open drawer again
drawer_trigger.click()
# verify tag is selected, other tag is not visible anymore
selected_tags = modal.locator(".selected-tags")
selected_tags = drawer.locator(".selected-tags")
expect(selected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()

View File

@@ -1,61 +1,28 @@
import { Behavior, registerBehavior } from "./index";
import { registerBehavior } from "./index";
import { isKeyboardActive } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends Behavior {
constructor(element) {
super(element);
class DetailsModalBehavior extends ModalBehavior {
doClose() {
super.doClose();
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
// Navigate to close URL
const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
this.buttonLink = element.querySelector("a:has(button.close)");
// Try restore focus to view details to view details link of respective bookmark
const bookmarkId = this.element.dataset.bookmarkId;
const restoreFocusElement =
document.querySelector(
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
) ||
document.querySelector("ul.bookmark-list") ||
document.body;
this.overlayLink.addEventListener("click", this.onClose);
this.buttonLink.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.overlayLink.removeEventListener("click", this.onClose);
this.buttonLink.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.element.remove();
const closeUrl = this.overlayLink.href;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
}
},
{ once: true },
);
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}

View File

@@ -0,0 +1,97 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<section class="content content-area"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector("section.side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector("section.side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -0,0 +1,59 @@
let keyboardActive = false;
window.addEventListener(
"keydown",
() => {
keyboardActive = true;
},
{ capture: true },
);
window.addEventListener(
"mousedown",
() => {
keyboardActive = false;
},
{ capture: true },
);
export function isKeyboardActive() {
return keyboardActive;
}
export class FocusTrapController {
constructor(element) {
this.element = element;
this.focusableElements = this.element.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
);
this.firstFocusableElement = this.focusableElements[0];
this.lastFocusableElement =
this.focusableElements[this.focusableElements.length - 1];
this.onKeyDown = this.onKeyDown.bind(this);
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
if (event.key !== "Tab") {
return;
}
if (event.shiftKey) {
if (document.activeElement === this.firstFocusableElement) {
event.preventDefault();
this.lastFocusableElement.focus();
}
} else {
if (document.activeElement === this.lastFocusableElement) {
event.preventDefault();
this.firstFocusableElement.focus();
}
}
}
}

View File

@@ -0,0 +1,91 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
this.element.remove();
this.clearInert();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

@@ -1,68 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class TagModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header">
<h2>Tags</h2>
<button class="close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(tagCloud);
const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
}
}
registerBehavior("ld-tag-modal", TagModalBehavior);

View File

@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/clear-button";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/details-modal";
import "./behaviors/dropdown";
import "./behaviors/filter-drawer";
import "./behaviors/form";
import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0042_userprofile_custom_css_hash"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="collapse_side_panel",
field=models.BooleanField(default=False),
),
]

View File

@@ -93,6 +93,19 @@ class Bookmark(models.Model):
return self.resolved_title + " (" + self.url[:30] + "...)"
@receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs):
if instance.preview_image_file:
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
if os.path.isfile(filepath):
try:
os.remove(filepath)
except Exception as error:
logger.error(
f"Failed to delete preview image: {filepath}", exc_info=error
)
class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload"
@@ -440,6 +453,7 @@ class UserProfile(models.Model):
null=False, default=30, validators=[MinValueValidator(10)]
)
sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
@@ -479,6 +493,7 @@ class UserProfileForm(forms.ModelForm):
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
]

View File

@@ -36,8 +36,14 @@
}
}
& dl {
margin-bottom: 0;
& .sections section {
margin-top: var(--unit-4);
}
& .sections h3 {
margin-bottom: var(--unit-2);
font-size: var(--font-size);
font-weight: bold;
}
& .assets {

View File

@@ -10,8 +10,38 @@
}
/* Bookmark page grid */
.bookmarks-page.grid {
grid-gap: var(--unit-9);
.bookmarks-page {
&.grid {
grid-gap: var(--unit-9);
}
[ld-filter-drawer-trigger] {
display: none;
}
@media (max-width: 840px) {
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
&.collapse-side-panel {
section.main {
grid-column: span var(--grid-columns);
}
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
}
/* Bookmark area header controls */

View File

@@ -2,7 +2,8 @@
/* Content area component */
section.content-area {
h2 {
h2,
h3 {
font-size: var(--font-size-lg);
}
@@ -14,7 +15,8 @@ section.content-area {
padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4);
h2 {
h2,
h3 {
flex: 0 0 auto;
line-height: var(--unit-9);
margin: 0;

View File

@@ -10,7 +10,6 @@ html {
font-size: var(--html-font-size);
line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
}
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */

View File

@@ -62,13 +62,14 @@
gap: var(--unit-4);
max-height: 75vh;
max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%;
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
padding: var(--unit-6);
padding-bottom: 0;
color: var(--text-color);
& h2 {
@@ -78,7 +79,7 @@
margin: 0;
}
& button.close {
& .close {
background: none;
border: none;
padding: 0;
@@ -95,10 +96,53 @@
& .modal-body {
overflow-y: auto;
position: relative;
padding: 0 var(--unit-6);
}
& .modal-body:not(:has(+ .modal-footer)) {
margin-bottom: var(--unit-6);
}
& .modal-footer {
padding: var(--unit-6);
padding-top: 0;
text-align: right;
}
}
.modal.drawer {
display: block;
& .modal-container {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-left: solid 1px var(--modal-container-border-color);
border-radius: 0;
transform: translateX(100%);
animation: fade-in 0.25s ease 1;
transition: transform 0.25s ease;
}
&.active {
& .modal-container {
transform: translateX(0);
}
}
&.active.closing {
& .modal-container {
animation: fade-out 0.25s ease 1;
transform: translateX(100%);
}
}
}
.scroll-lock {
overflow: hidden !important;
}

View File

@@ -4,17 +4,17 @@
{% load bookmarks %}
{% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
<div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header mb-0">
<h2>Archived bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -31,7 +31,7 @@
</section>
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>Tags</h2>
</div>
@@ -39,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -6,10 +6,12 @@
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
role="list" tabindex="-1"
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
<div class="content">
<div class="title">
<label class="form-checkbox bulk-edit-checkbox">
@@ -78,7 +80,8 @@
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}

View File

@@ -40,14 +40,14 @@
</div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/>
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
</div>
{% endif %}
<dl class="grid columns-2 columns-sm-1 gap-0">
<div class="sections grid columns-2 columns-sm-1 gap-0">
{% if details.is_editable %}
<div class="status col-2">
<dt>Status</dt>
<dd class="d-flex" style="gap: .8rem">
<section class="status col-2">
<h3>Status</h3>
<div class="d-flex" style="gap: .8rem">
<div class="form-group">
<label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,44 @@
</label>
</div>
{% endif %}
</dd>
</div>
</div>
</section>
{% endif %}
{% if details.show_files %}
<div class="files col-2">
<dt>Files</dt>
<dd>
<section class="files col-2">
<h3>Files</h3>
<div>
{% include 'bookmarks/details/assets.html' %}
</dd>
</div>
</div>
</section>
{% endif %}
{% if details.bookmark.tag_names %}
<div class="tags col-1">
<dt>Tags</dt>
<dd>
<section class="tags col-1">
<h3 id="details-modal-tags-title">Tags</h3>
<div>
{% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</dd>
</div>
</div>
</section>
{% endif %}
<div class="date-added col-1">
<dt>Date added</dt>
<dd>
<section class="date-added col-1">
<h3>Date added</h3>
<div>
<span>{{ details.bookmark.date_added }}</span>
</dd>
</div>
{% if details.bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ details.bookmark.resolved_description }}</dd>
</div>
</section>
{% if details.bookmark.resolved_description %}
<section class="description col-2">
<h3>Description</h3>
<div>{{ details.bookmark.resolved_description }}</div>
</section>
{% endif %}
{% if details.bookmark.notes %}
<div class="notes col-2">
<dt>Notes</dt>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
</div>
<section class="notes col-2">
<h3>Notes</h3>
<div class="markdown">{% markdown details.bookmark.notes %}</div>
</section>
{% endif %}
</dl>
</div>
</form>

View File

@@ -1,21 +1,17 @@
<div class="modal active bookmark-details"
ld-details-modal>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<div class="modal-overlay" aria-label="Close"></div>
</a>
<div class="modal-container">
<div class="modal active bookmark-details" ld-details-modal
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</a>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content">

View File

@@ -36,5 +36,8 @@
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
{% if rss_feed_url %}
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>

View File

@@ -4,16 +4,17 @@
{% load bookmarks %}
{% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
<div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header mb-0">
<h2>Bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -30,7 +31,7 @@
</section>
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>Tags</h2>
</div>
@@ -38,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -97,5 +97,9 @@
{% block content %}
{% endblock %}
</div>
<div class="modals">
{% block overlays %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks
</button>
<ul class="menu" role="list">
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
</li>
@@ -28,28 +28,28 @@
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<form class="d-inline" action="{% url 'logout' %}" method="post">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link">Logout</button>
</form>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<a href="{% url 'bookmarks:new' %}" aria-label="Add bookmark" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
<div ld-dropdown class="dropdown dropdown-right">
<button class="btn btn-link dropdown-toggle" tabindex="0">
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- menu component -->
<ul class="menu" role="list">
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
</li>
@@ -72,7 +72,7 @@
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
</li>
<li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button>
</form>

View File

@@ -4,16 +4,16 @@
{% load bookmarks %}
{% block content %}
<div class="bookmarks-page grid columns-md-1">
<div
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header">
<h2>Shared bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='shared' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -28,7 +28,7 @@
</section>
{# Filters #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>User</h2>
</div>
@@ -43,12 +43,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -124,6 +124,16 @@
visible without having to scroll to the end of the page first.
</div>
</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">
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>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}

View File

@@ -503,3 +503,10 @@ class BookmarkArchivedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("bookmarks:archived"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:archived"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:archived"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -1,25 +1,25 @@
import os
import shutil
import tempfile
from django.conf import settings
from django.test import TestCase
from django.test import TestCase, override_settings
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.override = override_settings(LD_ASSET_FOLDER=self.temp_dir)
self.override.enable()
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
self.override.disable()
shutil.rmtree(self.temp_dir)
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")

View File

@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def find_section(self, soup, section_name):
dt = soup.find("dt", string=section_name)
dd = dt.find_next_sibling("dd") if dt else None
return dd
def find_section_content(self, soup, section_name):
h3 = soup.find("h3", string=section_name)
content = h3.find_next_sibling("div") if h3 else None
return content
def get_section(self, soup, section_name):
dd = self.find_section(soup, section_name)
self.assertIsNotNone(dd)
return dd
def get_section_content(self, soup, section_name):
content = self.find_section_content(soup, section_name)
self.assertIsNotNone(content)
return content
def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url})
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# sharing disabled
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertFalse(archived.has_attr("checked"))
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertTrue(archived.has_attr("checked"))
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNotNone(section)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNone(section)
# guest user
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNone(section)
def test_date_added(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Date added")
section = self.get_section_content(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
date = section.find("span", string=expected_date)
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags")
section = self.find_section_content(soup, "Tags")
self.assertIsNone(section)
# with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Tags")
section = self.get_section_content(soup, "Tags")
for tag in bookmark.tags.all():
tag_link = section.find("a", string=f"#{tag.name}")
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(description="")
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description")
section = self.find_section_content(soup, "Description")
self.assertIsNone(section)
# with description
bookmark = self.setup_bookmark(description="Test description")
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description")
section = self.get_section_content(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description)
def test_notes(self):
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes")
section = self.find_section_content(soup, "Notes")
self.assertIsNone(section)
# with notes
bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Notes")
section = self.get_section_content(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
def test_edit_link(self):
@@ -568,7 +568,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
section = self.find_section_content(soup, "Files")
self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
@@ -576,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
section = self.find_section_content(soup, "Files")
self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
@@ -585,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list)
@@ -594,7 +594,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list)
@@ -608,7 +608,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
]
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
for asset in assets:
@@ -738,7 +738,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no pending asset
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
files_section = self.find_section_content(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
@@ -749,7 +749,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.save()
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
files_section = self.find_section_content(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)

View File

@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("bookmarks:index"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
class BookmarkIndexViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -0,0 +1,70 @@
import os
import shutil
import tempfile
from django.conf import settings
from django.test import TestCase, override_settings
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)
self.override.enable()
def tearDown(self):
self.override.disable()
shutil.rmtree(self.temp_dir)
def setup_preview_file(self, filename):
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_bookmark_with_preview(self):
bookmark = self.setup_bookmark()
bookmark.preview_image_file = f"preview_{bookmark.id}.jpg"
bookmark.save()
self.setup_preview_file(bookmark.preview_image_file)
return bookmark
def assertPreviewImageExists(self, bookmark):
self.assertTrue(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def assertPreviewImageDoesNotExist(self, bookmark):
self.assertFalse(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def test_delete_bookmark_deletes_preview_image(self):
bookmark = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark)
bookmark.delete()
self.assertPreviewImageDoesNotExist(bookmark)
def test_bulk_delete_bookmarks_deletes_preview_images(self):
bookmark1 = self.setup_bookmark_with_preview()
bookmark2 = self.setup_bookmark_with_preview()
bookmark3 = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark1)
self.assertPreviewImageExists(bookmark2)
self.assertPreviewImageExists(bookmark3)
bookmarks.delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertPreviewImageDoesNotExist(bookmark1)
self.assertPreviewImageDoesNotExist(bookmark2)
self.assertPreviewImageDoesNotExist(bookmark3)

View File

@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_includes_public_shared_rss_feed(self):
response = self.client.get(reverse("bookmarks:shared"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNotNone(feed)
self.assertEqual(feed.attrs["href"], reverse("bookmarks:feeds.public_shared"))

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
class BookmarkSharedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response,
'<li ld-bookmark-item class="shared">',
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -1,15 +1,18 @@
import datetime
import urllib.parse
from collections import OrderedDict
from unittest.mock import patch
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.services import website_loader
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
@@ -33,7 +36,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["title"] = bookmark.title
expectation["description"] = bookmark.description
expectation["notes"] = bookmark.notes
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["web_archive_snapshot_url"] = (
bookmark.web_archive_snapshot_url
or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)
)
expectation["favicon_url"] = (
f"http://testserver/static/{bookmark.favicon_file}"
if bookmark.favicon_file
@@ -590,6 +596,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_get_bookmark_returns_fallback_webarchive_url(self):
self.authenticate()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="",
url="https://example.com/",
added=timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
),
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(
response.data["web_archive_snapshot_url"],
"https://web.archive.org/web/20230811214511/https://example.com/",
)
def test_update_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()

View File

@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML(
f"""
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
<a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
""",
html,
count=count,
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread"], list_item["class"])
def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="shared">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["shared"], list_item["class"])
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(unread=True, shared=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread", "shared"], list_item["class"])
def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()

View File

@@ -4,6 +4,8 @@ import os
from django.test import TestCase, override_settings
from django.urls import URLResolver
from bookmarks import utils
class OidcSupportTest(TestCase):
def test_should_not_add_oidc_urls_by_default(self):
@@ -55,9 +57,83 @@ class OidcSupportTest(TestCase):
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertEqual(
True,
base_settings.OIDC_VERIFY_SSL,
)
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM)
del os.environ["LD_ENABLE_OIDC"]
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email")
def test_username_should_use_email_by_default(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_use_custom_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["preferred_username"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim")
def test_username_should_fallback_to_email_for_non_existing_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_fallback_to_email_for_empty_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_be_normalized(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual("NormalizedUser", username)

View File

@@ -47,6 +47,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"auto_tagging_rules": "",
"items_per_page": "30",
"sticky_pagination": False,
"collapse_side_panel": False,
}
return {**form_data, **overrides}
@@ -117,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"auto_tagging_rules": "example.com tag",
"items_per_page": "10",
"sticky_pagination": True,
"collapse_side_panel": True,
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
@@ -194,6 +196,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
)
self.assertEqual(
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
self.assertSuccessMessage(html, "Profile updated")

View File

@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
from django.http import HttpResponseRedirect
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
from django.conf import settings
try:
with open("version.txt", "r") as f:
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
return HttpResponseRedirect(redirect_url)
def generate_username(email):
def generate_username(email, claims):
# taken from mozilla-django-oidc docs :)
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
# (ascii and unicode), _, @, +, . and - characters. So we normalize
# it and slice at 150 characters.
return unicodedata.normalize("NFKC", email)[:150]
if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:
username = claims[settings.OIDC_USERNAME_CLAIM]
else:
username = email
return unicodedata.normalize("NFKC", username)[:150]

View File

@@ -104,6 +104,7 @@ def shared(request):
"tag_cloud": tag_cloud,
"details": bookmark_details,
"users": users,
"rss_feed_url": reverse("bookmarks:feeds.public_shared"),
},
)

View File

@@ -208,6 +208,7 @@ class BookmarkListContext:
self.show_favicons = user_profile.enable_favicons
self.show_preview_images = user_profile.enable_preview_images
self.show_notes = user_profile.permanent_notes
self.collapse_side_panel = user_profile.collapse_side_panel
@staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
RUN npm run build
FROM python:3.12.6-alpine3.20 AS build-deps
FROM python:3.12.9-alpine3.21 AS build-deps
# Add required packages
# alpine-sdk linux-headers pkgconfig: build Python packages from source
# libpq-dev: build Postgres client from source
@@ -49,7 +49,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.12.6-alpine3.20 AS linkding
FROM python:3.12.9-alpine3.21 AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap libssl3
@@ -73,6 +73,8 @@ ENV PATH=/opt/venv/bin:$PATH
RUN mkdir data && \
python manage.py collectstatic
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
ENV UWSGI_MAX_FD=4096
# Expose uwsgi server at port 9090
EXPOSE 9090
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
RUN npm run build
FROM python:3.12.6-slim-bookworm AS build-deps
FROM python:3.12.9-slim-bookworm AS build-deps
# Add required packages
# build-essential pkg-config: build Python packages from source
# libpq-dev: build Postgres client from source
@@ -51,7 +51,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.12.6-slim-bookworm AS linkding
FROM python:3.12.9-slim-bookworm AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
@@ -71,6 +71,8 @@ ENV PATH=/opt/venv/bin:$PATH
RUN mkdir data && \
python manage.py collectstatic
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
ENV UWSGI_MAX_FD=4096
# Expose uwsgi server at port 9090
EXPOSE 9090
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman

View File

@@ -11,6 +11,7 @@ This section lists community projects around using linkding, in alphabetical ord
- [go-linkding](https://github.com/piero-vic/go-linkding) A Go client library to interact with the linkding REST API. By [piero-vic](https://github.com/piero-vic)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)

View File

@@ -19,7 +19,7 @@ For multiple options, use one `-e` argument per option.
### Docker-compose
For docker-compose options are configured using an `.env` file.
For docker-compose options are configured using an `.env` file.
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
## List of options
@@ -105,11 +105,11 @@ Values: `True`, `False` | Default = `False`
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.
Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding.
If there is no user with that email address as username, a new user is created automatically.
Users are associated by the email address provided from the OIDC provider, which is by default also used as username in linkding. You can configure a custom claim to be used as username with `OIDC_USERNAME_CLAIM`.
If there is no user with that email address as username, a new user is created automatically.
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
The options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support.
Please check their documentation for more information on the options.
@@ -124,6 +124,15 @@ The following options can be configured:
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.
- `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`.
- `OIDC_RP_SCOPES` - Scopes asked for on the authorization flow. Default is `oidc email profile`.
- `OIDC_USERNAME_CLAIM` - A custom claim to used as username for new accounts, for example `preferred_username`. If the configured claim does not exist or is empty, the email claim is used as fallback. Default is `email`.
#### `OIDC` and `LD_SUPERUSER_NAME`
As noted above, OIDC matches users by email address, but `LD_SUPERUSER_NAME` will only set the username.
Instead of setting `LD_SUPERUSER_NAME` it is recommended that you use the method described in [User setup](/installation#user-setup) to configure a superuser with both username and email address.
This way when OIDC searches for a matching user it will find the superuser account you created.
Note that you should create the superuser **before** logging in with OIDC for the first time.
<details>
@@ -198,7 +207,7 @@ All the other database variables below are only required for configured Postgres
Values: `String` | Default = `linkding`
The name of the database.
The name of the database.
### `LD_DB_USER`
@@ -258,7 +267,7 @@ Alternative favicon providers:
Values: `Float` | Default = 60.0
When creating HTML archive snapshots, control the timeout for how long to wait for the snapshot to complete, in `seconds`.
Defaults to 60 seconds; on lower-powered hardware you may need to increase this value.
Defaults to 60 seconds; on lower-powered hardware you may need to increase this value.
### `LD_SINGLEFILE_OPTIONS`

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.37.0",
"version": "1.38.1",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -76,7 +76,7 @@ urllib3==2.2.3
# via
# requests
# waybackpy
uwsgi==2.0.26
uwsgi==2.0.28
# via -r requirements.in
waybackpy==3.0.6
# via -r requirements.in

View File

@@ -194,8 +194,10 @@ if LD_ENABLE_OIDC:
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
# Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")

View File

@@ -1 +1 @@
1.37.0
1.38.1