Compare commits

...

17 Commits

Author SHA1 Message Date
Sascha Ißbrücker
c919e79759 Bump version 2024-04-01 15:47:03 +02:00
Sascha Ißbrücker
8ff9b42a79 Update README.md 2024-04-01 15:27:58 +02:00
Sascha Ißbrücker
4280ab40c6 Archive snapshots of websites locally (#672)
* Add basic HTML snapshots

* Implement asset list

* Add snapshot creation tests

* Add deletion tests

* Show file size

* Remove snapshots

* Create new snapshots

* Switch to single-file

* CSS tweak

* Remove auto refresh

* Show delete link when there is no file yet

* Add current date to display name

* Add flag for snapshot support

* Add option for disabling automatic snapshots

* Make snapshots sharable

* Document image variants

* Update README.md

* Add migrations

* Fix tests
2024-04-01 15:19:38 +02:00
tianheg
db1906942a Update Railway hosting option (#670) 2024-03-31 16:25:24 +02:00
Sascha Ißbrücker
69877a32e5 Add how to for increasing the font size (#667) 2024-03-30 11:43:15 +01:00
Sascha Ißbrücker
e5a9a772f0 Update CHANGELOG.md 2024-03-30 11:07:40 +01:00
tianheg
2f56d418cf Add new hosting option (#661)
* Add new hosting option

* Update Railway template url
2024-03-30 10:37:17 +01:00
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
79 changed files with 3535 additions and 317 deletions

View File

@@ -1,5 +1,49 @@
# Changelog
## v1.26.0 (30/03/2024)
### What's Changed
* Add option for showing bookmark description as separate block by @sissbruecker in https://github.com/sissbruecker/linkding/pull/663
* Add bookmark details view by @sissbruecker in https://github.com/sissbruecker/linkding/pull/665
* Make bookmark list actions configurable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/666
* Bump black from 24.1.1 to 24.3.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/662
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.25.0...v1.26.0
---
## 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

@@ -33,16 +33,13 @@ The name comes from:
**Feature Overview:**
- Clean UI optimized for readability
- Organize bookmarks with tags
- Add notes using Markdown
- Read it later functionality
- Share bookmarks with other users
- Bulk editing
- Bulk editing, Markdown notes, read it later functionality
- Share bookmarks with other users or guests
- 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/)
- Automatically archive websites, either as local HTML file or on Internet Archive
- 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
@@ -62,27 +59,45 @@ The Docker image is compatible with ARM platforms, so it can be run on a Raspber
linkding uses an SQLite database by default.
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
<details>
<summary>🧪 Alpine-based image</summary>
The default Docker image (`latest` tag) is based on a slim variant of Debian Linux.
Alternatively, there is an image based on Alpine Linux (`latest-alpine` tag) which has a smaller size, resulting in a smaller download and less disk space required.
The Alpine image is currently about 45 MB in compressed size, compared to about 130 MB for the Debian image.
To use it, replace the `latest` tag with `latest-alpine`, either in the CLI command below when using Docker, or in the `docker-compose.yml` file when using docker-compose.
> [!WARNING]
> The image is currently considered experimental in order to gather feedback and iron out any issues.
> Only use it if you are comfortable running experimental software or want to help out with testing.
> While there should be no issues with creating new installations, there might be issues when migrating existing installations.
> If you plan to migrate your existing installation, make sure to create proper [backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) first.
</details>
### Using Docker
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
<table>
<thead>
<tr>
<th>Tag</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>latest</code></td>
<td>Provides the basic functionality of linkding</td>
</tr>
<tr>
<td><code>latest-plus</code></td>
<td>
Includes feature for archiving websites as HTML snapshots
<ul>
<li>Significantly larger image size as it includes a Chromium installation</li>
<li>Requires more runtime memory to run Chromium</li>
<li>Requires more disk space for storing HTML snapshots</li>
</ul>
</td>
</tr>
<tr>
<td><code>latest-alpine</code></td>
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
</tr>
<tr>
<td><code>latest-plus-alpine</code></td>
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
</tr>
</tbody>
</table>
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
```shell
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
```
@@ -184,6 +199,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
## Documentation

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,6 +324,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()
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
self.locate_bulk_edit_select_all().click()

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

@@ -1,4 +1,4 @@
import { registerBehavior, swap } from "./index";
import { registerBehavior, swapContent } from "./index";
class BookmarkPage {
constructor(element) {
@@ -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) {
@@ -33,8 +37,8 @@ class BookmarkPage {
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swap(this.bookmarkList, bookmarkListHtml);
swap(this.tagCloud, tagCloudHtml);
swapContent(this.bookmarkList, bookmarkListHtml);
swapContent(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(

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

@@ -0,0 +1,54 @@
import { registerBehavior, swap } from "./index";
class FormBehavior {
constructor(element) {
this.element = element;
element.addEventListener("submit", this.onFormSubmit.bind(this));
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.element.action;
const formData = new FormData(this.element);
if (event.submitter) {
formData.append(event.submitter.name, event.submitter.value);
}
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
// Dispatch refresh events
const refreshEvents = this.element.getAttribute("refresh-events");
if (refreshEvents) {
refreshEvents.split(",").forEach((eventName) => {
document.dispatchEvent(new CustomEvent(eventName));
});
}
// Refresh form
await this.refresh();
}
async refresh() {
const refreshUrl = this.element.getAttribute("refresh-url");
const html = await fetch(refreshUrl).then((response) => response.text());
swap(this.element, html);
}
}
class FormAutoSubmitBehavior {
constructor(element) {
this.element = element;
this.element.addEventListener("change", () => {
const form = this.element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true }));
});
}
}
registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior);

View File

@@ -12,7 +12,14 @@ export function applyBehaviors(container, behaviorNames = null) {
behaviorNames.forEach((behaviorName) => {
const behavior = behaviorRegistry[behaviorName];
const elements = container.querySelectorAll(`[${behaviorName}]`);
const elements = Array.from(
container.querySelectorAll(`[${behaviorName}]`),
);
// Include the container element if it has the behavior
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
elements.push(container);
}
elements.forEach((element) => {
element.__behaviors = element.__behaviors || [];
@@ -31,6 +38,13 @@ export function applyBehaviors(container, behaviorNames = null) {
}
export function swap(element, html) {
const dom = new DOMParser().parseFromString(html, "text/html");
const newElement = dom.body.firstChild;
element.replaceWith(newElement);
applyBehaviors(newElement);
}
export function swapContent(element, html) {
element.innerHTML = html;
applyBehaviors(element);
}

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

@@ -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,10 +1,11 @@
import './behaviors/bookmark-page';
import './behaviors/bulk-edit';
import './behaviors/confirm-button';
import './behaviors/dropdown';
import './behaviors/modal';
import './behaviors/global-shortcuts';
import './behaviors/tag-autocomplete';
export { default as TagAutoComplete } from './components/TagAutocomplete.svelte';
export { default as SearchAutoComplete } from './components/SearchAutoComplete.svelte';
export { ApiClient } from './api';
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/modal";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";
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,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

@@ -0,0 +1,43 @@
# Generated by Django 5.0.2 on 2024-03-31 08:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0029_bookmark_list_actions_toast"),
]
operations = [
migrations.CreateModel(
name="BookmarkAsset",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_created", models.DateTimeField(auto_now_add=True)),
("file", models.CharField(blank=True, max_length=2048)),
("file_size", models.IntegerField(null=True)),
("asset_type", models.CharField(max_length=64)),
("content_type", models.CharField(max_length=128)),
("display_name", models.CharField(blank=True, max_length=2048)),
("status", models.CharField(max_length=64)),
("gzip", models.BooleanField(default=False)),
(
"bookmark",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="bookmarks.bookmark",
),
),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-04-01 10:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0030_bookmarkasset"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="enable_automatic_html_snapshots",
field=models.BooleanField(default=True),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2024-04-01 12:17
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="html_snapshots_hint",
message="This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.",
owner=user,
)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -1,18 +1,22 @@
import binascii
import logging
import os
from typing import List
import binascii
from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.http import QueryDict
from bookmarks.utils import unique
from bookmarks.validators import BookmarkURLValidator
logger = logging.getLogger(__name__)
class Tag(models.Model):
name = models.CharField(max_length=64)
@@ -85,6 +89,47 @@ class Bookmark(models.Model):
return self.resolved_title + " (" + self.url[:30] + "...)"
class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot"
CONTENT_TYPE_HTML = "text/html"
STATUS_PENDING = "pending"
STATUS_COMPLETE = "complete"
STATUS_FAILURE = "failure"
bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE)
date_created = models.DateTimeField(auto_now_add=True, null=False)
file = models.CharField(max_length=2048, blank=True, null=False)
file_size = models.IntegerField(null=True)
asset_type = models.CharField(max_length=64, blank=False, null=False)
content_type = models.CharField(max_length=128, blank=False, null=False)
display_name = models.CharField(max_length=2048, blank=True, null=False)
status = models.CharField(max_length=64, blank=False, null=False)
gzip = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.file:
try:
file_path = os.path.join(settings.LD_ASSET_FOLDER, self.file)
if os.path.isfile(file_path):
self.file_size = os.path.getsize(file_path)
except Exception:
pass
super().save(*args, **kwargs)
@receiver(post_delete, sender=BookmarkAsset)
def bookmark_asset_deleted(sender, instance, **kwargs):
if instance.file:
filepath = os.path.join(settings.LD_ASSET_FOLDER, instance.file)
if os.path.isfile(filepath):
try:
os.remove(filepath)
except Exception as error:
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
@@ -278,6 +323,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 +359,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,9 +391,14 @@ 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)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
class UserProfileForm(forms.ModelForm):
@@ -341,13 +407,20 @@ class UserProfileForm(forms.ModelForm):
fields = [
"theme",
"bookmark_date_display",
"bookmark_description_display",
"bookmark_description_max_lines",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"enable_automatic_html_snapshots",
"display_url",
"display_view_bookmark_action",
"display_edit_bookmark_action",
"display_archive_bookmark_action",
"display_remove_bookmark_action",
"permanent_notes",
"custom_css",
]

View File

@@ -34,6 +34,9 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
tasks.create_web_archive_snapshot(current_user, bookmark, False)
# Load favicon
tasks.load_favicon(current_user, bookmark)
# Create HTML snapshot
if current_user.profile.enable_automatic_html_snapshots:
tasks.create_html_snapshot(bookmark)
return bookmark

View File

@@ -0,0 +1,32 @@
import gzip
import shutil
import subprocess
import os
from django.conf import settings
class MonolithError(Exception):
pass
# Monolith isn't used at the moment, as the local snapshot implementation
# switched to single-file after the prototype. Keeping this around in case
# it turns out to be useful in the future.
def create_snapshot(url: str, filepath: str):
monolith_path = settings.LD_MONOLITH_PATH
monolith_options = settings.LD_MONOLITH_OPTIONS
temp_filepath = filepath + ".tmp"
try:
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
subprocess.run(command, check=True, shell=True)
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)
except subprocess.CalledProcessError as error:
raise MonolithError(f"Failed to create snapshot: {error.stderr}")

View File

@@ -0,0 +1,33 @@
import gzip
import os
import shutil
import subprocess
from django.conf import settings
class SingeFileError(Exception):
pass
def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH
singlefile_options = settings.LD_SINGLEFILE_OPTIONS
temp_filepath = filepath + ".tmp"
try:
command = f"{singlefile_path} '{url}' {singlefile_options} {temp_filepath}"
subprocess.run(command, check=True, shell=True)
# single-file doesn't return exit codes apparently, so check if the file was created
if not os.path.exists(temp_filepath):
raise SingeFileError("Failed to create snapshot")
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)
except subprocess.CalledProcessError as error:
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")

View File

@@ -1,4 +1,5 @@
import logging
import os
import waybackpy
from background_task import background
@@ -7,10 +8,11 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
from django.utils import timezone, formats
import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import favicon_loader
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__)
@@ -193,3 +195,64 @@ def _schedule_refresh_favicons_task(user_id: int):
tasks.append(task)
Task.objects.bulk_create(tasks)
def is_html_snapshot_feature_active() -> bool:
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active():
return
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
content_type="text/html",
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
asset.save()
_create_html_snapshot_task(asset.id)
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
@background()
def _create_html_snapshot_task(asset_id: int):
try:
asset = BookmarkAsset.objects.get(id=asset_id)
except BookmarkAsset.DoesNotExist:
return
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
try:
filename = _generate_snapshot_filename(asset)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
singlefile.create_snapshot(asset.bookmark.url, filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
logger.info(
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
)
except singlefile.SingeFileError as error:
asset.status = BookmarkAsset.STATUS_FAILURE
logger.error(
f"Failed to create HTML snapshot for bookmark. url={asset.bookmark.url}",
exc_info=error,
)
asset.save()

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,126 @@
/* 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;
}
.assets {
margin-top: $unit-2;
}
.assets .asset {
display: flex;
align-items: center;
gap: $unit-3;
padding: $unit-2 0;
border-top: $unit-o solid $border-color-light;
}
.assets .asset:last-child {
border-bottom: $unit-o solid $border-color-light;
}
.assets .asset-icon {
display: flex;
align-items: center;
justify-content: center;
}
.assets .asset-text {
flex: 1 1 0;
}
.assets .asset-text .filesize {
color: $gray-color;
margin-left: $unit-2;
}
.assets .asset-actions, .assets-actions {
display: flex;
gap: $unit-3;
align-items: center;
}
.assets .asset-actions .btn, .assets-actions .btn {
height: unset;
padding: 0;
border: none;
}
.assets-actions {
margin-top: $unit-2;
}
.tags a {
color: $alternative-color;
}
.status form {
display: flex;
gap: $unit-2;
}
.status .form-group, .status .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,9 +1,9 @@
.settings-page {
section.content-area {
margin-bottom: $unit-12;
margin-bottom: $unit-10;
h2 {
margin-bottom: $unit-4;
margin-bottom: $unit-3;
}
}

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 class="bookmark-details page">
{% if details.is_editable %}
{% include 'bookmarks/details/actions.html' %}
{% endif %}
{% include 'bookmarks/details/title.html' %}
<div>
{% include 'bookmarks/details/form.html' %}
</div>
</div>
{% endblock %}

View File

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

View File

@@ -0,0 +1,42 @@
{% if asset.content_type == 'text/html' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M2 21v-6"/>
<path d="M5 15v6"/>
<path d="M2 18h3"/>
<path d="M20 15v6h2"/>
<path d="M13 21v-6l2 3l2 -3v6"/>
<path d="M7.5 15h3"/>
<path d="M9 15v6"/>
</svg>
{% elif asset.content_type == 'application/pdf' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6"/>
<path d="M17 18h2"/>
<path d="M20 15h-3v6"/>
<path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z"/>
</svg>
{% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 8h.01"/>
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"/>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/>
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>
</svg>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/>
</svg>
{% endif %}

View File

@@ -0,0 +1,37 @@
{% if details.assets %}
<div class="assets">
{% for asset in details.assets %}
<div class="asset" data-asset-id="{{ asset.id }}">
<div class="asset-icon {{ asset.icon_classes }}">
{% include 'bookmarks/details/asset_icon.html' %}
</div>
<div class="asset-text truncate {{ asset.text_classes }}">
<span>
{{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %}
{% if asset.status == 'failure' %}(failed){% endif %}
</span>
{% if asset.file_size %}
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %}
</div>
<div class="asset-actions">
{% if asset.file %}
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a>
{% endif %}
{% if details.is_editable %}
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
Remove
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if details.is_editable %}
<div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-link">Create HTML snapshot</button>
</div>
{% endif %}

View File

@@ -0,0 +1,99 @@
{% load static %}
{% load shared %}
<form ld-form action="{% url 'bookmarks:details' details.bookmark.id %}"
refresh-url="{% url 'bookmarks:partials.details_form' details.bookmark.id %}"
refresh-events="bookmark-page-refresh"
method="post">
<div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %}
<img class="favicon" src="{% static details.bookmark.favicon_file %}" alt="">
{% endif %}
<span>{{ details.bookmark.url }}</span>
</a>
{% if details.bookmark.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %}
<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 details.is_editable %}
<div class="status col-2">
<dt>Status</dt>
<dd class="d-flex" style="gap: .8rem">
{% csrf_token %}
<div class="form-group">
<label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="is_archived"
{% if details.bookmark.is_archived %}checked{% endif %}>
<i class="form-icon"></i> Archived
</label>
</div>
<div class="form-group">
<label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="unread"
{% if details.bookmark.unread %}checked{% endif %}>
<i class="form-icon"></i> Unread
</label>
</div>
{% if details.profile.enable_sharing %}
<div class="form-group">
<label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="shared"
{% if details.bookmark.shared %}checked{% endif %}>
<i class="form-icon"></i> Shared
</label>
</div>
{% endif %}
</dd>
</div>
{% endif %}
{% if details.show_files %}
<div class="files col-2">
<dt>Files</dt>
<dd>
{% include 'bookmarks/details/assets.html' %}
</dd>
</div>
{% endif %}
{% if details.bookmark.tag_names %}
<div class="tags col-1">
<dt>Tags</dt>
<dd>
{% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</dd>
</div>
{% endif %}
<div class="date-added col-1">
<dt>Date added</dt>
<dd>
<span>{{ details.bookmark.date_added }}</span>
</dd>
</div>
{% if details.bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ details.bookmark.resolved_description }}</dd>
</div>
{% endif %}
{% if details.bookmark.notes %}
<div class="notes col-2">
<dt>Notes</dt>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
</div>
{% endif %}
</dl>
</form>

View File

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

View File

@@ -0,0 +1,27 @@
<div 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/form.html' %}
</div>
</div>
{% if details.is_editable %}
<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

@@ -105,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

@@ -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,18 +163,30 @@
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" }}
{% if has_snapshot_support %}
<div class="form-group">
<label for="{{ form.enable_automatic_html_snapshots.id_for_label }}" class="form-checkbox">
{{ form.enable_automatic_html_snapshots }}
<i class="form-icon"></i> Automatically create HTML snapshots
</label>
<div class="form-input-hint">
Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be
created manually in the details view of a bookmark.
</div>
</div>
{% endif %}
<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>
</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 %}
@@ -195,10 +246,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">
@@ -237,10 +284,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;
@@ -252,6 +301,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

@@ -1,6 +1,6 @@
import random
import logging
import datetime
from datetime import datetime
from typing import List
from bs4 import BeautifulSoup
@@ -10,7 +10,7 @@ from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, Tag
from bookmarks.models import Bookmark, BookmarkAsset, Tag
class BookmarkFactoryMixin:
@@ -133,6 +133,38 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
def setup_asset(
self,
bookmark: Bookmark,
date_created: datetime = None,
file: str = None,
file_size: int = None,
asset_type: str = BookmarkAsset.TYPE_SNAPSHOT,
content_type: str = "image/html",
display_name: str = None,
status: str = BookmarkAsset.STATUS_COMPLETE,
gzip: bool = False,
):
if date_created is None:
date_created = timezone.now()
if not file:
file = get_random_string(length=32)
if not display_name:
display_name = file
asset = BookmarkAsset(
bookmark=bookmark,
date_created=date_created,
file=file,
file_size=file_size,
asset_type=asset_type,
content_type=content_type,
display_name=display_name,
status=status,
gzip=gzip,
)
asset.save()
return asset
def setup_tag(self, user: User = None, name: str = ""):
if user is None:
user = self.get_or_create_test_user()

View File

@@ -0,0 +1,125 @@
import os
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip"
self.setup_asset_file(filename)
asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset
def test_view_access(self):
# own bookmark
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# unshared, sharing enabled
profile = other_user.profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=False)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 200)
def test_view_access_guest_user(self):
self.client.logout()
# unshared, sharing disabled
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing disabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# unshared, sharing enabled
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing enabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# unshared, public sharing enabled
profile.enable_public_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, public sharing enabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 200)

View File

@@ -0,0 +1,89 @@
import os
from django.conf import settings
from django.test import TestCase
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.services import bookmarks
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip"
self.setup_asset_file(filename)
asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset
def test_delete_bookmark_deletes_asset_file(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
)
bookmark.delete()
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
)
def test_bulk_delete_bookmarks_deletes_asset_files(self):
bookmark1 = self.setup_bookmark()
asset1 = self.setup_asset_with_file(bookmark1)
bookmark2 = self.setup_bookmark()
asset2 = self.setup_asset_with_file(bookmark2)
bookmark3 = self.setup_bookmark()
asset3 = self.setup_asset_with_file(bookmark3)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
)
bookmarks.delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
)
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
)
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
)
def test_save_updates_file_size(self):
# File does not exist initially
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
self.assertIsNone(asset.file_size)
# Add file, save again
self.setup_asset_file(asset.file)
asset.save()
self.assertEqual(asset.file_size, 4)
# Create asset with initial file
asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
self.assertEqual(asset.file_size, 4)

View File

@@ -0,0 +1,781 @@
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import formats
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks
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_view_name(self):
return "bookmarks:details_modal"
def get_base_url(self, bookmark):
return reverse(self.get_view_name(), args=[bookmark.id])
def get_details_form(self, soup, bookmark):
expected_url = reverse("bookmarks:details", args=[bookmark.id])
return soup.find("form", {"action": expected_url})
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 find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id})
def details_route_access_test(self, view_name: str, shareable: bool):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(reverse(view_name, 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(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404)
# non-existent bookmark
response = self.client.get(reverse(view_name, args=[9999]))
self.assertEqual(response.status_code, 404)
# guest user
self.client.logout()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404 if shareable else 302)
def details_route_sharing_access_test(self, view_name: str, shareable: bool):
# shared bookmark, sharing disabled
other_user = self.setup_user()
bookmark = self.setup_bookmark(shared=True, user=other_user)
response = self.client.get(reverse(view_name, 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(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200 if shareable else 404)
# shared bookmark, guest user, no public sharing
self.client.logout()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404 if shareable else 302)
# shared bookmark, guest user, public sharing
profile.enable_public_sharing = True
profile.save()
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200 if shareable else 302)
def test_access(self):
self.details_route_access_test(self.get_view_name(), True)
def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True)
def test_form_partial_access(self):
# form partial is only used when submitting forms, which should be only
# accessible to the owner of the bookmark. As such assume it requires
# login.
self.details_route_access_test("bookmarks:partials.details_form", False)
def test_form_partial_access_with_sharing(self):
# form partial is only used when submitting forms, which should be only
# accessible to the owner of the bookmark. As such assume it requires
# login.
self.details_route_sharing_access_test("bookmarks:partials.details_form", False)
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)
form = self.get_details_form(soup, bookmark)
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")
self.assertIsNotNone(section)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
self.assertIsNone(section)
# 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")
self.assertIsNone(section)
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)
def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_visibility(self):
# no assets
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list)
# with assets
bookmark = self.setup_bookmark()
self.setup_asset(bookmark)
soup = self.get_details(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list(self):
bookmark = self.setup_bookmark()
assets = [
self.setup_asset(bookmark),
self.setup_asset(bookmark),
self.setup_asset(bookmark),
]
soup = self.get_details(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
for asset in assets:
asset_item = self.find_asset(asset_list, asset)
self.assertIsNotNone(asset_item)
asset_icon = asset_item.select_one(".asset-icon svg")
self.assertIsNotNone(asset_icon)
asset_text = asset_item.select_one(".asset-text span")
self.assertIsNotNone(asset_text)
self.assertIn(asset.display_name, asset_text.text)
view_url = reverse("bookmarks:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url})
self.assertIsNotNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_without_file(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
asset.file = ""
asset.save()
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_url = reverse("bookmarks:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url})
self.assertIsNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_status(self):
bookmark = self.setup_bookmark()
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, pending_asset)
asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(queued)", asset_text.text)
asset_item = self.find_asset(soup, failed_asset)
asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(failed)", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_file_size(self):
bookmark = self.setup_bookmark()
asset1 = self.setup_asset(bookmark, file_size=None)
asset2 = self.setup_asset(bookmark, file_size=54639)
asset3 = self.setup_asset(bookmark, file_size=11492020)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset1)
asset_text = asset_item.select_one(".asset-text")
self.assertEqual(asset_text.text.strip(), asset1.display_name)
asset_item = self.find_asset(soup, asset2)
asset_text = asset_item.select_one(".asset-text")
self.assertIn("53.4\xa0KB", asset_text.text)
asset_item = self.find_asset(soup, asset3)
asset_text = asset_item.select_one(".asset-text")
self.assertIn("11.0\xa0MB", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_actions_visibility(self):
bookmark = self.setup_bookmark()
# with file
asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNotNone(view_link)
self.assertIsNotNone(delete_button)
# without file
asset.file = ""
asset.save()
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNone(view_link)
self.assertIsNotNone(delete_button)
# shared bookmark
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
bookmark = self.setup_bookmark(shared=True, user=other_user)
asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNotNone(view_link)
self.assertIsNone(delete_button)
# shared bookmark, guest user
self.client.logout()
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNotNone(view_link)
self.assertIsNone(delete_button)
def test_remove_asset(self):
# remove asset
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
# non-existent asset
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
self.assertEqual(response.status_code, 404)
# post without asset ID does not remove
asset = self.setup_asset(bookmark)
response = self.client.post(self.get_base_url(bookmark))
self.assertEqual(response.status_code, 302)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
# guest user
asset = self.setup_asset(bookmark)
self.client.logout()
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self):
with patch.object(
tasks, "_create_html_snapshot_task"
) as mock_create_html_snapshot_task:
bookmark = self.setup_bookmark()
response = self.client.post(
self.get_base_url(bookmark), {"create_snapshot": ""}
)
self.assertEqual(response.status_code, 302)
mock_create_html_snapshot_task.assert_called_with(bookmark.id)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)

View File

@@ -0,0 +1,6 @@
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
def get_view_name(self):
return "bookmarks:details"

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

@@ -105,6 +105,24 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_create_should_load_html_snapshot(self):
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark_data = Bookmark(url="https://example.com")
bookmark = create_bookmark(bookmark_data, "tag1,tag2", self.user)
mock_create_html_snapshot.assert_called_once_with(bookmark)
def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_automatic_html_snapshots = False
profile.save()
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark_data = Bookmark(url="https://example.com")
create_bookmark(bookmark_data, "tag1,tag2", self.user)
mock_create_html_snapshot.assert_not_called()
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(
tasks, "create_web_archive_snapshot"
@@ -167,6 +185,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_update_should_not_create_html_snapshot(self):
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark = self.setup_bookmark()
bookmark.title = "updated title"
update_bookmark(bookmark, "tag1,tag2", self.user)
mock_create_html_snapshot.assert_not_called()
def test_archive_bookmark(self):
bookmark = Bookmark(
url="https://example.com",

View File

@@ -1,18 +1,20 @@
import datetime
import os.path
from dataclasses import dataclass
from typing import Any
from unittest import mock
import waybackpy
from background_task.models import Task
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback
from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks, singlefile
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
@@ -626,3 +628,86 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_create_pending_asset(self):
bookmark = self.setup_bookmark()
with mock.patch("bookmarks.services.monolith.create_snapshot"):
tasks.create_html_snapshot(bookmark)
self.assertEqual(BookmarkAsset.objects.count(), 1)
tasks.create_html_snapshot(bookmark)
self.assertEqual(BookmarkAsset.objects.count(), 2)
assets = BookmarkAsset.objects.filter(bookmark=bookmark)
for asset in assets:
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_update_file_info(self):
bookmark = self.setup_bookmark(url="https://example.com")
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
tasks.create_html_snapshot(bookmark)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
asset.date_created = datetime.datetime(2021, 1, 2, 3, 44, 55)
asset.save()
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
self.run_pending_task(tasks._create_html_snapshot_task)
mock_create.assert_called_once_with(
"https://example.com",
os.path.join(settings.LD_ASSET_FOLDER, expected_filename),
)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_error(self):
bookmark = self.setup_bookmark(url="https://example.com")
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
mock_create.side_effect = singlefile.SingeFileError("Error")
tasks.create_html_snapshot(bookmark)
self.run_pending_task(tasks._create_html_snapshot_task)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
self.assertEqual(asset.file, "")
self.assertFalse(asset.gzip)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_missing_bookmark(self):
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
tasks._create_html_snapshot_task(123)
self.run_pending_task(tasks._create_html_snapshot_task)
mock_create.assert_not_called()
@override_settings(LD_ENABLE_SNAPSHOTS=False)
def test_create_html_snapshot_should_not_run_when_single_file_is_disabled(
self,
):
bookmark = self.setup_bookmark()
tasks.create_html_snapshot(bookmark)
self.assertEqual(Task.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True, LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_html_snapshot_should_not_run_when_background_tasks_are_disabled(
self,
):
bookmark = self.setup_bookmark()
tasks.create_html_snapshot(bookmark)
self.assertEqual(Task.objects.count(), 0)

View File

@@ -23,37 +23,37 @@ class MetadataViewTestCase(TestCase):
"src": "/static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any"
"purpose": "any",
},
{
"src": "/static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
"purpose": "any",
},
{
"src": "/static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
"purpose": "any",
},
{
"src": "/static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable"
"purpose": "maskable",
},
{
"src": "/static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
"purpose": "maskable",
},
{
"src": "/static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
"purpose": "maskable",
},
],
"shortcuts": [
@@ -76,14 +76,14 @@ class MetadataViewTestCase(TestCase):
{
"name": "Shared",
"url": "/bookmarks/shared",
}
},
],
"screenshots": [
{
"src": "/static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide"
"form_factor": "wide",
}
],
"share_target": {
@@ -94,8 +94,8 @@ class MetadataViewTestCase(TestCase):
"url": "url",
"text": "url",
"title": "title",
}
}
},
},
}
self.assertDictEqual(response_body, expected_body)
@@ -120,37 +120,37 @@ class MetadataViewTestCase(TestCase):
"src": "/linkding/static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any"
"purpose": "any",
},
{
"src": "/linkding/static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
"purpose": "any",
},
{
"src": "/linkding/static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
"purpose": "any",
},
{
"src": "/linkding/static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable"
"purpose": "maskable",
},
{
"src": "/linkding/static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
"purpose": "maskable",
},
{
"src": "/linkding/static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
"purpose": "maskable",
},
],
"shortcuts": [
@@ -173,14 +173,14 @@ class MetadataViewTestCase(TestCase):
{
"name": "Shared",
"url": "/linkding/bookmarks/shared",
}
},
],
"screenshots": [
{
"src": "/linkding/static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide"
"form_factor": "wide",
}
],
"share_target": {
@@ -191,7 +191,7 @@ class MetadataViewTestCase(TestCase):
"url": "url",
"text": "url",
"title": "title",
}
}
},
},
}
self.assertDictEqual(response_body, expected_body)

View File

@@ -0,0 +1,44 @@
import gzip
import os
from unittest import mock
import subprocess
from django.test import TestCase
from bookmarks.services import monolith
class MonolithServiceTestCase(TestCase):
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
html_filepath = "temp.html.gz"
temp_html_filepath = "temp.html.gz.tmp"
def tearDown(self):
if os.path.exists(self.html_filepath):
os.remove(self.html_filepath)
if os.path.exists(self.temp_html_filepath):
os.remove(self.temp_html_filepath)
def create_test_file(self, *args, **kwargs):
with open(self.temp_html_filepath, "w") as file:
file.write(self.html_content)
def test_create_snapshot(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = self.create_test_file
monolith.create_snapshot("http://example.com", self.html_filepath)
self.assertTrue(os.path.exists(self.html_filepath))
self.assertFalse(os.path.exists(self.temp_html_filepath))
with gzip.open(self.html_filepath, "rt") as file:
content = file.read()
self.assertEqual(content, self.html_content)
def test_create_snapshot_failure(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(monolith.MonolithError):
monolith.create_snapshot("http://example.com", self.html_filepath)

View File

@@ -24,13 +24,20 @@ 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,
"enable_public_sharing": False,
"enable_favicons": False,
"enable_automatic_html_snapshots": True,
"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": "",
}
@@ -56,13 +63,20 @@ 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,
"enable_public_sharing": True,
"enable_favicons": True,
"enable_automatic_html_snapshots": False,
"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; }",
}
@@ -76,6 +90,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,8 +112,28 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.enable_favicons, form_data["enable_favicons"]
)
self.assertEqual(
self.user.profile.enable_automatic_html_snapshots,
form_data["enable_automatic_html_snapshots"],
)
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"]
)
@@ -249,6 +291,35 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
count=0,
)
def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(
self,
):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"""
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
""",
html,
count=0,
)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_automatic_html_snapshots_should_be_visible_when_snapshots_supported(
self,
):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"""
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
""",
html,
count=1,
)
def test_about_shows_version_info(self):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()

View File

@@ -0,0 +1,50 @@
import gzip
import os
from unittest import mock
import subprocess
from django.test import TestCase
from bookmarks.services import singlefile
class SingleFileServiceTestCase(TestCase):
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
html_filepath = "temp.html.gz"
temp_html_filepath = "temp.html.gz.tmp"
def tearDown(self):
if os.path.exists(self.html_filepath):
os.remove(self.html_filepath)
if os.path.exists(self.temp_html_filepath):
os.remove(self.temp_html_filepath)
def create_test_file(self, *args, **kwargs):
with open(self.temp_html_filepath, "w") as file:
file.write(self.html_content)
def test_create_snapshot(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = self.create_test_file
singlefile.create_snapshot("http://example.com", self.html_filepath)
self.assertTrue(os.path.exists(self.html_filepath))
self.assertFalse(os.path.exists(self.temp_html_filepath))
with gzip.open(self.html_filepath, "rt") as file:
content = file.read()
self.assertEqual(content, self.html_content)
def test_create_snapshot_failure(self):
# subprocess fails - which it probably doesn't as single-file doesn't return exit codes
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(singlefile.SingeFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)
# so also check that it raises error if output file isn't created
with mock.patch("subprocess.run") as mock_run:
with self.assertRaises(singlefile.SingeFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)

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

@@ -34,6 +34,22 @@ 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",
),
# Assets
path(
"assets/<int:asset_id>",
views.assets.view,
name="assets.view",
),
# Partials
path(
"bookmarks/partials/bookmark-list/active",
@@ -65,6 +81,11 @@ urlpatterns = [
partials.shared_tag_cloud,
name="partials.tag_cloud.shared",
),
path(
"bookmarks/partials/details-form/<int:bookmark_id>",
partials.details_form,
name="partials.details_form",
),
# Settings
path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"),

View File

@@ -1,3 +1,4 @@
from .assets import *
from .bookmarks import *
from .settings import *
from .toasts import *

43
bookmarks/views/assets.py Normal file
View File

@@ -0,0 +1,43 @@
import gzip
import os
from django.conf import settings
from django.http import (
HttpResponse,
Http404,
)
from bookmarks.models import BookmarkAsset
def view(request, asset_id: int):
try:
asset = BookmarkAsset.objects.get(pk=asset_id)
except BookmarkAsset.DoesNotExist:
raise Http404("Asset does not exist")
bookmark = asset.bookmark
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")
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if not os.path.exists(filepath):
raise Http404("Asset file does not exist")
if asset.gzip:
with gzip.open(filepath, "rb") as f:
content = f.read()
else:
with open(filepath, "rb") as f:
content = f.read()
return HttpResponse(content, content_type=asset.content_type)

View File

@@ -12,7 +12,13 @@ from django.shortcuts import render
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkSearch, build_tag_string
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkForm,
BookmarkSearch,
build_tag_string,
)
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
@@ -28,6 +34,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks,
unshare_bookmarks,
)
from bookmarks.services import tasks
from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts
@@ -104,6 +111,67 @@ 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")
if request.method == "POST":
if not is_owner:
raise Http404("Bookmark does not exist")
return_url = get_safe_return_url(
request.GET.get("return_url"),
reverse("bookmarks:details", args=[bookmark.id]),
)
if "remove_asset" in request.POST:
asset_id = request.POST["remove_asset"]
try:
asset = bookmark.bookmarkasset_set.get(pk=asset_id)
except BookmarkAsset.DoesNotExist:
raise Http404("Asset does not exist")
asset.delete()
if "create_snapshot" in request.POST:
tasks.create_html_snapshot(bookmark)
else:
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(return_url)
details_context = contexts.BookmarkDetailsContext(request, bookmark)
return render(
request,
template,
{
"details": details_context,
},
)
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

@@ -11,43 +11,45 @@ def manifest(request):
"display": "standalone",
"scope": "/" + settings.LD_CONTEXT_PATH,
"theme_color": "#5856e0",
"background_color": "#161822" if request.user_profile.theme == "dark" else "#ffffff",
"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"
"purpose": "any",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
"purpose": "any",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
"purpose": "any",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable"
"purpose": "maskable",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
"purpose": "maskable",
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
"purpose": "maskable",
},
],
"shortcuts": [
@@ -70,14 +72,16 @@ def manifest(request):
{
"name": "Shared",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/shared",
}
},
],
"screenshots": [
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/linkding-screenshot.png",
"src": "/"
+ settings.LD_CONTEXT_PATH
+ "static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide"
"form_factor": "wide",
}
],
"share_target": {
@@ -88,8 +92,8 @@ def manifest(request):
"url": "url",
"text": "url",
"title": "title",
}
}
},
},
}
return JsonResponse(response, status=200)

View File

@@ -1,6 +1,8 @@
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.shortcuts import render
from bookmarks.models import Bookmark
from bookmarks.views.partials import contexts
@@ -56,3 +58,15 @@ def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required
def details_form(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
details_context = contexts.BookmarkDetailsContext(request, bookmark)
return render(request, "bookmarks/details/form.html", {"details": details_context})

View File

@@ -6,11 +6,13 @@ from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import models
from django.urls import reverse
from django.conf import settings
from bookmarks import queries
from bookmarks import utils
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkSearch,
User,
UserProfile,
@@ -96,7 +98,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
@@ -268,3 +276,55 @@ class SharedTagCloudContext(TagCloudContext):
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, self.search, public_only
)
class BookmarkAssetItem:
def __init__(self, asset: BookmarkAsset):
self.asset = asset
self.id = asset.id
self.display_name = asset.display_name
self.content_type = asset.content_type
self.file = asset.file
self.file_size = asset.file_size
self.status = asset.status
icon_classes = []
text_classes = []
if asset.status == BookmarkAsset.STATUS_PENDING:
icon_classes.append("text-gray")
text_classes.append("text-gray")
elif asset.status == BookmarkAsset.STATUS_FAILURE:
icon_classes.append("text-error")
text_classes.append("text-error")
else:
icon_classes.append("text-primary")
self.icon_classes = " ".join(icon_classes)
self.text_classes = " ".join(text_classes)
class BookmarkDetailsContext:
def __init__(self, request: WSGIRequest, bookmark: Bookmark):
user = request.user
user_profile = request.user_profile
self.edit_return_url = utils.get_safe_return_url(
request.GET.get("return_url"),
reverse("bookmarks:details", args=[bookmark.id]),
)
self.delete_return_url = utils.get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:index")
)
self.bookmark = bookmark
self.profile = request.user_profile
self.is_editable = bookmark.owner == user
self.sharing_enabled = user_profile.enable_sharing
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
# For now hide files section if snapshots are not supported
self.show_files = settings.LD_ENABLE_SNAPSHOTS
self.assets = [
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
]

View File

@@ -12,7 +12,7 @@ from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark, BookmarkSearch, UserProfileForm, FeedToken
from bookmarks.models import Bookmark, UserProfileForm, FeedToken
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
from bookmarks.utils import app_version
@@ -24,6 +24,7 @@ logger = logging.getLogger(__name__)
def general(request):
profile_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
update_profile_success_message = None
refresh_favicons_success_message = None
import_success_message = _find_message_with_tag(
@@ -53,6 +54,7 @@ def general(request):
{
"form": profile_form,
"enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support,
"update_profile_success_message": update_profile_success_message,
"refresh_favicons_success_message": refresh_favicons_success_message,
"import_success_message": import_success_message,

View File

@@ -7,6 +7,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
mkdir -p data
# Create favicon folder if it does not exist
mkdir -p data/favicons
# Create assets folder if it does not exist
mkdir -p data/assets
# Generate secret key file if it does not exist
python manage.py generate_secret_key

View File

@@ -67,7 +67,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.11.8-alpine3.19 AS final
FROM python:3.11.8-alpine3.19 AS linkding
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap libssl3
# create www-data user and group
@@ -96,3 +96,10 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
CMD ["./bootstrap.sh"]
FROM linkding AS linkding-plus
# install node, chromium and single-file
RUN apk update && apk add nodejs npm chromium && npm install -g single-file-cli
# enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -69,7 +69,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.11.8-slim-bookworm as final
FROM python:3.11.8-slim-bookworm as linkding
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
WORKDIR /etc/linkding
# copy prod dependencies
@@ -94,3 +94,9 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
CMD ["./bootstrap.sh"]
FROM linkding AS linkding-plus
# install node, chromium and single-file
RUN apt-get update && apt-get -y install nodejs npm chromium && npm install -g single-file-cli
# enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -2,6 +2,13 @@
Collection of tips and tricks around using linkding.
**Table of Contents:**
* [Using the bookmarklet on Android/Chrome](#using-the-bookmarklet-on-androidchrome)
* [Using HTTP Shortcuts app on Android](#using-http-shortcuts-app-on-android)
* [Create a share action on iOS for adding bookmarks to linkding](#create-a-share-action-on-ios-for-adding-bookmarks-to-linkding)
* [Increase the font size](#increase-the-font-size)
## Using the bookmarklet on Android/Chrome
This how-to explains the usage of the standard linkding bookmarklet on Android / Chrome.
@@ -59,3 +66,27 @@ At the bottom of the share sheet there is a button for configuring share actions
> [!NOTE]
> You can also check the [Community section](https://github.com/sissbruecker/linkding#community) for other pre-made shortcuts that you can use.
## Increase the font size
The font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:
```css
html {
--font-size: 0.75rem;
--font-size-sm: 0.7rem;
--font-size-lg: 0.9rem;
}
.bookmark-list {
line-height: 1.15rem;
}
.tag-cloud {
line-height: 1.15rem;
}
```
You can adjust the `--font-size`, `--font-size-sm` and `--font-size-lg` variables to your liking.
Note that increasing the font might also require you to adjust the line-height in certain places to better separate texts from each other.
As an example, the above also increases the font size and line height of the bookmark list and tag cloud.

View File

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

View File

@@ -6,7 +6,7 @@
#
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

View File

@@ -2,14 +2,29 @@
version=$(<version.txt)
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
# Base image
docker buildx build --target linkding --platform linux/amd64,linux/arm64,linux/arm/v7 \
-f docker/default.Dockerfile \
-t sissbruecker/linkding:latest \
-t sissbruecker/linkding:$version \
--push .
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
docker buildx build --target linkding --platform linux/amd64,linux/arm64,linux/arm/v7 \
-f docker/alpine.Dockerfile \
-t sissbruecker/linkding:latest-alpine \
-t sissbruecker/linkding:$version-alpine \
--push .
# Plus image with support for single-file snapshots
# Needs checking if this works with ARMv7, excluded for now
docker buildx build --target linkding-plus --platform linux/amd64,linux/arm64 \
-f docker/default.Dockerfile \
-t sissbruecker/linkding:latest-plus \
-t sissbruecker/linkding:$version-plus \
--push .
docker buildx build --target linkding-plus --platform linux/amd64,linux/arm64 \
-f docker/alpine.Dockerfile \
-t sissbruecker/linkding:latest-plus-alpine \
-t sissbruecker/linkding:$version-plus-alpine \
--push .

View File

@@ -269,3 +269,20 @@ LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
"True",
"1",
)
# Asset / snapshot settings
LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
True,
"True",
"1",
)
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
LD_SINGLEFILE_OPTIONS = os.getenv("LD_SINGLEFILE_OPTIONS", "")
# Monolith isn't used at the moment, as the local snapshot implementation
# switched to single-file after the prototype. Keeping this around in case
# it turns out to be useful in the future.
LD_MONOLITH_PATH = os.getenv("LD_MONOLITH_PATH", "monolith")
LD_MONOLITH_OPTIONS = os.getenv("LD_MONOLITH_OPTIONS", "-a -v -s")

View File

@@ -1 +1 @@
1.25.0
1.27.0