Compare commits

..

24 Commits

Author SHA1 Message Date
Sascha Ißbrücker
a4df586a8a Bump version 2024-03-30 10:27:28 +01:00
Sascha Ißbrücker
d9b7996e06 Make bookmark list actions configurable (#666)
* Make bookmark list actions configurable

* Add upgrade notice
2024-03-29 23:07:11 +01:00
Sascha Ißbrücker
92f62d3ded Fix CSS sub-pixel issues 2024-03-29 20:49:07 +01:00
Sascha Ißbrücker
9c48085829 Add bookmark details view (#665)
* Experiment with bookmark details

* Add basic tests

* Refactor details into modal

* Implement edit and delete button

* Remove slide down animation

* Add fallback details view

* Add status actions

* Improve dark theme

* Improve return URLs

* Make bookmark details sharable

* Fix E2E tests
2024-03-29 12:37:20 +01:00
Sascha Ißbrücker
77e1525402 Fix flaky E2E tests 2024-03-24 22:16:09 +01:00
Sascha Ißbrücker
9df80e01de Add option for showing bookmark description as separate block (#663)
* Add option for showing bookmark description as separate block

* Use context
2024-03-24 21:31:15 +01:00
Sascha Ißbrücker
ec34cc523f Run formatter 2024-03-24 11:50:02 +01:00
Sascha Ißbrücker
eb0b092d17 Disable pointer-events on bookmark tooltip 2024-03-22 23:55:46 +01:00
dependabot[bot]
39e8f03345 Bump black from 24.1.1 to 24.3.0 (#662)
Bumps [black](https://github.com/psf/black) from 24.1.1 to 24.3.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.1.1...24.3.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-21 23:12:58 +01:00
Sascha Ißbrücker
d43b97e0c0 Update CHANGELOG.md 2024-03-19 10:02:44 +01:00
Sascha Ißbrücker
d6484ba8e9 Add release script 2024-03-18 22:54:22 +01:00
dependabot[bot]
4c26d66177 Bump django from 5.0.2 to 5.0.3 (#658)
Bumps [django](https://github.com/django/django) from 5.0.2 to 5.0.3.
- [Commits](https://github.com/django/django/compare/5.0.2...5.0.3)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-18 22:51:29 +01:00
Sascha Ißbrücker
c51dcafa40 Fix docker build 2024-03-18 22:50:07 +01:00
Sascha Ißbrücker
262dd2b28f Update OIDC configuration defaults 2024-03-18 22:41:25 +01:00
Sascha Ißbrücker
01ad7f4d9e Bump version 2024-03-17 12:00:30 +01:00
Sascha Ißbrücker
d0d5c15345 Add RSS feeds for shared bookmarks (#656)
* Add shared bookmarks feed

* Add public shared bookmarks feed
2024-03-17 11:55:34 +01:00
Sascha Ißbrücker
afb752765d Include web archive link in /api/bookmarks/ (#655) 2024-03-17 10:04:05 +01:00
Sascha Ißbrücker
ce213775b6 Try fix workflow config 2024-03-17 09:59:04 +01:00
Bruno Henriques
fd1bbadcf3 Update backup location to safe directory (#653)
The previous directory may not share the same directory as the user that runs the container. `/etc/linkding/data` is a safe directory.

Fixes #626
2024-03-17 09:06:07 +01:00
Sascha Ißbrücker
83c2530df4 Add option for custom CSS (#652)
* Add option for adding custom CSS

* add missing migration
2024-03-17 01:11:59 +01:00
ηg
39782e75e7 Add support for OIDC (#389)
* added support for oidc auth

* fixed oidc usernames

* hiding password for users that aren't logged in via local auth

* add dependency, update settings

* keep change password link

* add tests

* add docs

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-03-16 23:42:46 +01:00
Hugo van Rijswijk
4bee104b62 Build improvements (#649)
* Improve PWA capabilities

* Invert background_color theme logic

* Revert build changes

* Revert "Revert build changes"

This reverts commit 1ab640fda1.

* update

* revert svelte component changes
2024-03-16 15:57:23 +01:00
Sascha Ißbrücker
f4ecffbb7f Fix flaky bulk edit E2E test (#650) 2024-03-16 15:35:22 +01:00
Hugo van Rijswijk
6f52bafda8 Improve PWA capabilities (#630)
* Improve PWA capabilities

* Invert background_color theme logic

* Revert build changes
2024-03-16 15:20:22 +01:00
96 changed files with 3439 additions and 1234 deletions

View File

@@ -13,7 +13,7 @@
!/package-lock.json
!/requirements.dev.txt
!/requirements.txt
!/rollup.config.js
!/rollup.config.mjs
!/supervisord.conf
!/uwsgi.ini
!/version.txt

View File

@@ -1,23 +1,28 @@
name: linkding CI
on: [push, pull_request]
on:
pull_request:
push:
branches:
- master
jobs:
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: 'npm'
- name: Install Node dependencies
run: npm install
run: npm ci
- name: Setup Python environment
run: pip install -r requirements.txt -r requirements.dev.txt
- name: Run tests
@@ -26,17 +31,18 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: 'npm'
- name: Install Node dependencies
run: npm install
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt

View File

@@ -1,5 +1,36 @@
# Changelog
## v1.25.0 (18/03/2024)
### What's Changed
* Improve PWA capabilities by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/630
* build improvements by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/649
* Add support for oidc by @Nighmared in https://github.com/sissbruecker/linkding/pull/389
* Add option for custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/652
* Update backup location to safe directory by @bphenriques in https://github.com/sissbruecker/linkding/pull/653
* Include web archive link in /api/bookmarks/ by @sissbruecker in https://github.com/sissbruecker/linkding/pull/655
* Add RSS feeds for shared bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/656
* Bump django from 5.0.2 to 5.0.3 by @dependabot in https://github.com/sissbruecker/linkding/pull/658
### New Contributors
* @hugo-vrijswijk made their first contribution in https://github.com/sissbruecker/linkding/pull/630
* @Nighmared made their first contribution in https://github.com/sissbruecker/linkding/pull/389
* @bphenriques made their first contribution in https://github.com/sissbruecker/linkding/pull/653
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.2...v1.25.0
---
## v1.24.2 (16/03/2024)
### What's Changed
* Fix logout button by @sissbruecker in https://github.com/sissbruecker/linkding/pull/648
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.1...v1.24.2
---
## v1.24.1 (16/03/2024)
### What's Changed

View File

@@ -40,11 +40,12 @@ The name comes from:
- Automatically provides titles, descriptions and icons of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format
- Installable as a Progressive Web App (PWA)
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- Light and dark themes
- SSO support via OIDC or authentication proxies
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)

View File

@@ -30,6 +30,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
"notes",
"website_title",
"website_description",
"web_archive_snapshot_url",
"is_archived",
"unread",
"shared",
@@ -40,6 +41,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
read_only_fields = [
"website_title",
"website_description",
"web_archive_snapshot_url",
"date_added",
"date_modified",
]

View File

@@ -0,0 +1,133 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_show_details(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
details_modal = self.open_details_modal(bookmark)
title = details_modal.locator("h2")
expect(title).to_have_text(bookmark.title)
def test_close_details(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
# close with close button
details_modal = self.open_details_modal(bookmark)
details_modal.locator("button.close").click()
expect(details_modal).to_be_hidden()
# close with backdrop
details_modal = self.open_details_modal(bookmark)
overlay = details_modal.locator(".modal-overlay")
overlay.click(position={"x": 0, "y": 0})
expect(details_modal).to_be_hidden()
def test_toggle_archived(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# archive
url = reverse("bookmarks:index")
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
# unarchive
url = reverse("bookmarks:archived")
self.page.goto(self.live_server_url + url)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
def test_toggle_unread(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# mark as unread
url = reverse("bookmarks:index")
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).to_be_visible()
# mark as read
details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
def test_toggle_shared(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# share bookmark
url = reverse("bookmarks:index")
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).to_be_visible()
# unshare bookmark
details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
def test_edit_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
# Navigate to edit page
with self.page.expect_navigation():
details_modal.get_by_text("Edit").click()
# Cancel edit, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
self.page.get_by_text("Nevermind").click()
def test_delete(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
# Delete bookmark, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
details_modal.get_by_text("Delete...").click()
details_modal.get_by_text("Confirm").click()
# verify bookmark is deleted
self.locate_bookmark(bookmark.title)
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertEqual(Bookmark.objects.count(), 0)

View File

@@ -0,0 +1,37 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
def test_edit_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
# Navigate to edit page
with self.page.expect_navigation():
self.page.get_by_text("Edit").click()
# Cancel edit, verify return url
with self.page.expect_navigation(
url=self.live_server_url
+ reverse("bookmarks:details", args=[bookmark.id])
):
self.page.get_by_text("Nevermind").click()
def test_delete_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
# Trigger delete, verify return url
# Should probably return to last bookmark list page, but for now just returns to index
with self.page.expect_navigation(
url=self.live_server_url + reverse("bookmarks:index")
):
self.page.get_by_text("Delete...").click()
self.page.get_by_text("Confirm").click()

View File

@@ -39,6 +39,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
@@ -46,6 +47,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
0,
@@ -74,6 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
self.open(reverse("bookmarks:archived"), p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
@@ -81,6 +85,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
50,
@@ -109,6 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
self.open(reverse("bookmarks:index") + "?q=foo", p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
@@ -116,6 +123,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
50,
@@ -144,6 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
self.open(reverse("bookmarks:archived") + "?q=foo", p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
@@ -151,6 +161,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
50,
@@ -269,14 +281,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
url = reverse("bookmarks:index")
page = self.open(url, p)
bookmark_list = self.locate_bookmark_list()
# Select all bookmarks, enable select across
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
# Get reference for bookmark list
bookmark_list = page.locator("ul[ld-bookmark-list]")
# Execute bulk action
self.select_bulk_action("Mark as unread")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -302,6 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
url = reverse("bookmarks:index")
self.open(url, p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
@@ -312,7 +324,10 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
self.locate_bulk_edit_select_all().click()
expect(

View File

@@ -2,6 +2,7 @@ from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import UserProfile
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
@@ -39,3 +40,49 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden()
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_visible()
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden()
display = page.get_by_label("Bookmark description", exact=True)
display.select_option("separate")
expect(max_lines).to_be_visible()
display.select_option("inline")
expect(max_lines).to_be_hidden()

View File

@@ -1,5 +1,6 @@
from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext, Playwright, Page
from playwright.sync_api import expect
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -38,10 +39,25 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count)
def locate_bookmark_list(self):
return self.page.locator("ul[ld-bookmark-list]")
def locate_bookmark(self, title: str):
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
return bookmark_tags.filter(has_text=title)
def locate_details_modal(self):
return self.page.locator(".modal.bookmark-details")
def open_details_modal(self, bookmark):
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
details_button.click()
details_modal = self.locate_details_modal()
expect(details_modal).to_be_visible()
return details_modal
def locate_bulk_edit_bar(self):
return self.page.locator(".bulk-edit-bar")

View File

@@ -6,12 +6,12 @@ from django.db.models import QuerySet
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass
class FeedContext:
feed_token: FeedToken
feed_token: FeedToken | None
query_set: QuerySet[Bookmark]
@@ -67,3 +67,39 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)
class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks"
description = "All shared bookmarks"
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False
)
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks"
description = "All public shared bookmarks"
def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", ""))
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
return FeedContext(None, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -0,0 +1,38 @@
import { registerBehavior } from "./index";
class BookmarkDetails {
constructor(element) {
this.form = element.querySelector(".status form");
if (!this.form) {
// Form may not exist if user does not own the bookmark
return;
}
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.submitForm();
});
const inputs = this.form.querySelectorAll("input");
inputs.forEach((input) => {
input.addEventListener("change", () => {
this.submitForm();
});
});
}
async submitForm() {
const url = this.form.action;
const formData = new FormData(this.form);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
// Refresh bookmark page if it exists
document.dispatchEvent(new CustomEvent("bookmark-page-refresh"));
}
}
registerBehavior("ld-bookmark-details", BookmarkDetails);

View File

@@ -8,6 +8,10 @@ class BookmarkPage {
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
document.addEventListener("bookmark-page-refresh", () => {
this.refresh();
});
}
async onFormSubmit(event) {

View File

@@ -38,10 +38,14 @@ class ConfirmButtonBehavior {
container.append(question);
}
const buttonClasses = Array.from(this.button.classList.values())
.filter((cls) => cls.startsWith("btn"))
.join(" ");
const cancelButton = document.createElement(this.button.nodeName);
cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = "btn btn-link btn-sm mr-1";
cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName);
@@ -49,7 +53,7 @@ class ConfirmButtonBehavior {
confirmButton.name = this.button.dataset.name;
confirmButton.value = this.button.dataset.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = "btn btn-link btn-sm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
container.append(cancelButton, confirmButton);

View File

@@ -1,4 +1,4 @@
import { registerBehavior } from "./index";
import { applyBehaviors, registerBehavior } from "./index";
class ModalBehavior {
constructor(element) {
@@ -7,14 +7,50 @@ class ModalBehavior {
this.toggle = toggle;
}
onToggleClick() {
async onToggleClick(event) {
// Ignore Ctrl + click
if (event.ctrlKey || event.metaKey) {
return;
}
event.preventDefault();
event.stopPropagation();
// Create modal either by teleporting existing content or fetching from URL
const modal = this.toggle.hasAttribute("modal-content")
? this.createFromContent()
: await this.createFromUrl();
if (!modal) {
return;
}
// Register close handlers
const modalOverlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
applyBehaviors(document.body);
this.modal = modal;
}
async createFromUrl() {
const url = this.toggle.getAttribute("modal-url");
const modalHtml = await fetch(url).then((response) => response.text());
const parser = new DOMParser();
const doc = parser.parseFromString(modalHtml, "text/html");
return doc.querySelector(".modal");
}
createFromContent() {
const contentSelector = this.toggle.getAttribute("modal-content");
const content = document.querySelector(contentSelector);
if (!content) {
return;
}
// Create modal
// Todo: make title configurable, only used for tag cloud for now
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
@@ -22,7 +58,7 @@ class ModalBehavior {
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="btn btn-link close">
<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>
@@ -36,29 +72,28 @@ class ModalBehavior {
</div>
`;
// Teleport content element
const contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;
// Register close handlers
const modalOverlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".btn.close");
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
this.modal = modal;
return modal;
}
onClose() {
// Teleport content back
this.contentOwner.append(this.content);
if (this.content && this.contentOwner) {
this.contentOwner.append(this.content);
}
// Remove modal
this.modal.remove();
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") {
this.modal.remove();
}
});
}
}

View File

@@ -258,4 +258,4 @@
z-index: 2;
}
</style>
</style>

View File

@@ -151,18 +151,20 @@
}
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
height: var(--control-size-sm);
min-height: var(--control-size-sm);
padding: 0.05rem 0.3rem;
}
.form-autocomplete.small .form-autocomplete-input input {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-size: 0.7rem;
font-size: var(--font-size-sm);
}
.form-autocomplete.small .menu .menu-item {
font-size: 0.7rem;
font-size: var(--font-size-sm);
}
</style>

View File

@@ -1,6 +1,4 @@
import TagAutoComplete from "./components/TagAutocomplete.svelte";
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
import { ApiClient } from "./api";
import "./behaviors/bookmark-details";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
@@ -8,9 +6,6 @@ import "./behaviors/dropdown";
import "./behaviors/modal";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete,
};
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { ApiClient } from "./api";

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-03-16 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0025_userprofile_search_preferences"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="custom_css",
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.0.2 on 2024-03-23 21:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0026_userprofile_custom_css"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bookmark_description_display",
field=models.CharField(
choices=[("inline", "Inline"), ("separate", "Separate")],
default="inline",
max_length=10,
),
),
migrations.AddField(
model_name="userprofile",
name="bookmark_description_max_lines",
field=models.IntegerField(default=1),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.2 on 2024-03-29 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="display_archive_bookmark_action",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="display_edit_bookmark_action",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="display_remove_bookmark_action",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="display_view_bookmark_action",
field=models.BooleanField(default=True),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2024-03-29 21:25
from django.db import migrations
from django.contrib.auth import get_user_model
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="bookmark_list_actions_hint",
message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
owner=user,
)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -278,6 +278,12 @@ class UserProfile(models.Model):
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
]
BOOKMARK_DESCRIPTION_DISPLAY_INLINE = "inline"
BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = "separate"
BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [
(BOOKMARK_DESCRIPTION_DISPLAY_INLINE, "Inline"),
(BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, "Separate"),
]
BOOKMARK_LINK_TARGET_BLANK = "_blank"
BOOKMARK_LINK_TARGET_SELF = "_self"
BOOKMARK_LINK_TARGET_CHOICES = [
@@ -308,6 +314,16 @@ class UserProfile(models.Model):
blank=False,
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
)
bookmark_description_display = models.CharField(
max_length=10,
choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,
blank=False,
default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
)
bookmark_description_max_lines = models.IntegerField(
null=False,
default=1,
)
bookmark_link_target = models.CharField(
max_length=10,
choices=BOOKMARK_LINK_TARGET_CHOICES,
@@ -330,7 +346,12 @@ class UserProfile(models.Model):
enable_public_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False)
display_view_bookmark_action = models.BooleanField(default=True, null=False)
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
display_archive_bookmark_action = models.BooleanField(default=True, null=False)
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
custom_css = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False)
@@ -340,6 +361,8 @@ class UserProfileForm(forms.ModelForm):
fields = [
"theme",
"bookmark_date_display",
"bookmark_description_display",
"bookmark_description_max_lines",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
@@ -347,7 +370,12 @@ class UserProfileForm(forms.ModelForm):
"enable_public_sharing",
"enable_favicons",
"display_url",
"display_view_bookmark_action",
"display_edit_bookmark_action",
"display_archive_bookmark_action",
"display_remove_bookmark_action",
"permanent_notes",
"custom_css",
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m512 512h-512v-512h512" fill="#5856e0" fill-rule="nonzero" stroke-width=".293"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m249.095 110.679-141.167 141.167s-48.578 47.432 4.257 101.668c53.026 54.426 101.654 4.242 101.654 4.242l141.166-141.166"/><path d="m263.892 400.446 140.673-141.659s48.412-47.602-4.612-101.652c-53.215-54.24-101.667-3.888-101.667-3.888l-140.674 141.659"/></g></svg>

After

Width:  |  Height:  |  Size: 564 B

View File

@@ -0,0 +1 @@
<svg height="700pt" preserveAspectRatio="xMidYMid meet" viewBox="0 0 700 700" width="700pt" xmlns="http://www.w3.org/2000/svg"><path d="m3210 6573c-780-79-1463-417-1985-983-444-481-716-1082-791-1750-18-160-18-490 0-650 80-713 380-1341 880-1842 492-494 1125-801 1822-883 150-18 512-21 654-5 407 44 737 142 1094 323 775 394 1350 1108 1571 1952 71 271 98 487 98 785-1 311-35 562-117 847-54 188-99 302-201 508-216 439-510 795-900 1090-441 335-992 550-1544 605-95 9-499 12-581 3zm-639-2228c-543-544-1003-1011-1020-1038-91-134-135-274-134-422 1-167 61-314 200-485 135-165 308-291 467-338 110-32 264-27 376 12 185 65 130 15 1233 1115l1007 1006 150-150c83-82 150-154 150-160s-442-452-982-992c-658-656-1010-1000-1064-1040-304-223-643-298-965-214-271 72-548 272-751 543-115 153-179 283-226 457-22 85-26 115-25 256 0 140 3 172 26 257 34 130 95 266 169 380 54 83 172 205 1067 1101l1006 1007 152-152 153-153zm2359 1051c242-44 461-167 675-381 282-281 415-567 415-890 0-119-17-230-51-336-32-101-123-277-188-363-54-71-2003-2046-2020-2046-11 0-301 281-301 292 0 6 435 449 967 985 533 536 986 998 1007 1027 53 70 121 216 140 303 30 136 14 277-48 416-65 147-245 351-396 449-211 137-403 161-617 79-162-63-173-73-1009-913-428-431-871-876-984-991-112-114-207-207-211-207s-75 67-160 148l-153 149 785 789c431 434 865 872 964 973 266 271 397 369 612 455 176 70 396 94 573 62z" transform="matrix(.1 0 0 -.1 0 700)"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -9,7 +9,7 @@ body {
}
header {
margin-bottom: $unit-10;
margin-bottom: $unit-9;
.logo {
width: 28px;
@@ -50,14 +50,14 @@ section.content-area {
border-bottom: solid 1px $border-color;
display: flex;
flex-wrap: wrap;
column-gap: $unit-6;
padding-bottom: $unit-2;
margin-bottom: $unit-4;
column-gap: $unit-5;
padding-bottom: $unit-1;
margin-bottom: $unit-3;
h2 {
flex: 0 0 auto;
line-height: 1.8rem;
margin-bottom: 0;
line-height: $unit-9;
margin: 0;
}
.header-controls {
@@ -95,10 +95,6 @@ span.confirmation {
text-overflow: ellipsis;
}
.text-sm {
font-size: 0.7rem;
}
.text-gray-dark {
color: $gray-color-dark;
}
@@ -124,10 +120,6 @@ span.confirmation {
margin-right: auto;
}
.ml-auto {
margin-left: auto;
}
.btn.btn-wide {
padding-left: $unit-6;
padding-right: $unit-6;

View File

@@ -0,0 +1,79 @@
/* Common styles */
.bookmark-details {
h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
.weblinks {
display: flex;
flex-direction: column;
gap: $unit-2;
}
a.weblink {
display: flex;
align-items: center;
gap: $unit-2;
}
a.weblink img, a.weblink svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: $body-font-color;
}
a.weblink span {
flex: 1 1 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
dl {
margin-bottom: 0;
}
.tags a {
color: $alternative-color;
}
.status form {
display: flex;
gap: $unit-2;
}
.status form .form-group, .status form .form-switch {
margin: 0;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
}
/* Bookmark details view specific */
.bookmark-details.page {
display: flex;
flex-direction: column;
gap: $unit-6;
}
/* Bookmark details modal specific */
.bookmark-details.modal {
.modal-header {
display: flex;
align-items: flex-start;
gap: $unit-2;
}
.modal-body {
padding-top: 0;
padding-bottom: 0;
}
}

View File

@@ -1,11 +1,10 @@
.bookmarks-page.grid {
grid-gap: $unit-10;
grid-gap: $unit-9;
}
/* Bookmark area header controls */
.bookmarks-page .content-area-header {
--searchbox-max-width: 350px;
--searchbox-height: 1.8rem;
@media (max-width: $size-sm) {
--searchbox-max-width: initial;
@@ -20,18 +19,18 @@
// Regular input
input[type='search'] {
height: var(--searchbox-height);
height: $control-size;
-webkit-appearance: none;
}
// Enhanced auto-complete input
// This needs a bit more wrangling to make the CSS component align with the attached button
.form-autocomplete {
height: var(--searchbox-height);
height: $control-size;
.form-autocomplete-input {
width: 100%;
height: var(--searchbox-height);
height: $control-size;
input[type='search'] {
width: 100%;
@@ -72,6 +71,7 @@
.menu {
padding: $unit-4;
min-width: 250px;
font-size: $font-size-sm;
}
.menu .actions {
@@ -82,9 +82,11 @@
.radio-group {
margin-bottom: $unit-1;
.form-label {
padding-bottom: 0;
}
.form-radio.form-inline {
margin: 0 $unit-2 0 0;
padding: 0;
@@ -92,6 +94,7 @@
align-items: center;
column-gap: $unit-1;
}
.form-icon {
top: 0;
position: relative;
@@ -105,6 +108,9 @@ ul.bookmark-list {
list-style: none;
margin: 0;
padding: 0;
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
}
@keyframes appear {
@@ -122,59 +128,81 @@ ul.bookmark-list {
/* Bookmarks */
li[ld-bookmark-item] {
position: relative;
margin-top: $unit-2;
[ld-bulk-edit-checkbox].form-checkbox {
display: none;
}
.title {
position: relative;
}
.title img {
position: absolute;
width: 16px;
height: 16px;
left: 0;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.title img + a {
padding-left: 22px;
}
.title a {
display: inline-block;
vertical-align: top;
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
content: attr(data-tooltip);
position: absolute;
z-index: 10;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
height: fit-content;
background-color: #292f62;
color: #fff;
padding: $unit-1;
border-radius: $border-radius;
border: 1px solid #424a8c;
font-size: $font-size-sm;
font-style: normal;
white-space: normal;
animation: 0.3s ease 0s appear;
}
.title a[data-tooltip]:hover::after, .title a[data-tooltip]:focus::after {
content: attr(data-tooltip);
position: absolute;
z-index: 10;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 90%;
height: fit-content;
background-color: #292f62;
color: #fff;
padding: $unit-1;
border-radius: $border-radius;
border: 1px solid #424a8c;
font-size: $font-size-sm;
font-style: normal;
white-space: normal;
pointer-events: none;
animation: 0.3s ease 0s appear;
}
&.unread .title a {
font-style: italic;
}
.title img {
width: 16px;
height: 16px;
margin-right: $unit-h;
vertical-align: text-top;
}
.url-display {
.url-path, .url-display {
font-size: $font-size-sm;
color: $secondary-link-color;
}
.description {
color: $gray-color-dark;
}
.description.separate {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
overflow: hidden;
}
.tags {
a, a:visited:hover {
color: $alternative-color;
}
@@ -195,6 +223,8 @@ li[ld-bookmark-item] {
}
.actions {
font-size: $font-size-sm;
a, button.btn-link {
color: $gray-color;
padding: 0;
@@ -211,10 +241,6 @@ li[ld-bookmark-item] {
color: $gray-color-dark;
}
}
.separator {
align-self: flex-start;
}
}
}
@@ -223,6 +249,8 @@ li[ld-bookmark-item] {
}
.tag-cloud {
/* Increase line-height for better separation within / between items */
line-height: 1.1rem;
.selected-tags {
margin-bottom: $unit-4;
@@ -258,55 +286,13 @@ ul.bookmark-list {
overflow-y: auto;
}
&.show-notes .notes,
li.show-notes .notes {
display: block;
}
}
/* Bookmark notes markdown styles */
ul.bookmark-list .notes-content {
& {
.notes .markdown {
padding: $unit-2 $unit-3;
}
p, ul, ol, pre, blockquote {
margin: 0 0 $unit-2 0;
}
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
ul, ol {
margin-left: $unit-4;
}
ul li, ol li {
margin-top: $unit-1;
}
pre {
padding: $unit-1 $unit-2;
background-color: $code-bg-color;
border-radius: $unit-1;
overflow-x: auto;
}
pre code {
background: none;
box-shadow: none;
padding: 0;
}
> pre:first-child:last-child {
padding: 0;
background: none;
border-radius: 0;
&.show-notes .notes,
li.show-notes .notes {
display: block;
}
}
@@ -320,7 +306,7 @@ $bulk-edit-transition-duration: 400ms;
.bulk-edit-bar {
margin-top: -1px;
margin-left: -$bulk-edit-bar-offset;
margin-bottom: $unit-4;
margin-bottom: $unit-3;
max-height: 0;
overflow: hidden;
transition: max-height $bulk-edit-transition-duration;
@@ -342,7 +328,6 @@ $bulk-edit-transition-duration: 400ms;
width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0;
min-height: 1rem;
}
/* Bookmark checkboxes */
@@ -350,8 +335,10 @@ $bulk-edit-transition-duration: 400ms;
display: block;
position: absolute;
width: $bulk-edit-toggle-width;
min-height: $bulk-edit-toggle-width;
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
top: 0;
top: 50%;
transform: translateY(-50%);
padding: 0;
margin: 0;
visibility: hidden;
@@ -359,7 +346,7 @@ $bulk-edit-transition-duration: 400ms;
transition: all $bulk-edit-transition-duration;
.form-icon {
top: $unit-1;
top: 0;
}
}
@@ -371,7 +358,7 @@ $bulk-edit-transition-duration: 400ms;
/* Actions */
.bulk-edit-actions {
display: flex;
align-items: baseline;
align-items: center;
padding: $unit-1 0;
border-top: solid 1px $border-color;
gap: $unit-2;

View File

@@ -0,0 +1,40 @@
.markdown {
p, ul, ol, pre, blockquote {
margin: 0 0 $unit-2 0;
}
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
ul, ol {
margin-left: $unit-4;
}
ul li, ol li {
margin-top: $unit-1;
}
pre {
padding: $unit-1 $unit-2;
background-color: $code-bg-color;
border-radius: $unit-1;
overflow-x: auto;
}
pre code {
background: none;
box-shadow: none;
padding: 0;
}
> pre:first-child:last-child {
padding: 0;
background: none;
border-radius: 0;
}
}

View File

@@ -37,6 +37,14 @@
min-width: 0;
}
.columns-2 {
--grid-columns: 2;
}
.gap-0 {
gap: 0;
}
.col-1 {
grid-column: unquote("span min(1, var(--grid-columns))");
}

View File

@@ -1,12 +1,16 @@
.settings-page {
section.content-area {
margin-bottom: $unit-12;
margin-bottom: $unit-10;
h2 {
margin-bottom: $unit-4;
margin-bottom: $unit-3;
}
}
textarea.custom-css {
font-family: monospace;
}
.input-group > input[type=submit] {
height: auto;
}

View File

@@ -3,6 +3,32 @@
// Variables and mixins
@import "../../node_modules/spectre.css/src/variables";
// Customize variables to reduce font and control sizes
// Can use CSS variables for font sizes, as they are not used in SCSS calculations
$font-size: var(--font-size);
$font-size-sm: var(--font-size-sm);
$font-size-lg: var(--font-size-lg);
// Can't use CSS variables for these, used in SCSS calculations
$line-height: 1rem;
$control-size: $unit-8;
$control-size-sm: $unit-6;
$control-size-lg: $unit-9;
// Declare defaults for CSS variables, expose SCSS variables as CSS variables
html {
--font-size: 0.7rem;
--font-size-sm: 0.65rem;
--font-size-lg: 0.8rem;
--control-size: #{$control-size};
--control-size-sm: #{$control-size-sm};
--control-size-lg: #{$control-size-lg};
}
// Mixins
@import "../../node_modules/spectre.css/src/mixins";
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
@@ -64,19 +90,6 @@ a:visited:hover {
transition: none !important;
}
// Fix radio button sub-pixel size
.form-radio .form-icon {
width: 14px;
height: 14px;
border-width: 1px;
}
.form-radio input:checked + .form-icon::before {
top: 3px;
left: 3px;
transform: unset;
}
// Make code work with light and dark theme
code {
color: $gray-color-dark;
@@ -127,6 +140,53 @@ ul.menu li:first-child {
}
}
// Customize modal animation
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.modal.active .modal-container, .modal.active .modal-overlay {
animation: fade-in .15s ease 1;
}
.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
animation: fade-out .15s ease 1;
}
// Customize menu animation
.dropdown .menu {
animation: fade-in .15s ease 1;
}
// Modal close button
.modal .modal-header button.close {
background: none;
border: none;
padding: 0;
line-height: 0;
cursor: pointer;
opacity: .85;
color: $gray-color-dark;
&:hover {
opacity: 1;
}
}
// Increase input font size on small viewports to prevent zooming on focus the input
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
// viewport size

View File

@@ -7,9 +7,11 @@
// Import style modules
@import "base";
@import "responsive";
@import "bookmark-details";
@import "bookmark-page";
@import "bookmark-form";
@import "settings";
@import "markdown";
/* Dark theme overrides */
@@ -40,8 +42,17 @@ a:focus, .btn:focus {
}
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
background: $dt-primary-button-color;
border-color: $dt-primary-button-color;
background: $dt-primary-input-color;
border-color: $dt-primary-input-color;
}
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
background: $light-color;
}
.form-switch input:checked + .form-icon {
background: $dt-primary-input-color;
border-color: $dt-primary-input-color;
}
.form-radio input:checked + .form-icon::before {

View File

@@ -7,6 +7,8 @@
// Import style modules
@import "base";
@import "responsive";
@import "bookmark-details";
@import "bookmark-page";
@import "bookmark-form";
@import "settings";
@import "markdown";

View File

@@ -1,5 +1,3 @@
$html-font-size: 18px !default;
$body-bg: #161822 !default;
$bg-color: lighten($body-bg, 5%) !default;
$bg-color-light: lighten($body-bg, 5%) !default;
@@ -30,4 +28,5 @@ $code-bg-color: rgba(255, 255, 255, 0.1);
$code-shadow-color: rgba(255, 255, 255, 0.2);
/* Dark theme specific */
$dt-primary-input-color: #5C68E7 !default;
$dt-primary-button-color: #5761cb !default;

View File

@@ -1,5 +1,3 @@
$html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);

View File

@@ -6,50 +6,66 @@
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
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 %}>
<label ld-bulk-edit-checkbox class="form-checkbox">
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<i class="form-icon"></i>
</label>
<div class="title">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<img src="{% static bookmark_item.favicon_file %}" alt="">
{% endif %}
<span>{{ bookmark_item.title }}</span>
<label ld-bulk-edit-checkbox class="form-checkbox">
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<i class="form-icon"></i>
</label>
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<img src="{% static bookmark_item.favicon_file %}" alt="">
{% endif %}
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
<span>{{ bookmark_item.title }}</span>
</a>
</div>
{% if bookmark_list.show_url %}
<div class="url-path truncate">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display text-sm">
class="url-display">
{{ bookmark_item.url }}
</a>
</div>
{% endif %}
<div class="description truncate">
{% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate">
{% if bookmark_item.tag_names %}
<span class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% else %}
{% if bookmark_item.description %}
<div class="description separate">
{{ bookmark_item.description }}
</div>
{% endif %}
{% if bookmark_item.tag_names %}
<span>
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
</div>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
</div>
{% endif %}
{% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
<div class="markdown">
{% markdown bookmark_item.notes %}
</div>
</div>
{% endif %}
<div class="actions text-gray text-sm">
<div class="actions text-gray">
{% if bookmark_item.display_date %}
{% if bookmark_item.web_archive_snapshot_url %}
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
@@ -61,23 +77,35 @@
{% else %}
<span>{{ bookmark_item.display_date }}</span>
{% endif %}
<span class="separator">|</span>
<span>|</span>
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a ld-modal
modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
{% if bookmark_list.show_edit_action %}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% else %}
{# Shared bookmark actions #}
<span>Shared by
@@ -86,7 +114,7 @@
{% endif %}
{% if bookmark_item.has_extra_actions %}
<div class="extra-actions">
<span class="separator hide-sm">|</span>
<span class="hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"

View File

@@ -0,0 +1,13 @@
{% extends 'bookmarks/layout.html' %}
{% block content %}
<div ld-bookmark-details class="bookmark-details page">
{% if request.user == bookmark.owner %}
{% include 'bookmarks/details/actions.html' %}
{% endif %}
{% include 'bookmarks/details/title.html' %}
<div>
{% include 'bookmarks/details/content.html' %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
<div class="actions">
<div class="left-actions">
<a class="btn" href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ edit_return_url|urlencode }}">Edit</a>
</div>
<div class="right-actions">
<form action="{% url 'bookmarks:index.action' %}?return_url={{ delete_return_url|urlencode }}" method="post">
{% csrf_token %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}" class="btn btn-link text-error">
Delete...
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,85 @@
{% load static %}
{% load shared %}
<div class="weblinks">
<a class="weblink" href="{{ bookmark.url }}" rel="noopener"
target="{{ request.user_profile.bookmark_link_target }}">
{% if bookmark.favicon_file and request.user_profile.enable_favicons %}
<img class="favicon" src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
<span>{{ bookmark.url }}</span>
</a>
{% if bookmark.web_archive_snapshot_url %}
<a class="weblink" href="{{ bookmark.web_archive_snapshot_url }}"
target="{{ request.user_profile.bookmark_link_target }}">
{% if bookmark.favicon_file and request.user_profile.enable_favicons %}
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
<path
d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
fill="currentColor" fill-rule="evenodd"/>
</svg>
{% endif %}
<span>View on Internet Archive</span>
</a>
{% endif %}
</div>
<dl class="grid columns-2 columns-sm-1 gap-0">
{% if request.user == bookmark.owner %}
<div class="status col-2">
<dt>Status</dt>
<dd class="d-flex" style="gap: .8rem">
<form action="{% url 'bookmarks:details' bookmark.id %}" method="post">
{% csrf_token %}
<div class="form-group">
<label class="form-switch">
<input type="checkbox" name="is_archived" {% if bookmark.is_archived %}checked{% endif %}>
<i class="form-icon"></i> Archived
</label>
</div>
<div class="form-group">
<label class="form-switch">
<input type="checkbox" name="unread" {% if bookmark.unread %}checked{% endif %}>
<i class="form-icon"></i> Unread
</label>
</div>
{% if request.user_profile.enable_sharing %}
<div class="form-group">
<label class="form-switch">
<input type="checkbox" name="shared" {% if bookmark.shared %}checked{% endif %}>
<i class="form-icon"></i> Shared
</label>
</div>
{% endif %}
</form>
</dd>
</div>
{% endif %}
{% if bookmark.tag_names %}
<div class="tags col-1">
<dt>Tags</dt>
<dd>
{% for tag_name in bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</dd>
</div>
{% endif %}
<div class="date-added col-1">
<dt>Date added</dt>
<dd>
<span>{{ bookmark.date_added }}</span>
</dd>
</div>
{% if bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ bookmark.resolved_description }}</dd>
</div>
{% endif %}
{% if bookmark.notes %}
<div class="notes col-2">
<dt>Notes</dt>
<dd class="markdown">{% markdown bookmark.notes %}</dd>
</div>
{% endif %}
</dl>

View File

@@ -0,0 +1,3 @@
<h2>
{{ bookmark.resolved_title }}
</h2>

View File

@@ -0,0 +1,27 @@
<div ld-bookmark-details class="modal active bookmark-details">
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header">
{% include 'bookmarks/details/title.html' %}
<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>
</div>
<div class="modal-body">
<div class="content">
{% include 'bookmarks/details/content.html' %}
</div>
</div>
{% if request.user == bookmark.owner %}
<div class="modal-footer">
{% include 'bookmarks/details/actions.html' %}
</div>
{% endif %}
</div>
</div>

View File

@@ -35,7 +35,7 @@
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<div class="content-area-header mb-4">
<div class="content-area-header">
<h2>Tags</h2>
</div>
<div class="tag-cloud-container">

View File

@@ -6,8 +6,10 @@
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
@@ -19,14 +21,21 @@
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style>
{% endif %}
</head>
<body ld-global-shortcuts>
@@ -96,9 +105,9 @@
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %}
{% for toast in toast_messages %}
<div class="toast">
<div class="toast d-flex">
{{ toast.message }}
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear"></button>
</div>
{% endfor %}
</form>

View File

@@ -6,8 +6,8 @@
<div class="dropdown">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
Bookmarks
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
style="height:1rem;width:1rem;vertical-align: text-bottom;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
style="height:1rem;width:1rem;vertical-align: middle;">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"/>

View File

@@ -25,7 +25,7 @@
<path d="M18 9v11"></path>
</svg>
</button>
<div class="menu text-sm" tabindex="0">
<div class="menu" tabindex="0">
<form id="search_preferences" action="" method="post">
{% csrf_token %}
{% if 'sort' in preferences_form.editable_fields %}

View File

@@ -17,22 +17,24 @@
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
{{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
</div>
<br/>
<div class="d-flex justify-between">
<input type="submit" value="Login" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
<input type="hidden" name="next" value="{{ next }}"/>
{% if enable_oidc %}
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
{% endif %}
{% if allow_registration %}
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
{% endif %}
</div>
</form>
</section>
{% endblock %}

View File

@@ -29,6 +29,23 @@
be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_description_display.id_for_label }}" class="form-label">Bookmark
description</label>
{{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
</div>
</div>
<div
class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
<label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description
max lines</label>
{{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
<div class="form-input-hint">
Limits the number of lines that are displayed for the bookmark description.
</div>
</div>
<div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }}
@@ -48,6 +65,28 @@
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
</div>
</div>
<div class="form-group">
<label>Bookmark actions</label>
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
{{ form.display_view_bookmark_action }}
<i class="form-icon"></i> View
</label>
<label for="{{ form.display_edit_bookmark_action.id_for_label }}" class="form-checkbox">
{{ form.display_edit_bookmark_action }}
<i class="form-icon"></i> Edit
</label>
<label for="{{ form.display_archive_bookmark_action.id_for_label }}" class="form-checkbox">
{{ form.display_archive_bookmark_action }}
<i class="form-icon"></i> Archive
</label>
<label for="{{ form.display_remove_bookmark_action.id_for_label }}" class="form-checkbox">
{{ form.display_remove_bookmark_action }}
<i class="form-icon"></i> Remove
</label>
<div class="form-input-hint">
Which actions to display for each bookmark.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
@@ -124,6 +163,18 @@
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
</div>
</div>
<div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div class="mt-2">
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
</div>
</details>
<div class="form-input-hint">
Allows to add custom CSS to the page.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
@@ -150,7 +201,9 @@
<i class="form-icon"></i> Import public bookmarks as shared
</label>
<div class="form-input-hint">
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
When importing bookmarks from a service that supports marking bookmarks as public or private (using the
<code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not
private as shared bookmarks.
Otherwise, all bookmarks will be imported as private bookmarks.
</div>
</div>
@@ -181,10 +234,6 @@
<section class="content-area">
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<p>
Note that exporting bookmark notes is currently not supported due to limitations of the format.
For proper backups please use a database backup as described in the documentation.
</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
@@ -223,10 +272,12 @@
</div>
<script>
// Automatically disable public bookmark sharing if bookmark sharing is disabled
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
// Automatically disable public bookmark sharing if bookmark sharing is disabled
function updatePublicSharing() {
if (enableSharing.checked) {
enablePublicSharing.disabled = false;
@@ -238,6 +289,18 @@
updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing);
// Automatically hide the bookmark description max lines input if the description display is set to inline
function updateBookmarkDescriptionMaxLines() {
if (bookmarkDescriptionDisplay.value === "inline") {
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
} else {
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
}
}
updateBookmarkDescriptionMaxLines();
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
</script>
{% endblock %}

View File

@@ -49,9 +49,11 @@
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul>
<ul style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
</ul>
<p>
All URLs support appending a <code>q</code> URL parameter for specifying a search query.

View File

@@ -84,6 +84,7 @@ class BookmarkFactoryMixin:
unread: bool = False,
shared: bool = False,
with_tags: bool = False,
with_web_archive_snapshot_url: bool = False,
user: User = None,
):
user = user or self.get_or_create_test_user()
@@ -112,6 +113,9 @@ class BookmarkFactoryMixin:
if with_tags:
tag_name = f"{tag_prefix} {i}{suffix}"
tags = [self.setup_tag(name=tag_name, user=user)]
web_archive_snapshot_url = ""
if with_web_archive_snapshot_url:
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
bookmark = self.setup_bookmark(
url=url,
title=title,
@@ -119,6 +123,7 @@ class BookmarkFactoryMixin:
unread=unread,
shared=shared,
tags=tags,
web_archive_snapshot_url=web_archive_snapshot_url,
user=user,
)
bookmarks.append(bookmark)

View File

@@ -0,0 +1,562 @@
from django.test import TestCase
from django.urls import reverse
from django.utils import formats
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self):
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_base_url(self, bookmark):
return reverse("bookmarks:details_modal", args=[bookmark.id])
def get_details(self, bookmark, return_url=""):
url = self.get_base_url(bookmark)
if return_url:
url += f"?return_url={return_url}"
response = self.client.get(url)
soup = self.make_soup(response.content)
return soup
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 get_section(self, soup, section_name):
dd = self.find_section(soup, section_name)
self.assertIsNotNone(dd)
return dd
def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url})
def test_access(self):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 404)
# non-existent bookmark
response = self.client.get(reverse("bookmarks:details_modal", args=[9999]))
self.assertEqual(response.status_code, 404)
# guest user
self.client.logout()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 404)
def test_access_with_sharing(self):
# shared bookmark, sharing disabled
other_user = self.setup_user()
bookmark = self.setup_bookmark(shared=True, user=other_user)
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 404)
# shared bookmark, sharing enabled
profile = other_user.profile
profile.enable_sharing = True
profile.save()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 200)
# shared bookmark, guest user, no public sharing
self.client.logout()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 404)
# shared bookmark, guest user, public sharing
profile.enable_public_sharing = True
profile.save()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 200)
def test_displays_title(self):
# with title
bookmark = self.setup_bookmark(title="Test title")
soup = self.get_details(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
self.assertEqual(title.text.strip(), bookmark.title)
# with website title
bookmark = self.setup_bookmark(title="", website_title="Website title")
soup = self.get_details(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
self.assertEqual(title.text.strip(), bookmark.website_title)
# with URL only
bookmark = self.setup_bookmark(title="", website_title="")
soup = self.get_details(bookmark)
title = soup.find("h2")
self.assertIsNotNone(title)
self.assertEqual(title.text.strip(), bookmark.url)
def test_website_link(self):
# basics
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.url)
self.assertEqual(link.text.strip(), bookmark.url)
# favicons disabled
bookmark = self.setup_bookmark(favicon_file="example.png")
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img")
self.assertIsNone(image)
# favicons enabled, no favicon
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file="")
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img")
self.assertIsNone(image)
# favicons enabled, favicon present
bookmark = self.setup_bookmark(favicon_file="example.png")
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img")
self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png")
def test_internet_archive_link(self):
# without snapshot url
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNone(link)
# with snapshot url
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
self.assertEqual(link.text.strip(), "View on Internet Archive")
# favicons disabled
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
)
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg")
self.assertIsNone(image)
# favicons enabled, no favicon
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file=""
)
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg")
self.assertIsNone(image)
# favicons enabled, favicon present
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
)
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg")
self.assertIsNotNone(image)
def test_weblinks_respect_target_setting(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
# target blank
profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
profile.save()
soup = self.get_details(bookmark)
website_link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(website_link)
self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK)
web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(web_archive_link)
self.assertEqual(
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK
)
# target self
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
soup = self.get_details(bookmark)
website_link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(website_link)
self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF)
web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(web_archive_link)
self.assertEqual(
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
)
def test_status(self):
# renders form
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Status")
form = section.find("form")
self.assertIsNotNone(form)
self.assertEqual(
form["action"], reverse("bookmarks:details", args=[bookmark.id])
)
self.assertEqual(form["method"], "post")
# sharing disabled
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
unread = section.find("input", {"type": "checkbox", "name": "unread"})
self.assertIsNotNone(unread)
shared = section.find("input", {"type": "checkbox", "name": "shared"})
self.assertIsNone(shared)
# sharing enabled
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
unread = section.find("input", {"type": "checkbox", "name": "unread"})
self.assertIsNotNone(unread)
shared = section.find("input", {"type": "checkbox", "name": "shared"})
self.assertIsNotNone(shared)
# unchecked
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertFalse(archived.has_attr("checked"))
unread = section.find("input", {"type": "checkbox", "name": "unread"})
self.assertFalse(unread.has_attr("checked"))
shared = section.find("input", {"type": "checkbox", "name": "shared"})
self.assertFalse(shared.has_attr("checked"))
# checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_details(bookmark)
section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertTrue(archived.has_attr("checked"))
unread = section.find("input", {"type": "checkbox", "name": "unread"})
self.assertTrue(unread.has_attr("checked"))
shared = section.find("input", {"type": "checkbox", "name": "shared"})
self.assertTrue(shared.has_attr("checked"))
def test_status_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
form_action = reverse("bookmarks:details", args=[bookmark.id])
form = soup.find("form", {"action": form_action})
self.assertIsNotNone(section)
self.assertIsNotNone(form)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
form_action = reverse("bookmarks:details", args=[bookmark.id])
form = soup.find("form", {"action": form_action})
self.assertIsNone(section)
self.assertIsNone(form)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
form_action = reverse("bookmarks:details", args=[bookmark.id])
form = soup.find("form", {"action": form_action})
self.assertIsNone(section)
self.assertIsNone(form)
def test_status_update(self):
bookmark = self.setup_bookmark()
# update status
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 302)
bookmark.refresh_from_db()
self.assertTrue(bookmark.is_archived)
self.assertTrue(bookmark.unread)
self.assertTrue(bookmark.shared)
# update individual status
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "", "unread": "on", "shared": ""},
)
self.assertEqual(response.status_code, 302)
bookmark.refresh_from_db()
self.assertFalse(bookmark.is_archived)
self.assertTrue(bookmark.unread)
self.assertFalse(bookmark.shared)
def test_status_update_access(self):
# no sharing
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
profile = other_user.profile
profile.enable_sharing = True
profile.save()
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# shared, public sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
profile = other_user.profile
profile.enable_public_sharing = True
profile.save()
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
def test_date_added(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
date = section.find("span", string=expected_date)
self.assertIsNotNone(date)
def test_tags(self):
# without tags
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Tags")
self.assertIsNone(section)
# with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_details(bookmark)
section = self.get_section(soup, "Tags")
for tag in bookmark.tags.all():
tag_link = section.find("a", string=f"#{tag.name}")
self.assertIsNotNone(tag_link)
expected_url = reverse("bookmarks:index") + f"?q=%23{tag.name}"
self.assertEqual(tag_link["href"], expected_url)
def test_description(self):
# without description
bookmark = self.setup_bookmark(description="", website_description="")
soup = self.get_details(bookmark)
section = self.find_section(soup, "Description")
self.assertIsNone(section)
# with description
bookmark = self.setup_bookmark(description="Test description")
soup = self.get_details(bookmark)
section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description)
# with website description
bookmark = self.setup_bookmark(
description="", website_description="Website description"
)
soup = self.get_details(bookmark)
section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.website_description)
def test_notes(self):
# without notes
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Notes")
self.assertIsNone(section)
# with notes
bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_details(bookmark)
section = self.get_section(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
def test_edit_link(self):
bookmark = self.setup_bookmark()
# with default return URL
soup = self.get_details(bookmark)
edit_link = soup.find("a", string="Edit")
self.assertIsNotNone(edit_link)
details_url = reverse("bookmarks:details", args=[bookmark.id])
expected_url = (
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url
)
self.assertEqual(edit_link["href"], expected_url)
# with custom return URL
soup = self.get_details(bookmark, return_url="/custom")
edit_link = soup.find("a", string="Edit")
self.assertIsNotNone(edit_link)
expected_url = (
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
)
self.assertEqual(edit_link["href"], expected_url)
def test_delete_button(self):
bookmark = self.setup_bookmark()
# basics
soup = self.get_details(bookmark)
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(delete_button)
self.assertEqual(delete_button.text.strip(), "Delete...")
self.assertEqual(delete_button["value"], str(bookmark.id))
form = delete_button.find_parent("form")
self.assertIsNotNone(form)
expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks"
self.assertEqual(form["action"], expected_url)
# with custom return URL
soup = self.get_details(bookmark, return_url="/custom")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
form = delete_button.find_parent("form")
expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
self.assertEqual(form["action"], expected_url)
def test_actions_visibility(self):
# with sharing
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
# with public sharing
profile = other_user.profile
profile.enable_public_sharing = True
profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)

View File

@@ -0,0 +1,8 @@
from django.urls import reverse
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
def get_base_url(self, bookmark):
return reverse("bookmarks:details", args=[bookmark.id])

View File

@@ -35,6 +35,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["notes"] = bookmark.notes
expectation["website_title"] = bookmark.website_title
expectation["website_description"] = bookmark.website_description
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["is_archived"] = bookmark.is_archived
expectation["unread"] = bookmark.unread
expectation["shared"] = bookmark.shared
@@ -61,6 +62,17 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_with_more_details(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(
5, with_tags=True, with_web_archive_snapshot_url=True
)
response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
@@ -436,6 +448,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_get_bookmark_with_more_details(self):
self.authenticate()
tag1 = self.setup_tag()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://web.archive.org/web/1/",
tags=[tag1],
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_update_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()

View File

@@ -10,11 +10,11 @@ from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertBookmarksLink(
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
@@ -26,10 +26,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
)
self.assertInHTML(
f"""
{favicon_img}
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener">
{favicon_img}
<span>{bookmark.resolved_title}</span>
</a>
""",
@@ -40,7 +40,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<span>{label_content}</span>
<span class="separator">|</span>
<span>|</span>
""",
html,
)
@@ -54,19 +54,39 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content}
</a>
<span class="separator">|</span>
<span>|</span>
""",
html,
)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=1)
def assertViewLink(
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
):
self.assertViewLinkCount(html, bookmark, return_url=return_url)
def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=0)
def assertNoViewLink(
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
):
self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
# Edit link
def assertViewLinkCount(
self,
html: str,
bookmark: Bookmark,
count=1,
return_url=reverse("bookmarks:index"),
):
details_url = reverse("bookmarks:details", args=[bookmark.id])
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
self.assertInHTML(
f"""
<a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a>
""",
html,
count=count,
)
def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
self.assertInHTML(
f"""
@@ -75,7 +95,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
html,
count=count,
)
# Archive link
def assertArchiveLinkCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(
f"""
<button type="submit" name="archive" value="{bookmark.id}"
@@ -84,7 +105,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
html,
count=count,
)
# Delete link
def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(
f"""
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
@@ -94,6 +116,17 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
count=count,
)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=1)
def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=0)
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
self.assertEditLinkCount(html, bookmark, count=count)
self.assertArchiveLinkCount(html, bookmark, count=count)
self.assertDeleteLinkCount(html, bookmark, count=count)
def assertShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 1)
@@ -101,6 +134,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertShareInfoCount(html, bookmark, 0)
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
# Shared by link
self.assertInHTML(
f"""
<span>Shared by
@@ -133,7 +167,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
f"""
<div class="url-path truncate">
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
class="url-display text-sm">
class="url-display">
{bookmark.url}
</a>
</div>
@@ -154,7 +188,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertInHTML(
f"""
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
<div class="markdown">
{notes_html}
</div>
</div>
@@ -241,6 +275,172 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
user.profile.save()
return bookmark
def inline_bookmark_description_test(self, bookmark):
html = self.render_template()
soup = self.make_soup(html)
has_description = bool(bookmark.description)
has_tags = len(bookmark.tags.all()) > 0
# inline description block exists
description = soup.select_one(".description.inline.truncate")
self.assertIsNotNone(description)
# separate description block does not exist
separate_description = soup.select_one(".description.separate")
self.assertIsNone(separate_description)
# one direct child element per description or tags
children = description.find_all(recursive=False)
expected_child_count = (
0 + (1 if has_description else 0) + (1 if has_tags else 0)
)
self.assertEqual(len(children), expected_child_count)
# has separator between description and tags
if has_description and has_tags:
self.assertTrue("|" in description.text)
# contains description text
if has_description:
description_text = description.find("span", text=bookmark.description)
self.assertIsNotNone(description_text)
if not has_tags:
# no tags element
tags = soup.select_one(".tags")
self.assertIsNone(tags)
else:
# tags element exists
tags = soup.select_one(".tags")
self.assertIsNotNone(tags)
# one link for each tag
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
for tag in bookmark.tags.all():
tag_link = tags.find("a", text=f"#{tag.name}")
self.assertIsNotNone(tag_link)
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
def test_inline_bookmark_description(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
)
profile.save()
# no description, no tags
bookmark = self.setup_bookmark(description="")
self.inline_bookmark_description_test(bookmark)
# with description, no tags
bookmark = self.setup_bookmark(description="Test description")
self.inline_bookmark_description_test(bookmark)
# no description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
)
self.inline_bookmark_description_test(bookmark)
# with description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="Test description",
tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
)
self.inline_bookmark_description_test(bookmark)
def separate_bookmark_description_test(self, bookmark):
html = self.render_template()
soup = self.make_soup(html)
has_description = bool(bookmark.description)
has_tags = len(bookmark.tags.all()) > 0
# inline description block does not exist
inline_description = soup.select_one(".description.inline")
self.assertIsNone(inline_description)
if not has_description:
# no description element
description = soup.select_one(".description")
self.assertIsNone(description)
else:
# contains description text
description = soup.select_one(".description.separate")
self.assertIsNotNone(description)
self.assertEqual(description.text.strip(), bookmark.description)
if not has_tags:
# no tags element
tags = soup.select_one(".tags")
self.assertIsNone(tags)
else:
# tags element exists
tags = soup.select_one(".tags")
self.assertIsNotNone(tags)
# one link for each tag
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
for tag in bookmark.tags.all():
tag_link = tags.find("a", text=f"#{tag.name}")
self.assertIsNotNone(tag_link)
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
def test_separate_bookmark_description(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
)
profile.save()
# no description, no tags
bookmark = self.setup_bookmark(description="")
self.separate_bookmark_description_test(bookmark)
# with description, no tags
bookmark = self.setup_bookmark(description="Test description")
self.separate_bookmark_description_test(bookmark)
# no description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
)
self.separate_bookmark_description_test(bookmark)
# with description, with tags
Bookmark.objects.all().delete()
bookmark = self.setup_bookmark(
description="Test description",
tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
)
self.separate_bookmark_description_test(bookmark)
def test_bookmark_description_max_lines(self):
self.setup_bookmark()
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list")
style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:1;", style)
profile = self.get_or_create_test_user().profile
profile.bookmark_description_max_lines = 3
profile.save()
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list")
style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
@@ -351,9 +551,58 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertViewLink(html, bookmark)
self.assertBookmarkActions(html, bookmark)
self.assertNoShareInfo(html, bookmark)
def test_hide_view_link(self):
bookmark = self.setup_bookmark()
profile = self.get_or_create_test_user().profile
profile.display_view_bookmark_action = False
profile.save()
html = self.render_template()
self.assertViewLinkCount(html, bookmark, count=0)
self.assertEditLinkCount(html, bookmark, count=1)
self.assertArchiveLinkCount(html, bookmark, count=1)
self.assertDeleteLinkCount(html, bookmark, count=1)
def test_hide_edit_link(self):
bookmark = self.setup_bookmark()
profile = self.get_or_create_test_user().profile
profile.display_edit_bookmark_action = False
profile.save()
html = self.render_template()
self.assertViewLinkCount(html, bookmark, count=1)
self.assertEditLinkCount(html, bookmark, count=0)
self.assertArchiveLinkCount(html, bookmark, count=1)
self.assertDeleteLinkCount(html, bookmark, count=1)
def test_hide_archive_link(self):
bookmark = self.setup_bookmark()
profile = self.get_or_create_test_user().profile
profile.display_archive_bookmark_action = False
profile.save()
html = self.render_template()
self.assertViewLinkCount(html, bookmark, count=1)
self.assertEditLinkCount(html, bookmark, count=1)
self.assertArchiveLinkCount(html, bookmark, count=0)
self.assertDeleteLinkCount(html, bookmark, count=1)
def test_hide_remove_link(self):
bookmark = self.setup_bookmark()
profile = self.get_or_create_test_user().profile
profile.display_remove_bookmark_action = False
profile.save()
html = self.render_template()
self.assertViewLinkCount(html, bookmark, count=1)
self.assertEditLinkCount(html, bookmark, count=1)
self.assertArchiveLinkCount(html, bookmark, count=1)
self.assertDeleteLinkCount(html, bookmark, count=0)
def test_show_share_info_for_non_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
@@ -364,6 +613,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
@@ -539,9 +789,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
self.assertIsNone(bookmark_list)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile
@@ -549,9 +801,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save()
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
self.assertIsNone(bookmark_list)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile
@@ -559,11 +813,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save()
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
html = self.render_template()
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
self.assertIn(
'<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html
)
self.assertIsNotNone(bookmark_list)
def test_toggle_notes_is_visible_by_default(self):
self.setup_bookmark(notes="Test note")
@@ -615,6 +869,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
)
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)

View File

@@ -0,0 +1,21 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.client.force_login(self.get_or_create_test_user())
def test_does_not_render_custom_style_tag_by_default(self):
response = self.client.get(reverse("bookmarks:index"))
self.assertNotContains(response, "<style>")
def test_renders_custom_style_tag_if_user_has_custom_css(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(response, "<style>body { background-color: red; }</style>")

View File

@@ -194,7 +194,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
@@ -229,3 +229,172 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=0)
def test_shared_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.shared", args=["foo"]))
self.assertEqual(response.status_code, 404)
def test_shared_metadata(self):
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
response = self.client.get(feed_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<title>Shared bookmarks</title>")
self.assertContains(response, "<description>All shared bookmarks</description>")
self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
self.assertContains(
response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
)
def test_shared_returns_shared_bookmarks_only(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=False)
self.setup_bookmark()
self.setup_bookmark(shared=False, user=user1)
self.setup_bookmark(shared=True, user=user2)
shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
]
response = self.client.get(
reverse("bookmarks:feeds.shared", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(shared_bookmarks))
for bookmark in shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_shared_with_query(self):
user = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_public_shared_does_not_require_auth(self):
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200)
def test_public_shared_metadata(self):
feed_url = reverse("bookmarks:feeds.public_shared")
response = self.client.get(feed_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<title>Public shared bookmarks</title>")
self.assertContains(
response, "<description>All public shared bookmarks</description>"
)
self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
self.assertContains(
response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
)
def test_public_shared_returns_publicly_shared_bookmarks_only(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=False)
self.setup_bookmark()
self.setup_bookmark(shared=False, user=user1)
self.setup_bookmark(shared=False, user=user2)
self.setup_bookmark(shared=True, user=user2)
self.setup_bookmark(shared=True, user=user3)
public_shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
]
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(public_shared_bookmarks))
for bookmark in public_shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_public_shared_with_query(self):
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)

View File

@@ -0,0 +1,29 @@
from django.test import TestCase, override_settings
from django.urls import path, include
from bookmarks.tests.helpers import HtmlTestMixin
from siteroot.urls import urlpatterns as base_patterns
# Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled
urlpatterns = base_patterns + [path("oidc/", include("mozilla_django_oidc.urls"))]
@override_settings(ROOT_URLCONF=__name__)
class LoginViewTestCase(TestCase, HtmlTestMixin):
def test_should_not_show_oidc_login_by_default(self):
response = self.client.get("/login/")
soup = self.make_soup(response.content.decode())
oidc_login_link = soup.find("a", text="Login with OIDC")
self.assertIsNone(oidc_login_link)
@override_settings(LD_ENABLE_OIDC=True)
def test_should_show_oidc_login_when_enabled(self):
response = self.client.get("/login/")
soup = self.make_soup(response.content.decode())
oidc_login_link = soup.find("a", text="Login with OIDC")
self.assertIsNotNone(oidc_login_link)

View File

@@ -11,9 +11,91 @@ class MetadataViewTestCase(TestCase):
response_body = response.json()
expected_body = {
"short_name": "linkding",
"name": "linkding",
"description": "Self-hosted bookmark service",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/",
"theme_color": "#5856e0",
"background_color": "#ffffff",
"icons": [
{
"src": "/static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any",
},
{
"src": "/static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any",
},
{
"src": "/static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any",
},
{
"src": "/static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable",
},
],
"shortcuts": [
{
"name": "Add bookmark",
"url": "/bookmarks/new",
},
{
"name": "Archived",
"url": "/bookmarks/archived",
},
{
"name": "Unread",
"url": "/bookmarks?unread=yes",
},
{
"name": "Untagged",
"url": "/bookmarks?q=!untagged",
},
{
"name": "Shared",
"url": "/bookmarks/shared",
},
],
"screenshots": [
{
"src": "/static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide",
}
],
"share_target": {
"action": "/bookmarks/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"url": "url",
"text": "url",
"title": "title",
},
},
}
self.assertDictEqual(response_body, expected_body)
@@ -26,8 +108,90 @@ class MetadataViewTestCase(TestCase):
response_body = response.json()
expected_body = {
"short_name": "linkding",
"name": "linkding",
"description": "Self-hosted bookmark service",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/linkding/",
"theme_color": "#5856e0",
"background_color": "#ffffff",
"icons": [
{
"src": "/linkding/static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any",
},
{
"src": "/linkding/static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any",
},
{
"src": "/linkding/static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any",
},
{
"src": "/linkding/static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/linkding/static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/linkding/static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable",
},
],
"shortcuts": [
{
"name": "Add bookmark",
"url": "/linkding/bookmarks/new",
},
{
"name": "Archived",
"url": "/linkding/bookmarks/archived",
},
{
"name": "Unread",
"url": "/linkding/bookmarks?unread=yes",
},
{
"name": "Untagged",
"url": "/linkding/bookmarks?q=!untagged",
},
{
"name": "Shared",
"url": "/linkding/bookmarks/shared",
},
],
"screenshots": [
{
"src": "/linkding/static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide",
}
],
"share_target": {
"action": "/linkding/bookmarks/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"url": "url",
"text": "url",
"title": "title",
},
},
}
self.assertDictEqual(response_body, expected_body)

View File

@@ -0,0 +1,51 @@
import importlib
import os
from django.test import TestCase, override_settings
from django.urls import URLResolver
class OidcSupportTest(TestCase):
def test_should_not_add_oidc_urls_by_default(self):
siteroot_urls = importlib.import_module("siteroot.urls")
importlib.reload(siteroot_urls)
oidc_url_found = any(
isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/"
for urlpattern in siteroot_urls.urlpatterns
)
self.assertFalse(oidc_url_found)
@override_settings(LD_ENABLE_OIDC=True)
def test_should_add_oidc_urls_when_enabled(self):
siteroot_urls = importlib.import_module("siteroot.urls")
importlib.reload(siteroot_urls)
oidc_url_found = any(
isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/"
for urlpattern in siteroot_urls.urlpatterns
)
self.assertTrue(oidc_url_found)
def test_should_not_add_oidc_authentication_backend_by_default(self):
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertListEqual(
["django.contrib.auth.backends.ModelBackend"],
base_settings.AUTHENTICATION_BACKENDS,
)
def test_should_add_oidc_authentication_backend_when_enabled(self):
os.environ["LD_ENABLE_OIDC"] = "True"
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertListEqual(
[
"django.contrib.auth.backends.ModelBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
],
base_settings.AUTHENTICATION_BACKENDS,
)
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable

View File

@@ -24,6 +24,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
form_data = {
"theme": UserProfile.THEME_AUTO,
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
"bookmark_description_max_lines": 1,
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_BLANK,
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
"enable_sharing": False,
@@ -31,7 +33,12 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_favicons": False,
"tag_search": UserProfile.TAG_SEARCH_STRICT,
"display_url": False,
"display_view_bookmark_action": True,
"display_edit_bookmark_action": True,
"display_archive_bookmark_action": True,
"display_remove_bookmark_action": True,
"permanent_notes": False,
"custom_css": "",
}
return {**form_data, **overrides}
@@ -55,6 +62,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"update_profile": "",
"theme": UserProfile.THEME_DARK,
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE,
"bookmark_description_max_lines": 3,
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_SELF,
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
"enable_sharing": True,
@@ -62,7 +71,12 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_favicons": True,
"tag_search": UserProfile.TAG_SEARCH_LAX,
"display_url": True,
"display_view_bookmark_action": False,
"display_edit_bookmark_action": False,
"display_archive_bookmark_action": False,
"display_remove_bookmark_action": False,
"permanent_notes": True,
"custom_css": "body { background-color: #000; }",
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
html = response.content.decode()
@@ -74,6 +88,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.bookmark_date_display, form_data["bookmark_date_display"]
)
self.assertEqual(
self.user.profile.bookmark_description_display,
form_data["bookmark_description_display"],
)
self.assertEqual(
self.user.profile.bookmark_description_max_lines,
form_data["bookmark_description_max_lines"],
)
self.assertEqual(
self.user.profile.bookmark_link_target, form_data["bookmark_link_target"]
)
@@ -90,9 +112,26 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
)
self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
self.assertEqual(self.user.profile.display_url, form_data["display_url"])
self.assertEqual(
self.user.profile.display_view_bookmark_action,
form_data["display_view_bookmark_action"],
)
self.assertEqual(
self.user.profile.display_edit_bookmark_action,
form_data["display_edit_bookmark_action"],
)
self.assertEqual(
self.user.profile.display_archive_bookmark_action,
form_data["display_archive_bookmark_action"],
)
self.assertEqual(
self.user.profile.display_remove_bookmark_action,
form_data["display_remove_bookmark_action"],
)
self.assertEqual(
self.user.profile.permanent_notes, form_data["permanent_notes"]
)
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertInHTML(
"""
<p class="form-input-hint">Profile updated</p>

View File

@@ -74,3 +74,11 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
html,
)
self.assertInHTML(
f'<a href="http://testserver/feeds/{token.key}/shared">Shared bookmarks</a>',
html,
)
self.assertInHTML(
f'<a href="http://testserver/feeds/shared">Public shared bookmarks</a>',
html,
)

View File

@@ -40,7 +40,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
# Should render toasts container
self.assertContains(response, '<div class="toasts">')
# Should render two toasts
self.assertContains(response, '<div class="toast">', count=2)
self.assertContains(response, '<div class="toast d-flex">', count=2)
def test_should_not_render_acknowledged_toasts(self):
self.create_toast(acknowledged=True)
@@ -81,9 +81,9 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
def test_toast_content(self):
toast = self.create_toast()
expected_toast = f"""
<div class="toast">
<div class="toast d-flex">
{toast.message}
<button type="submit" name="toast" value="{toast.id}" class="btn btn-clear float-right"></button>
<button type="submit" name="toast" value="{toast.id}" class="btn btn-clear"></button>
</div>
"""

View File

@@ -4,7 +4,12 @@ from django.views.generic import RedirectView
from bookmarks import views
from bookmarks.api.routes import router
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
from bookmarks.feeds import (
AllBookmarksFeed,
UnreadBookmarksFeed,
SharedBookmarksFeed,
PublicSharedBookmarksFeed,
)
from bookmarks.views import partials
app_name = "bookmarks"
@@ -29,6 +34,16 @@ urlpatterns = [
path("bookmarks/new", views.bookmarks.new, name="new"),
path("bookmarks/close", views.bookmarks.close, name="close"),
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
path(
"bookmarks/<int:bookmark_id>/details",
views.bookmarks.details,
name="details",
),
path(
"bookmarks/<int:bookmark_id>/details_modal",
views.bookmarks.details_modal,
name="details_modal",
),
# Partials
path(
"bookmarks/partials/bookmark-list/active",
@@ -77,6 +92,8 @@ urlpatterns = [
# Feeds
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
path("feeds/<str:feed_key>/shared", SharedBookmarksFeed(), name="feeds.shared"),
path("feeds/shared", PublicSharedBookmarksFeed(), name="feeds.public_shared"),
# Health check
path("health", views.health, name="health"),
# Manifest

View File

@@ -1,5 +1,6 @@
import logging
import re
import unicodedata
from datetime import datetime
from typing import Optional
@@ -111,3 +112,12 @@ def get_safe_return_url(return_url: str, fallback_url: str):
if not return_url or not re.match(r"^/[a-z]+", return_url):
return fallback_url
return return_url
def generate_username(email):
# 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]

View File

@@ -104,6 +104,59 @@ def search_action(request):
return HttpResponseRedirect(url)
def _details(request, bookmark_id: int, template: str):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
is_owner = bookmark.owner == request.user
is_shared = (
request.user.is_authenticated
and bookmark.shared
and bookmark.owner.profile.enable_sharing
)
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist")
edit_return_url = get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:details", args=[bookmark_id])
)
delete_return_url = get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:index")
)
# handles status actions form
if request.method == "POST":
if not is_owner:
raise Http404("Bookmark does not exist")
bookmark.is_archived = request.POST.get("is_archived") == "on"
bookmark.unread = request.POST.get("unread") == "on"
bookmark.shared = request.POST.get("shared") == "on"
bookmark.save()
return HttpResponseRedirect(edit_return_url)
return render(
request,
template,
{
"bookmark": bookmark,
"edit_return_url": edit_return_url,
"delete_return_url": delete_return_url,
},
)
def details(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details.html")
def details_modal(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details_modal.html")
def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings

View File

@@ -5,9 +5,95 @@ from django.conf import settings
def manifest(request):
response = {
"short_name": "linkding",
"name": "linkding",
"description": "Self-hosted bookmark service",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/" + settings.LD_CONTEXT_PATH,
"theme_color": "#5856e0",
"background_color": (
"#161822" if request.user_profile.theme == "dark" else "#ffffff"
),
"icons": [
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable",
},
],
"shortcuts": [
{
"name": "Add bookmark",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/new",
},
{
"name": "Archived",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/archived",
},
{
"name": "Unread",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks?unread=yes",
},
{
"name": "Untagged",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks?q=!untagged",
},
{
"name": "Shared",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/shared",
},
],
"screenshots": [
{
"src": "/"
+ settings.LD_CONTEXT_PATH
+ "static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide",
}
],
"share_target": {
"action": "/" + settings.LD_CONTEXT_PATH + "bookmarks/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"url": "url",
"text": "url",
"title": "title",
},
},
}
return JsonResponse(response, status=200)

View File

@@ -96,7 +96,13 @@ class BookmarkListContext:
)
self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display
self.description_display = user_profile.bookmark_description_display
self.description_max_lines = user_profile.bookmark_description_max_lines
self.show_url = user_profile.display_url
self.show_view_action = user_profile.display_view_bookmark_action
self.show_edit_action = user_profile.display_edit_bookmark_action
self.show_archive_action = user_profile.display_archive_bookmark_action
self.show_remove_action = user_profile.display_remove_bookmark_action
self.show_favicons = user_profile.enable_favicons
self.show_notes = user_profile.permanent_notes

View File

@@ -114,6 +114,12 @@ def integrations(request):
unread_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.unread", args=[feed_token.key])
)
shared_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.shared", args=[feed_token.key])
)
public_shared_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.public_shared")
)
return render(
request,
"settings/integrations.html",
@@ -122,6 +128,8 @@ def integrations(request):
"api_token": api_token.key,
"all_feed_url": all_feed_url,
"unread_feed_url": unread_feed_url,
"shared_feed_url": shared_feed_url,
"public_shared_feed_url": public_shared_feed_url,
},
)

View File

@@ -1,16 +1,22 @@
FROM node:18.18.0-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.js package.json package-lock.json ./
RUN npm install
COPY rollup.config.mjs package.json package-lock.json ./
RUN npm ci
# copy files needed for JS build
COPY bookmarks/frontend ./bookmarks/frontend
# run build
RUN npm run build
FROM python:3.10.13-alpine3.18 AS python-base
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev
# Use 3.11 for now, as django4-background-tasks doesn't work with 3.12 yet
FROM python:3.11.8-alpine3.19 AS python-base
# Add required packages
# alpine-sdk linux-headers pkgconfig: build Python packages from source
# libpq-dev: build Postgres client from source
# icu-dev sqlite-dev: build Sqlite ICU extension
# libffi-dev openssl-dev rust cargo: build Python cryptography from source
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo
WORKDIR /etc/linkding
@@ -32,7 +38,7 @@ RUN python manage.py compilescss && \
FROM python-base AS prod-deps
COPY requirements.txt ./requirements.txt
# replace psycopg2-binary with psycopg2
# Need to build psycopg2 from source for ARM platforms
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
RUN mkdir /opt/venv && \
python -m venv --upgrade-deps --copies /opt/venv && \
@@ -61,9 +67,9 @@ 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.10.13-alpine3.18 AS final
FROM python:3.11.8-alpine3.19 AS final
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap
RUN apk update && apk add bash curl icu libpq mailcap libssl3
# create www-data user and group
RUN set -x ; \
addgroup -g 82 -S www-data ; \

View File

@@ -1,16 +1,24 @@
FROM node:18.18.0-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.js package.json package-lock.json ./
RUN npm install
COPY rollup.config.mjs package.json package-lock.json ./
RUN npm ci
# copy files needed for JS build
COPY bookmarks/frontend ./bookmarks/frontend
# run build
RUN npm run build
FROM python:3.10.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential libpq-dev
# Use 3.11 for now, as django4-background-tasks doesn't work with 3.12 yet
FROM python:3.11.8-slim-bookworm AS python-base
# Add required packages
# build-essential pkg-config: build Python packages from source
# libpq-dev: build Postgres client from source
# libicu-dev libsqlite3-dev: build Sqlite ICU extension
# llibffi-dev libssl-dev curl rustup: build Python cryptography from source
RUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev libicu-dev libsqlite3-dev wget unzip libffi-dev libssl-dev curl
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /etc/linkding
@@ -32,7 +40,7 @@ RUN python manage.py compilescss && \
FROM python-base AS prod-deps
COPY requirements.txt ./requirements.txt
# replace psycopg2-binary with psycopg2
# Need to build psycopg2 from source for ARM platforms
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
RUN mkdir /opt/venv && \
python -m venv --upgrade-deps --copies /opt/venv && \
@@ -41,9 +49,6 @@ RUN mkdir /opt/venv && \
FROM python-base AS compile-icu
RUN apt-get update && apt-get -y install libicu-dev libsqlite3-dev wget unzip
WORKDIR /etc/linkding
# Defines SQLite version
# Since this is only needed for downloading the header files this probably
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
@@ -64,8 +69,8 @@ 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.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev curl
FROM python:3.11.8-slim-bookworm as final
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv

View File

@@ -48,6 +48,7 @@ Example response:
"notes": "Example notes",
"website_title": "Website title",
"website_description": "Website description",
"web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
"is_archived": false,
"unread": false,
"shared": false,

View File

@@ -94,6 +94,31 @@ For example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_A
By default, the logout redirects to the login URL, which means the user will be automatically authenticated again.
Instead, you might want to configure the logout URL of the auth proxy here.
### `LD_ENABLE_OIDC`
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.
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.
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.
The following options can be configured:
- `OIDC_OP_AUTHORIZATION_ENDPOINT` - The authorization endpoint of the OIDC provider.
- `OIDC_OP_TOKEN_ENDPOINT` - The token endpoint of the OIDC provider.
- `OIDC_OP_USER_ENDPOINT` - The user info endpoint of the OIDC provider.
- `OIDC_OP_JWKS_ENDPOINT` - The JWKS endpoint of the OIDC provider.
- `OIDC_RP_CLIENT_ID` - The client ID of the application.
- `OIDC_RP_CLIENT_SECRET` - The client secret of the application.
- `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`.
### `LD_CSRF_TRUSTED_ORIGINS`
Values: `String` | Default = None

View File

@@ -25,13 +25,13 @@ linkding includes a CLI command for creating a backup copy of the database.
To create a backup, execute the following command:
```shell
docker exec -it linkding python manage.py backup backup.sqlite3
docker exec -it linkding python manage.py backup /etc/linkding/data/backup.sqlite3
```
This creates a `backup.sqlite3` file in the Docker container.
This creates a `backup.sqlite3` file in the Docker container under `/etc/linkding/data`.
To copy the backup file to your host system, execute the following command:
```shell
docker cp linkding:/etc/linkding/backup.sqlite3 backup.sqlite3
docker cp linkding:/etc/linkding/data/backup.sqlite3 backup.sqlite3
```
This copies the backup file from the Docker container to the current folder on your host system.
Now you can move that file to your backup location.

1408
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.24.2",
"version": "1.26.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -13,19 +13,18 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "MIT",
"bugs": {
"url": "https://github.com/sissbruecker/linkding/issues"
},
"homepage": "https://github.com/sissbruecker/linkding#readme",
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^13.1.3",
"rollup": "^2.70.1",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/wasm-node": "^4.13.0",
"rollup-plugin-svelte": "^7.2.0",
"spectre.css": "^0.5.8",
"svelte": "^3.49.0"
"svelte": "^4.0.0"
},
"devDependencies": {
"prettier": "^3.0.2"

View File

@@ -6,13 +6,13 @@
#
asgiref==3.7.2
# via django
black==24.1.1
black==24.3.0
# via -r requirements.dev.in
click==8.1.7
# via black
coverage==7.4.1
# via -r requirements.dev.in
django==5.0.2
django==5.0.3
# via
# django-appconf
# django-debug-toolbar

View File

@@ -8,6 +8,7 @@ django-widget-tweaks
django4-background-tasks
djangorestframework
Markdown
mozilla-django-oidc
psycopg2-binary
python-dateutil
requests

View File

@@ -14,17 +14,25 @@ bleach-allowlist==1.0.3
# via -r requirements.in
certifi==2023.11.17
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via waybackpy
confusable-homoglyphs==3.2.0
# via django-registration
django==5.0.2
cryptography==42.0.5
# via
# josepy
# mozilla-django-oidc
# pyopenssl
django==5.0.3
# via
# -r requirements.in
# django-registration
# djangorestframework
# mozilla-django-oidc
django-registration==3.4
# via -r requirements.in
django-sass-processor==1.4
@@ -37,10 +45,18 @@ djangorestframework==3.14.0
# via -r requirements.in
idna==3.6
# via requests
josepy==1.14.0
# via mozilla-django-oidc
markdown==3.5.2
# via -r requirements.in
mozilla-django-oidc==4.0.1
# via -r requirements.in
psycopg2-binary==2.9.9
# via -r requirements.in
pycparser==2.21
# via cffi
pyopenssl==24.1.0
# via josepy
python-dateutil==2.8.2
# via -r requirements.in
pytz==2023.3.post1
@@ -48,6 +64,7 @@ pytz==2023.3.post1
requests==2.31.0
# via
# -r requirements.in
# mozilla-django-oidc
# waybackpy
six==1.16.0
# via

View File

@@ -1,40 +0,0 @@
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'bookmarks/frontend/index.js',
output: {
sourcemap: true,
format: 'iife',
name: 'linkding',
// Generate bundle in static folder to that it is picked up by Django static files finder
file: 'bookmarks/static/bundle.js'
},
plugins: [
svelte({
emitCss: false
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve({
browser: true,
dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/')
}),
commonjs(),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};

37
rollup.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'bookmarks/frontend/index.js',
output: {
sourcemap: true,
format: 'iife',
name: 'linkding',
// Generate bundle in static folder to that it is picked up by Django static files finder
file: 'bookmarks/static/bundle.js',
},
plugins: [
svelte({
emitCss: false,
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve({
browser: true,
}),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
],
watch: {
clearScreen: false,
},
};

10
scripts/release.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
version=$(<version.txt)
git push origin master
git tag v${version}
git push origin v${version}
./scripts/build-docker.sh
echo "Done ✅"

7
scripts/setup-oicd.sh Normal file
View File

@@ -0,0 +1,7 @@
# Example setup for OIDC with Zitadel
export LD_ENABLE_OIDC=True
export OIDC_USE_PKCE=True
export OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8080/oauth/v2/authorize
export OIDC_OP_TOKEN_ENDPOINT=http://localhost:8080/oauth/v2/token
export OIDC_OP_USER_ENDPOINT=http://localhost:8080/oidc/v1/userinfo
export OIDC_RP_CLIENT_ID=258574559115018243@linkding

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
"rest_framework",
"rest_framework.authtoken",
"background_task",
"mozilla_django_oidc",
]
MIDDLEWARE = [
@@ -130,8 +131,6 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
# Turn off SASS compilation by default
SASS_PROCESSOR_ENABLED = False
# Location where generated CSS files are saved
SASS_PROCESSOR_ROOT = os.path.join(BASE_DIR, "bookmarks", "static")
# Add SASS preprocessor finder to resolve generated CSS
STATICFILES_FINDERS = [
@@ -182,6 +181,24 @@ MAX_ATTEMPTS = 5
BACKGROUND_TASK_RUN_ASYNC = True
BACKGROUND_TASK_ASYNC_THREADS = 2
# Enable OICD support if configured
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1")
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
if LD_ENABLE_OIDC:
AUTHENTICATION_BACKENDS.append("mozilla_django_oidc.auth.OIDCAuthenticationBackend")
OIDC_USERNAME_ALGO = "bookmarks.utils.generate_username"
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("OIDC_OP_AUTHORIZATION_ENDPOINT")
OIDC_OP_TOKEN_ENDPOINT = os.getenv("OIDC_OP_TOKEN_ENDPOINT")
OIDC_OP_USER_ENDPOINT = os.getenv("OIDC_OP_USER_ENDPOINT")
OIDC_OP_JWKS_ENDPOINT = os.getenv("OIDC_OP_JWKS_ENDPOINT")
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_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
# Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
@@ -194,9 +211,7 @@ if LD_ENABLE_AUTH_PROXY:
# in the LD_AUTH_PROXY_USERNAME_HEADER request header
MIDDLEWARE.append("bookmarks.middlewares.CustomRemoteUserMiddleware")
# Configure auth backend that does not require a password credential
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.RemoteUserBackend",
]
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.RemoteUserBackend"]
# Configure logout URL
if LD_AUTH_PROXY_LOGOUT_URL:
LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL

View File

@@ -19,16 +19,27 @@ from django.contrib.auth import views as auth_views
from django.urls import path, include
from bookmarks.admin import linkding_admin_site
from .settings import ALLOW_REGISTRATION, DEBUG
class LinkdingLoginView(auth_views.LoginView):
"""
Custom login view to lazily add additional context data
Allows to override settings in tests
"""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["allow_registration"] = settings.ALLOW_REGISTRATION
context["enable_oidc"] = settings.LD_ENABLE_OIDC
return context
urlpatterns = [
path("admin/", linkding_admin_site.urls),
path(
"login/",
auth_views.LoginView.as_view(
redirect_authenticated_user=True,
extra_context=dict(allow_registration=ALLOW_REGISTRATION),
),
LinkdingLoginView.as_view(redirect_authenticated_user=True),
name="login",
),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
@@ -45,13 +56,16 @@ urlpatterns = [
path("", include("bookmarks.urls")),
]
if settings.LD_ENABLE_OIDC:
urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls")))
if settings.LD_CONTEXT_PATH:
urlpatterns = [path(settings.LD_CONTEXT_PATH, include(urlpatterns))]
if DEBUG:
if settings.DEBUG:
import debug_toolbar
urlpatterns.append(path("__debug__/", include(debug_toolbar.urls)))
if ALLOW_REGISTRATION:
if settings.ALLOW_REGISTRATION:
urlpatterns.append(path("", include("django_registration.backends.one_step.urls")))

View File

@@ -1 +1 @@
1.24.2
1.26.0