Compare commits

..

10 Commits

Author SHA1 Message Date
Sascha Ißbrücker
e50912df12 Bump version 2024-04-14 20:48:30 +02:00
Sascha Ißbrücker
393d688247 Fix directory name 2024-04-14 20:31:53 +02:00
Sascha Ißbrücker
6e38587174 Fix missing home directory in background tasks 2024-04-14 20:28:39 +02:00
dependabot[bot]
123c6fe02a Bump idna from 3.6 to 3.7 (#694)
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 14:41:53 +02:00
Sascha Ißbrücker
1b7731e506 Refresh file list when there are queued snapshots (#697)
* add destroy hook

* refresh details modal in interval

* refactor to refresh assets list

* disable create snapshot button when there is a pending snapshot
2024-04-14 14:41:22 +02:00
Sascha Ißbrücker
df9f0095cc Add button for creating missing HTML snapshots (#696)
* add button for creating missing HTML snapshots

* refactor messages in settings view

* show alternative text when there are no missing snapshots
2024-04-14 13:21:15 +02:00
Sascha Ißbrücker
25470edb2c Remove ads and cookie banners from HTML snapshots (#695)
* integrate ublock with single-file

* reuse chromium profile
2024-04-14 13:09:46 +02:00
Sascha Ißbrücker
22a1fc80ad Update README.md 2024-04-14 06:44:08 +02:00
Sascha Ißbrücker
65f0eb2a04 Refactor client-side fetch logic (#693)
* extract generic behaviors

* preserve query string when refreshing content

* refactor details modal refresh

* refactor bulk edit

* update tests

* restore tag modal

* Make IntelliJ aware of custom attributes

* improve e2e test coverage
2024-04-11 19:07:20 +02:00
Sascha Ißbrücker
82f86bf537 Update CHANGELOG.md 2024-04-09 20:46:59 +02:00
56 changed files with 1252 additions and 592 deletions

3
.gitignore vendored
View File

@@ -191,3 +191,6 @@ typings/
/tmp /tmp
# Database file # Database file
/data /data
# ublock + chromium
/uBlock0.chromium
/chromium-profile

View File

@@ -1,5 +1,23 @@
# Changelog # Changelog
## v1.28.0 (09/04/2024)
### What's Changed
* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684
* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686
* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687
* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688
* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691
* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689
### New Contributors
* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684
* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0
---
## v1.27.1 (07/04/2024) ## v1.27.1 (07/04/2024)
### What's Changed ### What's Changed

View File

@@ -45,7 +45,7 @@ The name comes from:
- Admin panel for user self-service and raw data access - Admin panel for user self-service and raw data access
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it) **Demo:** https://demo.linkding.link/
**Screenshot:** **Screenshot:**

View File

@@ -1,3 +1,4 @@
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import sync_playwright, expect
@@ -44,14 +45,17 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click() details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertReloads(0)
# unarchive # unarchive
url = reverse("bookmarks:archived") url = reverse("bookmarks:archived")
self.page.goto(self.live_server_url + url) self.page.goto(self.live_server_url + url)
self.resetReloads()
details_modal = self.open_details_modal(bookmark) details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click() details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertReloads(0)
def test_toggle_unread(self): def test_toggle_unread(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -66,11 +70,13 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal.get_by_text("Unread").click() details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).to_be_visible() expect(bookmark_item.get_by_text("Unread")).to_be_visible()
self.assertReloads(0)
# mark as read # mark as read
details_modal.get_by_text("Unread").click() details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible() expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
self.assertReloads(0)
def test_toggle_shared(self): def test_toggle_shared(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -89,11 +95,13 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
details_modal.get_by_text("Shared").click() details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).to_be_visible() expect(bookmark_item.get_by_text("Shared")).to_be_visible()
self.assertReloads(0)
# unshare bookmark # unshare bookmark
details_modal.get_by_text("Shared").click() details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title) bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible() expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
self.assertReloads(0)
def test_edit_return_url(self): def test_edit_return_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -131,3 +139,33 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertEqual(Bookmark.objects.count(), 0) self.assertEqual(Bookmark.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_remove_snapshot(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)
asset_list = details_modal.locator(".assets")
# No snapshots initially
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
expect(snapshot).not_to_be_visible()
# Create snapshot
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
self.assertReloads(0)
# Has new snapshots
expect(snapshot).to_be_visible()
# Create snapshot
asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click()
# Snapshot is removed
expect(snapshot).not_to_be_visible()
self.assertReloads(0)

View File

@@ -194,7 +194,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input") checkboxes = page.locator("label.bulk-edit-checkbox input")
self.assertEqual(6, checkboxes.count()) self.assertEqual(6, checkboxes.count())
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()
@@ -264,13 +264,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
# Hide select across by toggling a single bookmark # Hide select across by toggling a single bookmark
self.locate_bookmark("Bookmark 1").locator( self.locate_bookmark("Bookmark 1").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible() expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked # Show select across again, verify it is unchecked
self.locate_bookmark("Bookmark 1").locator( self.locate_bookmark("Bookmark 1").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked() expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
@@ -297,7 +297,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
# Verify bulk edit checkboxes are reset # Verify bulk edit checkboxes are reset
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input") checkboxes = page.locator("label.bulk-edit-checkbox input")
self.assertEqual(31, checkboxes.count()) self.assertEqual(31, checkboxes.count())
for i in range(checkboxes.count()): for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked() expect(checkboxes.nth(i)).not_to_be_checked()

View File

@@ -169,7 +169,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator( self.locate_bookmark("Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Archive") self.select_bulk_action("Archive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -187,7 +187,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator( self.locate_bookmark("Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -230,7 +230,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator( self.locate_bookmark("Archived Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Unarchive") self.select_bulk_action("Unarchive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
@@ -248,7 +248,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator( self.locate_bookmark("Archived Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]" "label.bulk-edit-checkbox"
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()

View File

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

View File

@@ -39,6 +39,9 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def assertReloads(self, count: int): def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count) self.assertEqual(self.num_loads, count)
def resetReloads(self):
self.num_loads = 0
def locate_bookmark_list(self): def locate_bookmark_list(self):
return self.page.locator("ul[ld-bookmark-list]") return self.page.locator("ul[ld-bookmark-list]")
@@ -62,7 +65,7 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
return self.page.locator(".bulk-edit-bar") return self.page.locator(".bulk-edit-bar")
def locate_bulk_edit_select_all(self): def locate_bulk_edit_select_all(self):
return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]") return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
def locate_bulk_edit_select_across(self): def locate_bulk_edit_select_across(self):
return self.locate_bulk_edit_bar().locator("label.select-across") return self.locate_bulk_edit_bar().locator("label.select-across")

View File

@@ -1,67 +1,8 @@
import { registerBehavior, swapContent } from "./index"; import { Behavior, registerBehavior } from "./index";
class BookmarkPage { class BookmarkItem extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.form = element.querySelector("form.bookmark-actions");
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
document.addEventListener("bookmark-page-refresh", () => {
this.refresh();
});
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.form.action;
const formData = new FormData(this.form);
formData.append(event.submitter.name, event.submitter.value);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
await this.refresh();
}
async refresh() {
const query = window.location.search;
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
const tagsUrl = this.element.getAttribute("tags-url");
Promise.all([
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swapContent(this.bookmarkList, bookmarkListHtml);
swapContent(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(
"ul[data-bookmarks-total]",
);
const bookmarksTotal =
(listElement && listElement.dataset.bookmarksTotal) || 0;
this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", {
bubbles: true,
detail: { bookmarksTotal },
}),
);
});
}
}
registerBehavior("ld-bookmark-page", BookmarkPage);
class BookmarkItem {
constructor(element) {
this.element = element;
// Toggle notes // Toggle notes
const notesToggle = element.querySelector(".toggle-notes"); const notesToggle = element.querySelector(".toggle-notes");
@@ -72,9 +13,11 @@ class BookmarkItem {
// Add tooltip to title if it is truncated // Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a"); const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span"); const titleSpan = titleAnchor.querySelector("span");
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) { requestAnimationFrame(() => {
titleAnchor.dataset.tooltip = titleSpan.textContent; if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
} titleAnchor.dataset.tooltip = titleSpan.textContent;
}
});
} }
onToggleNotes(event) { onToggleNotes(event) {

View File

@@ -1,46 +1,60 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class BulkEdit { class BulkEdit extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.active = false; this.active = false;
this.actionSelect = element.querySelector("select[name='bulk_action']");
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
this.selectAcross = element.querySelector("label.select-across");
element.addEventListener( this.onToggleActive = this.onToggleActive.bind(this);
"bulk-edit-toggle-active", this.onToggleAll = this.onToggleAll.bind(this);
this.onToggleActive.bind(this), this.onToggleBookmark = this.onToggleBookmark.bind(this);
); this.onActionSelected = this.onActionSelected.bind(this);
element.addEventListener(
"bulk-edit-toggle-all",
this.onToggleAll.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-bookmark",
this.onToggleBookmark.bind(this),
);
element.addEventListener(
"bookmark-list-updated",
this.onListUpdated.bind(this),
);
this.actionSelect.addEventListener( this.init();
"change", // Reset when bookmarks are refreshed
this.onActionSelected.bind(this), document.addEventListener("refresh-bookmark-list-done", () => this.init());
);
} }
get allCheckbox() { init() {
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input"); // Update elements
} this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
this.actionSelect = this.element.querySelector(
"select[name='bulk_action']",
);
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
this.selectAcross = this.element.querySelector("label.select-across");
this.allCheckbox = this.element.querySelector(
".bulk-edit-checkbox.all input",
);
this.bookmarkCheckboxes = Array.from(
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
get bookmarkCheckboxes() { // Remove previous listeners if elements are the same
return [ this.activeToggle.removeEventListener("click", this.onToggleActive);
...this.element.querySelectorAll( this.actionSelect.removeEventListener("change", this.onActionSelected);
"[ld-bulk-edit-checkbox]:not([all]) input", this.allCheckbox.removeEventListener("change", this.onToggleAll);
), this.bookmarkCheckboxes.forEach((checkbox) => {
]; checkbox.removeEventListener("change", this.onToggleBookmark);
});
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
// Add new listeners
this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", this.onToggleBookmark);
});
} }
onToggleActive() { onToggleActive() {
@@ -81,16 +95,6 @@ class BulkEdit {
} }
} }
onListUpdated(event) {
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const total = event.detail.bookmarksTotal;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
updateSelectAcross(allChecked) { updateSelectAcross(allChecked) {
if (allChecked) { if (allChecked) {
this.selectAcross.classList.remove("d-none"); this.selectAcross.classList.remove("d-none");
@@ -109,33 +113,4 @@ class BulkEdit {
} }
} }
class BulkEditActiveToggle {
constructor(element) {
this.element = element;
element.addEventListener("click", this.onClick.bind(this));
}
onClick() {
this.element.dispatchEvent(
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
);
}
}
class BulkEditCheckbox {
constructor(element) {
this.element = element;
element.addEventListener("change", this.onChange.bind(this));
}
onChange() {
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
this.element.dispatchEvent(
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
);
}
}
registerBehavior("ld-bulk-edit", BulkEdit); registerBehavior("ld-bulk-edit", BulkEdit);
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);

View File

@@ -1,25 +1,29 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior { class ConfirmButtonBehavior extends Behavior {
constructor(element) { constructor(element) {
const button = element; super(element);
button.dataset.type = button.type; element.dataset.type = element.type;
button.dataset.name = button.name; element.dataset.name = element.name;
button.dataset.value = button.value; element.dataset.value = element.value;
button.removeAttribute("type"); element.removeAttribute("type");
button.removeAttribute("name"); element.removeAttribute("name");
button.removeAttribute("value"); element.removeAttribute("value");
button.addEventListener("click", this.onClick.bind(this)); element.addEventListener("click", this.onClick.bind(this));
this.button = button; }
destroy() {
Behavior.interacting = false;
} }
onClick(event) { onClick(event) {
event.preventDefault(); event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span"); const container = document.createElement("span");
container.className = "confirmation"; container.className = "confirmation";
const icon = this.button.getAttribute("confirm-icon"); const icon = this.element.getAttribute("ld-confirm-icon");
if (icon) { if (icon) {
const iconElement = document.createElementNS( const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg", "http://www.w3.org/2000/svg",
@@ -31,27 +35,27 @@ class ConfirmButtonBehavior {
container.append(iconElement); container.append(iconElement);
} }
const question = this.button.getAttribute("confirm-question"); const question = this.element.getAttribute("ld-confirm-question");
if (question) { if (question) {
const questionElement = document.createElement("span"); const questionElement = document.createElement("span");
questionElement.innerText = question; questionElement.innerText = question;
container.append(question); container.append(question);
} }
const buttonClasses = Array.from(this.button.classList.values()) const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn")) .filter((cls) => cls.startsWith("btn"))
.join(" "); .join(" ");
const cancelButton = document.createElement(this.button.nodeName); const cancelButton = document.createElement(this.element.nodeName);
cancelButton.type = "button"; cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel"; cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`; cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this)); cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName); const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.button.dataset.type; confirmButton.type = this.element.dataset.type;
confirmButton.name = this.button.dataset.name; confirmButton.name = this.element.dataset.name;
confirmButton.value = this.button.dataset.value; confirmButton.value = this.element.dataset.value;
confirmButton.innerText = question ? "Yes" : "Confirm"; confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses; confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this)); confirmButton.addEventListener("click", this.reset.bind(this));
@@ -59,14 +63,15 @@ class ConfirmButtonBehavior {
container.append(cancelButton, confirmButton); container.append(cancelButton, confirmButton);
this.container = container; this.container = container;
this.button.before(container); this.element.before(container);
this.button.classList.add("d-none"); this.element.classList.add("d-none");
} }
reset() { reset() {
setTimeout(() => { setTimeout(() => {
Behavior.interacting = false;
this.container.remove(); this.container.remove();
this.button.classList.remove("d-none"); this.element.classList.remove("d-none");
}); });
} }
} }

View File

@@ -1,8 +1,8 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class DropdownBehavior { class DropdownBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.opened = false; this.opened = false;
this.onOutsideClick = this.onOutsideClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this);

View File

@@ -0,0 +1,48 @@
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
class FetchBehavior extends Behavior {
constructor(element) {
super(element);
const eventName = element.getAttribute("ld-on");
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
this.onFetch = this.onFetch.bind(this);
this.onInterval = this.onInterval.bind(this);
element.addEventListener(eventName, this.onFetch);
if (interval) {
this.intervalId = setInterval(this.onInterval, interval);
}
}
destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
async onFetch(maybeEvent) {
if (maybeEvent) {
maybeEvent.preventDefault();
}
const url = this.element.getAttribute("ld-fetch");
const html = await fetch(url).then((response) => response.text());
const target = this.element.getAttribute("ld-target");
const select = this.element.getAttribute("ld-select");
swap(this.element, html, { target, select });
const events = this.element.getAttribute("ld-fire");
fireEvents(events);
}
onInterval() {
if (Behavior.interacting) {
return;
}
this.onFetch();
}
}
registerBehavior("ld-fetch", FetchBehavior);

View File

@@ -1,12 +1,13 @@
import { registerBehavior, swap } from "./index"; import { Behavior, fireEvents, registerBehavior } from "./index";
class FormBehavior { class FormBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
element.addEventListener("submit", this.onFormSubmit.bind(this));
element.addEventListener("submit", this.onSubmit.bind(this));
} }
async onFormSubmit(event) { async onSubmit(event) {
event.preventDefault(); event.preventDefault();
const url = this.element.action; const url = this.element.action;
@@ -21,34 +22,23 @@ class FormBehavior {
redirect: "manual", // ignore redirect redirect: "manual", // ignore redirect
}); });
// Dispatch refresh events const events = this.element.getAttribute("ld-fire");
const refreshEvents = this.element.getAttribute("refresh-events"); if (fireEvents) {
if (refreshEvents) { fireEvents(events);
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 { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
this.element.addEventListener("change", () => {
const form = this.element.closest("form"); element.addEventListener("change", () => {
const form = element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true })); form.dispatchEvent(new Event("submit", { cancelable: true }));
}); });
} }
} }
registerBehavior("ld-form", FormBehavior); registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);

View File

@@ -1,7 +1,9 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class GlobalShortcuts extends Behavior {
constructor(element) {
super(element);
class GlobalShortcuts {
constructor() {
document.addEventListener("keydown", this.onKeyDown.bind(this)); document.addEventListener("keydown", this.onKeyDown.bind(this));
} }

View File

@@ -1,4 +1,35 @@
const behaviorRegistry = {}; const behaviorRegistry = {};
const debug = false;
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && !node.isConnected) {
destroyBehaviors(node);
}
});
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.isConnected) {
applyBehaviors(node);
}
});
});
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
export class Behavior {
constructor(element) {
this.element = element;
}
destroy() {}
}
Behavior.interacting = false;
export function registerBehavior(name, behavior) { export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior; behaviorRegistry[name] = behavior;
@@ -33,18 +64,82 @@ export function applyBehaviors(container, behaviorNames = null) {
const behaviorInstance = new behavior(element); const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance); element.__behaviors.push(behaviorInstance);
if (debug) {
console.log(
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
);
}
}); });
}); });
} }
export function swap(element, html) { export function destroyBehaviors(element) {
const dom = new DOMParser().parseFromString(html, "text/html"); const behaviorNames = Object.keys(behaviorRegistry);
const newElement = dom.body.firstChild;
element.replaceWith(newElement); behaviorNames.forEach((behaviorName) => {
applyBehaviors(newElement); const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
elements.push(element);
elements.forEach((element) => {
if (!element.__behaviors) {
return;
}
element.__behaviors.forEach((behavior) => {
behavior.destroy();
if (debug) {
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
}
});
delete element.__behaviors;
});
});
} }
export function swapContent(element, html) { export function swap(element, html, options) {
element.innerHTML = html; const dom = new DOMParser().parseFromString(html, "text/html");
applyBehaviors(element);
let targetElement = element;
let strategy = "innerHTML";
if (options.target) {
const parts = options.target.split("|");
targetElement =
parts[0] === "self" ? element : document.querySelector(parts[0]);
strategy = parts[1] || "innerHTML";
}
let contents = Array.from(dom.body.children);
if (options.select) {
contents = Array.from(dom.querySelectorAll(options.select));
}
switch (strategy) {
case "append":
targetElement.append(...contents);
break;
case "outerHTML":
targetElement.parentElement.replaceChild(contents[0], targetElement);
break;
case "innerHTML":
default:
Array.from(targetElement.children).forEach((child) => {
child.remove();
});
targetElement.append(...contents);
}
}
export function fireEvents(events) {
if (!events) {
return;
}
events.split(",").forEach((eventName) => {
const targets = Array.from(
document.querySelectorAll(`[ld-on='${eventName}']`),
);
targets.push(document);
targets.forEach((target) => {
target.dispatchEvent(new CustomEvent(eventName));
});
});
} }

View File

@@ -1,97 +1,20 @@
import { applyBehaviors, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class ModalBehavior { class ModalBehavior extends Behavior {
constructor(element) { constructor(element) {
const toggle = element; super(element);
toggle.addEventListener("click", this.onToggleClick.bind(this));
this.toggle = toggle;
}
async onToggleClick(event) { const modalOverlay = element.querySelector(".modal-overlay");
// Ignore Ctrl + click const closeButton = element.querySelector("button.close");
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)); modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.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;
}
// Todo: make title configurable, only used for tag cloud for now
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<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"></div>
</div>
</div>
`;
const contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;
return modal;
} }
onClose() { onClose() {
// Teleport content back this.element.classList.add("closing");
if (this.content && this.contentOwner) { this.element.addEventListener("animationend", (event) => {
this.contentOwner.append(this.content);
}
// Remove modal
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") { if (event.animationName === "fade-out") {
this.modal.remove(); this.element.remove();
} }
}); });
} }

View File

@@ -1,9 +1,10 @@
import { registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api"; import { ApiClient } from "../api";
class TagAutocomplete { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
super(element);
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl); const apiClient = new ApiClient(apiBaseUrl);

View File

@@ -2,6 +2,7 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit"; import "./behaviors/bulk-edit";
import "./behaviors/confirm-button"; import "./behaviors/confirm-button";
import "./behaviors/dropdown"; import "./behaviors/dropdown";
import "./behaviors/fetch";
import "./behaviors/form"; import "./behaviors/form";
import "./behaviors/modal"; import "./behaviors/modal";
import "./behaviors/global-shortcuts"; import "./behaviors/global-shortcuts";

View File

@@ -18,11 +18,12 @@ logger = logging.getLogger(__name__)
def create_snapshot(url: str, filepath: str): def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH singlefile_path = settings.LD_SINGLEFILE_PATH
# parse string to list of arguments # parse options to list of arguments
singlefile_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS) ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
temp_filepath = filepath + ".tmp" temp_filepath = filepath + ".tmp"
# concat lists # concat lists
args = [singlefile_path] + singlefile_options + [url, temp_filepath] args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
try: try:
# Use start_new_session=True to create a new process group # Use start_new_session=True to create a new process group
process = subprocess.Popen(args, start_new_session=True) process = subprocess.Popen(args, start_new_session=True)

View File

@@ -1,6 +1,7 @@
import functools import functools
import logging import logging
import os import os
from typing import List
import waybackpy import waybackpy
from django.conf import settings from django.conf import settings
@@ -228,6 +229,26 @@ def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active(): if not is_html_snapshot_feature_active():
return return
asset = _create_snapshot_asset(bookmark)
asset.save()
def create_html_snapshots(bookmark_list: List[Bookmark]):
if not is_html_snapshot_feature_active():
return
assets_to_create = []
for bookmark in bookmark_list:
asset = _create_snapshot_asset(bookmark)
assets_to_create.append(asset)
BookmarkAsset.objects.bulk_create(assets_to_create)
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT") timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset( asset = BookmarkAsset(
bookmark=bookmark, bookmark=bookmark,
@@ -236,10 +257,7 @@ def create_html_snapshot(bookmark: Bookmark):
display_name=f"HTML snapshot from {timestamp}", display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING, status=BookmarkAsset.STATUS_PENDING,
) )
asset.save() return asset
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _generate_snapshot_filename(asset: BookmarkAsset) -> str: def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
@@ -305,3 +323,23 @@ def _create_html_snapshot_task(asset_id: int):
) )
asset.status = BookmarkAsset.STATUS_FAILURE asset.status = BookmarkAsset.STATUS_FAILURE
asset.save() asset.save()
def create_missing_html_snapshots(user: User) -> int:
if not is_html_snapshot_feature_active():
return 0
bookmarks_without_snapshots = Bookmark.objects.filter(owner=user).exclude(
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT,
bookmarkasset__status__in=[
BookmarkAsset.STATUS_PENDING,
BookmarkAsset.STATUS_COMPLETE,
],
)
bookmarks_without_snapshots |= Bookmark.objects.filter(owner=user).exclude(
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT
)
create_html_snapshots(list(bookmarks_without_snapshots))
return bookmarks_without_snapshots.count()

View File

@@ -130,7 +130,7 @@ li[ld-bookmark-item] {
position: relative; position: relative;
margin-top: $unit-2; margin-top: $unit-2;
[ld-bulk-edit-checkbox].form-checkbox { .form-checkbox.bulk-edit-checkbox {
display: none; display: none;
} }
@@ -323,7 +323,7 @@ $bulk-edit-transition-duration: 400ms;
} }
/* All checkbox */ /* All checkbox */
[ld-bulk-edit-checkbox][all].form-checkbox { .form-checkbox.bulk-edit-checkbox.all {
display: block; display: block;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset; margin: 0 0 0 $bulk-edit-toggle-offset;
@@ -331,7 +331,7 @@ $bulk-edit-transition-duration: 400ms;
} }
/* Bookmark checkboxes */ /* Bookmark checkboxes */
li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox { li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
display: block; display: block;
position: absolute; position: absolute;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
@@ -350,7 +350,7 @@ $bulk-edit-transition-duration: 400ms;
} }
} }
&.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox { &.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }

View File

@@ -4,11 +4,7 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1" <div ld-bulk-edit class="bookmarks-page grid columns-md-1">
ld-bulk-edit
ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <section class="content-area col-2">
@@ -17,17 +13,22 @@
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %} {% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button> <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div class="bookmark-list-container"> <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@@ -38,7 +39,8 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>

View File

@@ -11,7 +11,7 @@
{% for bookmark_item in bookmark_list.items %} {% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}> <li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<div class="title"> <div class="title">
<label ld-bulk-edit-checkbox class="form-checkbox"> <label class="form-checkbox bulk-edit-checkbox">
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}"> <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
@@ -81,8 +81,8 @@
{% endif %} {% endif %}
{# View link is visible for both owned and shared bookmarks #} {# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %} {% if bookmark_list.show_view_action %}
<a ld-modal <a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}" ld-on="click" ld-target="body|append"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a> href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
{% endif %} {% endif %}
{% if bookmark_item.is_editable %} {% if bookmark_item.is_editable %}
@@ -118,7 +118,7 @@
{% if bookmark_item.show_mark_as_read %} {% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}" <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?"> ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
@@ -128,7 +128,7 @@
{% if bookmark_item.show_unshare %} {% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}" <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?"> ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>

View File

@@ -2,7 +2,7 @@
{% htmlmin %} {% htmlmin %}
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray"> <div class="bulk-edit-actions bg-gray">
<label ld-bulk-edit-checkbox all class="form-checkbox"> <label class="form-checkbox bulk-edit-checkbox all">
<input type="checkbox"> <input type="checkbox">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>

View File

@@ -1,4 +1,4 @@
<button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" title="Bulk edit"> <button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
height="20px"> height="20px">
<path <path

View File

@@ -1,37 +1,44 @@
{% if details.assets %} <div {% if details.has_pending_assets %}
<div class="assets"> ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
{% for asset in details.assets %} ld-interval="5" ld-target="self|outerHTML"
<div class="asset" data-asset-id="{{ asset.id }}"> {% endif %}>
<div class="asset-icon {{ asset.icon_classes }}"> {% if details.assets %}
{% include 'bookmarks/details/asset_icon.html' %} <div class="assets">
</div> {% for asset in details.assets %}
<div class="asset-text truncate {{ asset.text_classes }}"> <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> <span>
{{ asset.display_name }} {{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %} {% if asset.status == 'pending' %}(queued){% endif %}
{% if asset.status == 'failure' %}(failed){% endif %} {% if asset.status == 'failure' %}(failed){% endif %}
</span> </span>
{% if asset.file_size %} {% if asset.file_size %}
<span class="filesize">{{ asset.file_size|filesizeformat }}</span> <span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %} {% 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> </div>
<div class="asset-actions"> {% endfor %}
{% if asset.file %} </div>
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a> {% endif %}
{% 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 %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-link">Create HTML snapshot</button> <button type="submit" name="create_snapshot" class="btn btn-link"
</div> {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
{% endif %} </button>
</div>
{% endif %}
</div>

View File

@@ -1,9 +1,8 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
<form ld-form action="{% url 'bookmarks:details' details.bookmark.id %}" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
refresh-url="{% url 'bookmarks:partials.details_form' details.bookmark.id %}" action="{% url 'bookmarks:details' details.bookmark.id %}"
refresh-events="bookmark-page-refresh"
method="post"> method="post">
<div class="weblinks"> <div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener" <a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
@@ -35,14 +34,14 @@
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="is_archived" <input ld-auto-submit type="checkbox" name="is_archived"
{% if details.bookmark.is_archived %}checked{% endif %}> {% if details.bookmark.is_archived %}checked{% endif %}>
<i class="form-icon"></i> Archived <i class="form-icon"></i> Archived
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="unread" <input ld-auto-submit type="checkbox" name="unread"
{% if details.bookmark.unread %}checked{% endif %}> {% if details.bookmark.unread %}checked{% endif %}>
<i class="form-icon"></i> Unread <i class="form-icon"></i> Unread
</label> </label>
@@ -50,7 +49,7 @@
{% if details.profile.enable_sharing %} {% if details.profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="shared" <input ld-auto-submit type="checkbox" name="shared"
{% if details.bookmark.shared %}checked{% endif %}> {% if details.bookmark.shared %}checked{% endif %}>
<i class="form-icon"></i> Shared <i class="form-icon"></i> Shared
</label> </label>

View File

@@ -1,4 +1,7 @@
<div class="modal active bookmark-details"> <div ld-modal
ld-fetch="{% url 'bookmarks:details_modal' details.bookmark.id %}" ld-on="refresh-details"
ld-select=".content" ld-target=".modal.bookmark-details .content|outerHTML"
class="modal active bookmark-details">
<div class="modal-overlay" aria-label="Close"></div> <div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">

View File

@@ -4,11 +4,7 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1" <div ld-bulk-edit class="bookmarks-page grid columns-md-1">
ld-bulk-edit
ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <section class="content-area col-2">
@@ -17,17 +13,22 @@
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags %} {% bookmark_search bookmark_list.search tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button> <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div class="bookmark-list-container"> <div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@@ -38,7 +39,8 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>

View File

@@ -4,10 +4,7 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page grid columns-md-1" <div class="bookmarks-page grid columns-md-1">
ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area col-2"> <section class="content-area col-2">
@@ -15,15 +12,20 @@
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %} {% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button> <button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
method="post"> class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
<div class="bookmark-list-container"> ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@@ -41,7 +43,8 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>

View File

@@ -0,0 +1,21 @@
<div ld-modal class="modal active">
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<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/tag_cloud.html' %}
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,12 @@
{# Profile section #} {# Profile section #}
<section class="content-area"> <section class="content-area">
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
@@ -120,13 +126,6 @@
{% if request.user_profile.enable_favicons and enable_refresh_favicons %} {% if request.user_profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button> <button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %} {% endif %}
{% if refresh_favicons_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ refresh_favicons_success_message }}
</p>
</div>
{% endif %}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
@@ -173,6 +172,7 @@
Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be
created manually in the details view of a bookmark. created manually in the details view of a bookmark.
</div> </div>
<button class="btn mt-2" name="create_missing_html_snapshots">Create missing HTML snapshots</button>
</div> </div>
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
@@ -189,13 +189,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2"> <input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ update_profile_success_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>
@@ -224,20 +217,6 @@
<input class="form-input" type="file" name="import_file"> <input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn btn btn-primary" value="Upload"> <input type="submit" class="input-group-btn btn btn-primary" value="Upload">
</div> </div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>

View File

@@ -94,15 +94,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
) )
def assertBulkActionForm(self, response, url: str): def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode()) soup = self.make_soup(response.content.decode())
needle = collapse_whitespace( form = soup.select_one("form.bookmark-actions")
f""" self.assertIsNotNone(form)
<form class="bookmark-actions" self.assertEqual(form.attrs["action"], url)
action="{url}"
method="post" autocomplete="off">
"""
)
self.assertIn(needle, html)
def test_should_list_archived_and_user_owned_bookmarks(self): def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(

View File

@@ -1,3 +1,4 @@
import re
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@@ -105,17 +106,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_access_with_sharing(self): def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True) self.details_route_sharing_access_test(self.get_view_name(), True)
def test_form_partial_access(self): def test_assets_access(self):
# form partial is only used when submitting forms, which should be only self.details_route_access_test("bookmarks:details_assets", True)
# 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): def test_assets_access_with_sharing(self):
# form partial is only used when submitting forms, which should be only self.details_route_sharing_access_test("bookmarks:details_assets", True)
# 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): def test_displays_title(self):
# with title # with title
@@ -765,6 +760,27 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_refresh_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
fetch_url = reverse("bookmarks:details_assets", args=[bookmark.id])
# no pending asset
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNone(assets_wrapper)
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNotNone(assets_wrapper)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self): def test_create_snapshot(self):
with patch.object( with patch.object(
@@ -777,3 +793,27 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1) self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
# no pending asset
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
self.assertFalse(create_button.has_attr("disabled"))
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
self.assertTrue(create_button.has_attr("disabled"))

View File

@@ -94,15 +94,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
) )
def assertBulkActionForm(self, response, url: str): def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode()) soup = self.make_soup(response.content.decode())
needle = collapse_whitespace( form = soup.select_one("form.bookmark-actions")
f""" self.assertIsNotNone(form)
<form class="bookmark-actions" self.assertEqual(form.attrs["action"], url)
action="{url}"
method="post" autocomplete="off">
"""
)
self.assertIn(needle, html)
def test_should_list_unarchived_and_user_owned_bookmarks(self): def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(

View File

@@ -80,7 +80,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id]) details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
self.assertInHTML( self.assertInHTML(
f""" f"""
<a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a> <a ld-fetch="{details_modal_url}?return_url={return_url}"
ld-on="click" ld-target="body|append"
href="{details_url}">View</a>
""", """,
html, html,
count=count, count=count,
@@ -216,7 +218,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="unshare" value="{bookmark.id}" <button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?"> ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>
@@ -232,7 +234,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}" <button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?"> ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
@@ -782,10 +784,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_note_cleans_html(self): def test_note_cleans_html(self):
self.setup_bookmark(notes='<script>alert("test")</script>') self.setup_bookmark(notes='<script>alert("test")</script>')
self.setup_bookmark(
notes='<b ld-fetch="https://example.com" ld-on="click">bold text</b>'
)
html = self.render_template() html = self.render_template()
note_html = '&lt;script&gt;alert("test")&lt;/script&gt;' note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
self.assertNotes(html, note_html, 1) self.assertIn(note_html, html, 1)
note_html = "<b>bold text</b>"
self.assertIn(note_html, html, 1)
def test_notes_are_hidden_initially_by_default(self): def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes="Test note") self.setup_bookmark(notes="Test note")

View File

@@ -611,3 +611,89 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
self.assertEqual(BookmarkAsset.objects.count(), 0) self.assertEqual(BookmarkAsset.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots(self):
bookmarks_with_snapshots = []
bookmarks_without_snapshots = []
# setup bookmarks with snapshots
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
bookmarks_with_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_PENDING,
)
bookmarks_with_snapshots.append(bookmark)
# setup bookmarks without snapshots
bookmark = self.setup_bookmark()
bookmarks_without_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_FAILURE,
)
bookmarks_without_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type="some_other_type",
status=BookmarkAsset.STATUS_PENDING,
)
bookmarks_without_snapshots.append(bookmark)
bookmark = self.setup_bookmark()
self.setup_asset(
bookmark=bookmark,
asset_type="some_other_type",
status=BookmarkAsset.STATUS_COMPLETE,
)
bookmarks_without_snapshots.append(bookmark)
initial_assets = list(BookmarkAsset.objects.all())
initial_assets_count = len(initial_assets)
initial_asset_ids = [asset.id for asset in initial_assets]
count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())
self.assertEqual(count, 4)
self.assertEqual(BookmarkAsset.objects.count(), initial_assets_count + count)
for bookmark in bookmarks_without_snapshots:
new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(
id__in=initial_asset_ids
)
self.assertEqual(new_assets.count(), 1)
for bookmark in bookmarks_with_snapshots:
new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(
id__in=initial_asset_ids
)
self.assertEqual(new_assets.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots_respects_current_user(self):
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
other_user = self.setup_user()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())
self.assertEqual(count, 3)
self.assertEqual(BookmarkAsset.objects.count(), count)

View File

@@ -44,6 +44,24 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
return {**form_data, **overrides} return {**form_data, **overrides}
def assertSuccessMessage(self, html, message: str, count=1):
self.assertInHTML(
f"""
<div class="toast toast-success mb-4">{ message }</div>
""",
html,
count=count,
)
def assertErrorMessage(self, html, message: str, count=1):
self.assertInHTML(
f"""
<div class="toast toast-error mb-4">{ message }</div>
""",
html,
count=count,
)
def test_should_render_successfully(self): def test_should_render_successfully(self):
response = self.client.get(reverse("bookmarks:settings.general")) response = self.client.get(reverse("bookmarks:settings.general"))
@@ -138,12 +156,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.permanent_notes, form_data["permanent_notes"] self.user.profile.permanent_notes, form_data["permanent_notes"]
) )
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"]) self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertInHTML( self.assertSuccessMessage(html, "Profile updated")
"""
<p class="form-input-hint">Profile updated</p>
""",
html,
)
def test_update_profile_should_not_be_called_without_respective_form_action(self): def test_update_profile_should_not_be_called_without_respective_form_action(self):
form_data = { form_data = {
@@ -156,13 +169,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO) self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
self.assertInHTML( self.assertSuccessMessage(html, "Profile updated", count=0)
"""
<p class="form-input-hint">Profile updated</p>
""",
html,
count=0,
)
def test_enable_favicons_should_schedule_icon_update(self): def test_enable_favicons_should_schedule_icon_update(self):
with patch.object( with patch.object(
@@ -210,13 +217,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
mock_schedule_refresh_favicons.assert_called_once() mock_schedule_refresh_favicons.assert_called_once()
self.assertInHTML( self.assertSuccessMessage(
""" html, "Scheduled favicon update. This may take a while..."
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
""",
html,
) )
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self): def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
@@ -230,14 +232,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
mock_schedule_refresh_favicons.assert_not_called() mock_schedule_refresh_favicons.assert_not_called()
self.assertInHTML( self.assertSuccessMessage(
""" html, "Scheduled favicon update. This may take a while...", count=0
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
""",
html,
count=0,
) )
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self): def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
@@ -365,3 +361,57 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(requests, "get", return_value=latest_version_response_mock): with patch.object(requests, "get", return_value=latest_version_response_mock):
version_info = get_version_info(random.random()) version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version) self.assertEqual(version_info, app_version)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots(self):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 5
form_data = {
"create_missing_html_snapshots": "",
}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while..."
)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots_no_missing_snapshots(self):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 0
form_data = {
"create_missing_html_snapshots": "",
}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(html, "No missing snapshots found.")
def test_create_missing_html_snapshots_should_not_be_called_without_respective_form_action(
self,
):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 5
form_data = {}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_not_called()
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0
)

View File

@@ -11,19 +11,27 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def assertFormSuccessHint(self, response, text: str): def assertSuccessMessage(self, response, message: str):
self.assertContains(response, '<div class="has-success">') self.assertInHTML(
self.assertContains(response, text) f"""
<div class="toast toast-success mb-4">{ message }</div>
""",
response.content.decode("utf-8"),
)
def assertNoFormSuccessHint(self, response): def assertNoSuccessMessage(self, response):
self.assertNotContains(response, '<div class="has-success">') self.assertNotContains(response, '<div class="toast toast-success mb-4">')
def assertFormErrorHint(self, response, text: str): def assertErrorMessage(self, response, message: str):
self.assertContains(response, '<div class="has-error">') self.assertInHTML(
self.assertContains(response, text) f"""
<div class="toast toast-error mb-4">{ message }</div>
""",
response.content.decode("utf-8"),
)
def assertNoFormErrorHint(self, response): def assertNoErrorMessage(self, response):
self.assertNotContains(response, '<div class="has-error">') self.assertNotContains(response, '<div class="toast toast-error mb-4">')
def test_should_import_successfully(self): def test_should_import_successfully(self):
with open( with open(
@@ -36,10 +44,10 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
) )
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertFormSuccessHint( self.assertSuccessMessage(
response, "3 bookmarks were successfully imported" response, "3 bookmarks were successfully imported."
) )
self.assertNoFormErrorHint(response) self.assertNoErrorMessage(response)
def test_should_check_authentication(self): def test_should_check_authentication(self):
self.client.logout() self.client.logout()
@@ -53,8 +61,8 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse("bookmarks:settings.import"), follow=True) response = self.client.post(reverse("bookmarks:settings.import"), follow=True)
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertNoFormSuccessHint(response) self.assertNoSuccessMessage(response)
self.assertFormErrorHint(response, "Please select a file to import.") self.assertErrorMessage(response, "Please select a file to import.")
@disable_logging @disable_logging
def test_should_show_hint_if_import_raises_exception(self): def test_should_show_hint_if_import_raises_exception(self):
@@ -68,8 +76,8 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
) )
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertNoFormSuccessHint(response) self.assertNoSuccessMessage(response)
self.assertFormErrorHint( self.assertErrorMessage(
response, "An error occurred during bookmark import." response, "An error occurred during bookmark import."
) )
@@ -87,10 +95,13 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
) )
self.assertRedirects(response, reverse("bookmarks:settings.general")) self.assertRedirects(response, reverse("bookmarks:settings.general"))
self.assertFormSuccessHint( self.assertSuccessMessage(
response, "2 bookmarks were successfully imported" response, "2 bookmarks were successfully imported."
)
self.assertErrorMessage(
response,
"1 bookmarks could not be imported. Please check the logs for more details.",
) )
self.assertFormErrorHint(response, "1 bookmarks could not be imported")
def test_should_respect_map_private_flag_option(self): def test_should_respect_map_private_flag_option(self):
with open( with open(

View File

@@ -61,6 +61,10 @@ class SingleFileServiceTestCase(TestCase):
expected_args = [ expected_args = [
"single-file", "single-file",
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
"http://example.com", "http://example.com",
self.html_filepath + ".tmp", self.html_filepath + ".tmp",
] ]
@@ -79,6 +83,10 @@ class SingleFileServiceTestCase(TestCase):
expected_args = [ expected_args = [
"single-file", "single-file",
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
"--some-option", "--some-option",
"some value", "some value",
"--another-option", "--another-option",
@@ -97,9 +105,9 @@ class SingleFileServiceTestCase(TestCase):
with mock.patch("subprocess.Popen", return_value=mock_process): with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath) singlefile.create_snapshot("http://example.com", self.html_filepath)
mock_process.wait.assert_called_with(timeout=60) mock_process.wait.assert_called_with(timeout=120)
@override_settings(LD_SINGLEFILE_TIMEOUT_SEC=120) @override_settings(LD_SINGLEFILE_TIMEOUT_SEC=180)
def test_create_snapshot_custom_timeout_setting(self): def test_create_snapshot_custom_timeout_setting(self):
mock_process = mock.Mock() mock_process = mock.Mock()
mock_process.wait.return_value = 0 mock_process.wait.return_value = 0
@@ -108,4 +116,4 @@ class SingleFileServiceTestCase(TestCase):
with mock.patch("subprocess.Popen", return_value=mock_process): with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath) singlefile.create_snapshot("http://example.com", self.html_filepath)
mock_process.wait.assert_called_with(timeout=120) mock_process.wait.assert_called_with(timeout=180)

View File

@@ -44,6 +44,11 @@ urlpatterns = [
views.bookmarks.details_modal, views.bookmarks.details_modal,
name="details_modal", name="details_modal",
), ),
path(
"bookmarks/<int:bookmark_id>/details_assets",
views.bookmarks.details_assets,
name="details_assets",
),
# Assets # Assets
path( path(
"assets/<int:asset_id>", "assets/<int:asset_id>",
@@ -61,6 +66,11 @@ urlpatterns = [
partials.active_tag_cloud, partials.active_tag_cloud,
name="partials.tag_cloud.active", name="partials.tag_cloud.active",
), ),
path(
"bookmarks/partials/tag-modal/active",
partials.active_tag_modal,
name="partials.tag_modal.active",
),
path( path(
"bookmarks/partials/bookmark-list/archived", "bookmarks/partials/bookmark-list/archived",
partials.archived_bookmark_list, partials.archived_bookmark_list,
@@ -71,6 +81,11 @@ urlpatterns = [
partials.archived_tag_cloud, partials.archived_tag_cloud,
name="partials.tag_cloud.archived", name="partials.tag_cloud.archived",
), ),
path(
"bookmarks/partials/tag-modal/archived",
partials.archived_tag_modal,
name="partials.tag_modal.archived",
),
path( path(
"bookmarks/partials/bookmark-list/shared", "bookmarks/partials/bookmark-list/shared",
partials.shared_bookmark_list, partials.shared_bookmark_list,
@@ -82,9 +97,9 @@ urlpatterns = [
name="partials.tag_cloud.shared", name="partials.tag_cloud.shared",
), ),
path( path(
"bookmarks/partials/details-form/<int:bookmark_id>", "bookmarks/partials/tag-modal/shared",
partials.details_form, partials.shared_tag_modal,
name="partials.details_form", name="partials.tag_modal.shared",
), ),
# Settings # Settings
path("settings", views.settings.general, name="settings.index"), path("settings", views.settings.general, name="settings.index"),

View File

@@ -172,6 +172,10 @@ def details_modal(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details_modal.html") return _details(request, bookmark_id, "bookmarks/details_modal.html")
def details_assets(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details/assets.html")
def convert_tag_string(tag_string: str): def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings # strings

View File

@@ -1,8 +1,6 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.shortcuts import render from django.shortcuts import render
from bookmarks.models import Bookmark
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -24,6 +22,13 @@ def active_tag_cloud(request):
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context}) return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required
def active_tag_modal(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
@login_required @login_required
def archived_bookmark_list(request): def archived_bookmark_list(request):
bookmark_list_context = contexts.ArchivedBookmarkListContext(request) bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
@@ -43,6 +48,12 @@ def archived_tag_cloud(request):
@login_required @login_required
def archived_tag_modal(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
def shared_bookmark_list(request): def shared_bookmark_list(request):
bookmark_list_context = contexts.SharedBookmarkListContext(request) bookmark_list_context = contexts.SharedBookmarkListContext(request)
@@ -53,20 +64,13 @@ def shared_bookmark_list(request):
) )
@login_required
def shared_tag_cloud(request): def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request) tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context}) return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required def shared_tag_modal(request):
def details_form(request, bookmark_id: int): tag_cloud_context = contexts.SharedTagCloudContext(request)
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/tag_modal.html", {"tag_cloud": tag_cloud_context})
return render(request, "bookmarks/details/form.html", {"details": details_context})

View File

@@ -23,6 +23,113 @@ DEFAULT_PAGE_SIZE = 30
CJK_RE = re.compile(r"[\u4e00-\u9fff]+") CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
class RequestContext:
index_view = "bookmarks:index"
action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def __init__(self, request: WSGIRequest):
self.request = request
self.index_url = reverse(self.index_view)
self.action_url = reverse(self.action_view)
self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
self.query_params = request.GET.copy()
self.query_params.pop("details", None)
def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:
query_params = self.query_params.copy()
if add:
query_params.update(add)
if remove:
for key in remove:
query_params.pop(key, None)
encoded_params = query_params.urlencode()
return view_url + "?" + encoded_params if encoded_params else view_url
def index(self) -> str:
return self.get_url(self.index_url)
def action(self, return_url: str) -> str:
return self.get_url(self.action_url, add={"return_url": return_url})
def bookmark_list_partial(self) -> str:
return self.get_url(self.bookmark_list_partial_url)
def tag_cloud_partial(self) -> str:
return self.get_url(self.tag_cloud_partial_url)
def tag_modal_partial(self) -> str:
return self.get_url(self.tag_modal_partial_url)
def get_bookmark_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass")
def get_tag_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass")
class ActiveBookmarksContext(RequestContext):
index_view = "bookmarks:index"
action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_bookmarks(
self.request.user, self.request.user_profile, search
)
def get_tag_query_set(self, search: BookmarkSearch):
return queries.query_bookmark_tags(
self.request.user, self.request.user_profile, search
)
class ArchivedBookmarksContext(RequestContext):
index_view = "bookmarks:archived"
action_view = "bookmarks:archived.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmarks(
self.request.user, self.request.user_profile, search
)
def get_tag_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmark_tags(
self.request.user, self.request.user_profile, search
)
class SharedBookmarksContext(RequestContext):
index_view = "bookmarks:shared"
action_view = "bookmarks:shared.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
def get_bookmark_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, search, public_only
)
def get_tag_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, search, public_only
)
class BookmarkItem: class BookmarkItem:
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None: def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
self.bookmark = bookmark self.bookmark = bookmark
@@ -67,7 +174,10 @@ class BookmarkItem:
class BookmarkListContext: class BookmarkListContext:
request_context = RequestContext
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
request_context = self.request_context(request)
user = request.user user = request.user
user_profile = request.user_profile user_profile = request.user_profile
@@ -76,7 +186,7 @@ class BookmarkListContext:
self.request.GET, user_profile.search_preferences self.request.GET, user_profile.search_preferences
) )
query_set = self.get_bookmark_query_set() query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page") page_number = request.GET.get("page")
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE) paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
bookmarks_page = paginator.get_page(page_number) bookmarks_page = paginator.get_page(page_number)
@@ -86,16 +196,15 @@ class BookmarkListContext:
self.items = [ self.items = [
BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page
] ]
self.is_empty = paginator.count == 0 self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count self.bookmarks_total = paginator.count
self.return_url = self.generate_return_url(
self.search, self.get_base_url(), page_number self.return_url = request_context.index()
) self.action_url = request_context.action(return_url=self.return_url)
self.action_url = self.generate_action_url( self.refresh_url = request_context.bookmark_list_partial()
self.search, self.get_base_action_url(), self.return_url self.tag_modal_url = request_context.tag_modal_partial()
)
self.link_target = user_profile.bookmark_link_target self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display self.date_display = user_profile.bookmark_date_display
self.description_display = user_profile.bookmark_description_display self.description_display = user_profile.bookmark_description_display
@@ -131,55 +240,17 @@ class BookmarkListContext:
else base_action_url + "?" + query_string else base_action_url + "?" + query_string
) )
def get_base_url(self):
raise Exception("Must be implemented by subclass")
def get_base_action_url(self):
raise Exception("Must be implemented by subclass")
def get_bookmark_query_set(self):
raise Exception("Must be implemented by subclass")
class ActiveBookmarkListContext(BookmarkListContext): class ActiveBookmarkListContext(BookmarkListContext):
def get_base_url(self): request_context = ActiveBookmarksContext
return reverse("bookmarks:index")
def get_base_action_url(self):
return reverse("bookmarks:index.action")
def get_bookmark_query_set(self):
return queries.query_bookmarks(
self.request.user, self.request.user_profile, self.search
)
class ArchivedBookmarkListContext(BookmarkListContext): class ArchivedBookmarkListContext(BookmarkListContext):
def get_base_url(self): request_context = ArchivedBookmarksContext
return reverse("bookmarks:archived")
def get_base_action_url(self):
return reverse("bookmarks:archived.action")
def get_bookmark_query_set(self):
return queries.query_archived_bookmarks(
self.request.user, self.request.user_profile, self.search
)
class SharedBookmarkListContext(BookmarkListContext): class SharedBookmarkListContext(BookmarkListContext):
def get_base_url(self): request_context = SharedBookmarksContext
return reverse("bookmarks:shared")
def get_base_action_url(self):
return reverse("bookmarks:shared.action")
def get_bookmark_query_set(self):
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(
user, self.request.user_profile, self.search, public_only
)
class TagGroup: class TagGroup:
@@ -218,7 +289,10 @@ class TagGroup:
class TagCloudContext: class TagCloudContext:
request_context = RequestContext
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
request_context = self.request_context(request)
user_profile = request.user_profile user_profile = request.user_profile
self.request = request self.request = request
@@ -226,7 +300,7 @@ class TagCloudContext:
self.request.GET, user_profile.search_preferences self.request.GET, user_profile.search_preferences
) )
query_set = self.get_tag_query_set() query_set = request_context.get_tag_query_set(self.search)
tags = list(query_set) tags = list(query_set)
selected_tags = self.get_selected_tags(tags) selected_tags = self.get_selected_tags(tags)
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name)) unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
@@ -242,8 +316,7 @@ class TagCloudContext:
self.selected_tags = unique_selected_tags self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags self.has_selected_tags = has_selected_tags
def get_tag_query_set(self): self.refresh_url = request_context.tag_cloud_partial()
raise Exception("Must be implemented by subclass")
def get_selected_tags(self, tags: List[Tag]): def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.search.q) parsed_query = queries.parse_query_string(self.search.q)
@@ -256,26 +329,15 @@ class TagCloudContext:
class ActiveTagCloudContext(TagCloudContext): class ActiveTagCloudContext(TagCloudContext):
def get_tag_query_set(self): request_context = ActiveBookmarksContext
return queries.query_bookmark_tags(
self.request.user, self.request.user_profile, self.search
)
class ArchivedTagCloudContext(TagCloudContext): class ArchivedTagCloudContext(TagCloudContext):
def get_tag_query_set(self): request_context = ArchivedBookmarksContext
return queries.query_archived_bookmark_tags(
self.request.user, self.request.user_profile, self.search
)
class SharedTagCloudContext(TagCloudContext): class SharedTagCloudContext(TagCloudContext):
def get_tag_query_set(self): request_context = SharedBookmarksContext
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(
user, self.request.user_profile, self.search, public_only
)
class BookmarkAssetItem: class BookmarkAssetItem:
@@ -328,3 +390,6 @@ class BookmarkDetailsContext:
self.assets = [ self.assets = [
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all() BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
] ]
self.has_pending_assets = any(
asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets
)

View File

@@ -25,12 +25,10 @@ def general(request):
profile_form = None profile_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
update_profile_success_message = None success_message = _find_message_with_tag(
refresh_favicons_success_message = None
import_success_message = _find_message_with_tag(
messages.get_messages(request), "bookmark_import_success" messages.get_messages(request), "bookmark_import_success"
) )
import_errors_message = _find_message_with_tag( error_message = _find_message_with_tag(
messages.get_messages(request), "bookmark_import_errors" messages.get_messages(request), "bookmark_import_errors"
) )
version_info = get_version_info(get_ttl_hash()) version_info = get_version_info(get_ttl_hash())
@@ -38,12 +36,18 @@ def general(request):
if request.method == "POST": if request.method == "POST":
if "update_profile" in request.POST: if "update_profile" in request.POST:
profile_form = update_profile(request) profile_form = update_profile(request)
update_profile_success_message = "Profile updated" success_message = "Profile updated"
if "refresh_favicons" in request.POST: if "refresh_favicons" in request.POST:
tasks.schedule_refresh_favicons(request.user) tasks.schedule_refresh_favicons(request.user)
refresh_favicons_success_message = ( success_message = "Scheduled favicon update. This may take a while..."
"Scheduled favicon update. This may take a while..." if "create_missing_html_snapshots" in request.POST:
) count = tasks.create_missing_html_snapshots(request.user)
if count > 0:
success_message = (
f"Queued {count} missing snapshots. This may take a while..."
)
else:
success_message = "No missing snapshots found."
if not profile_form: if not profile_form:
profile_form = UserProfileForm(instance=request.user_profile) profile_form = UserProfileForm(instance=request.user_profile)
@@ -55,10 +59,8 @@ def general(request):
"form": profile_form, "form": profile_form,
"enable_refresh_favicons": enable_refresh_favicons, "enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support, "has_snapshot_support": has_snapshot_support,
"update_profile_success_message": update_profile_success_message, "success_message": success_message,
"refresh_favicons_success_message": refresh_favicons_success_message, "error_message": error_message,
"import_success_message": import_success_message,
"import_errors_message": import_errors_message,
"version_info": version_info, "version_info": version_info,
}, },
) )

View File

@@ -21,7 +21,7 @@ python manage.py create_initial_superuser
# Migrate legacy background tasks to Huey # Migrate legacy background tasks to Huey
python manage.py migrate_tasks python manage.py migrate_tasks
# Ensure the DB folder is owned by the right user # Ensure folders are owned by the right user
chown -R www-data: /etc/linkding/data chown -R www-data: /etc/linkding/data
# Start background task processor using supervisord, unless explicitly disabled # Start background task processor using supervisord, unless explicitly disabled

View File

@@ -99,10 +99,31 @@ CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health ||
CMD ["./bootstrap.sh"] CMD ["./bootstrap.sh"]
FROM node:18-alpine AS ublock-build
WORKDIR /etc/linkding
# Install necessary tools
RUN apk add --no-cache curl jq unzip
# Fetch the latest release tag
# Download the library
# Unzip the library
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json
FROM linkding AS linkding-plus FROM linkding AS linkding-plus
# install node, chromium # install node, chromium
RUN apk update && apk add nodejs npm chromium RUN apk update && apk add nodejs npm chromium
# install single-file from fork for now, which contains several hotfixes # install single-file from fork for now, which contains several hotfixes
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/f3730995a52f27d5041a1ad9e7528af4b6b4cf4b RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
# copy uBlock0
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
# create chromium profile folder for user running background tasks
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
# enable snapshot support # enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -96,6 +96,24 @@ CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health ||
CMD ["./bootstrap.sh"] CMD ["./bootstrap.sh"]
FROM node:18-alpine AS ublock-build
WORKDIR /etc/linkding
# Install necessary tools
RUN apk add --no-cache curl jq unzip
# Fetch the latest release tag
# Download the library
# Unzip the library
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json
FROM linkding AS linkding-plus FROM linkding AS linkding-plus
# install chromium # install chromium
RUN apt-get update && apt-get -y install chromium RUN apt-get update && apt-get -y install chromium
@@ -106,6 +124,10 @@ RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
apt-get update && apt-get install -y nodejs apt-get update && apt-get install -y nodejs
# install single-file from fork for now, which contains several hotfixes # install single-file from fork for now, which contains several hotfixes
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/f3730995a52f27d5041a1ad9e7528af4b6b4cf4b RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
# create chromium profile folder for user running background tasks
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
# copy uBlock0
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
# enable snapshot support # enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.28.0", "version": "1.29.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -28,5 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.0.2" "prettier": "^3.0.2"
} },
"web-types": "./web-types.json"
} }

View File

@@ -43,7 +43,7 @@ djangorestframework==3.14.0
# via -r requirements.in # via -r requirements.in
huey==2.5.0 huey==2.5.0
# via -r requirements.in # via -r requirements.in
idna==3.6 idna==3.7
# via requests # via requests
josepy==1.14.0 josepy==1.14.0
# via mozilla-django-oidc # via mozilla-django-oidc

13
scripts/setup-ublock.sh Executable file
View File

@@ -0,0 +1,13 @@
rm -rf ublock0.chromium
TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name')
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip
curl -L -o uBlock0.zip $DOWNLOAD_URL
unzip uBlock0.zip
rm uBlock0.zip
curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json
mv temp.json ./uBlock0.chromium/assets/assets.json
mkdir -p chromium-profile

View File

@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import json import json
import os import os
import shlex
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -294,8 +295,19 @@ LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
"1", "1",
) )
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file") LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
"LD_SINGLEFILE_UBLOCK_OPTIONS",
shlex.join(
[
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
]
),
)
LD_SINGLEFILE_OPTIONS = os.getenv("LD_SINGLEFILE_OPTIONS", "") LD_SINGLEFILE_OPTIONS = os.getenv("LD_SINGLEFILE_OPTIONS", "")
LD_SINGLEFILE_TIMEOUT_SEC = float(os.getenv("LD_SINGLEFILE_TIMEOUT_SEC", 60)) LD_SINGLEFILE_TIMEOUT_SEC = float(os.getenv("LD_SINGLEFILE_TIMEOUT_SEC", 120))
# Monolith isn't used at the moment, as the local snapshot implementation # Monolith isn't used at the moment, as the local snapshot implementation
# switched to single-file after the prototype. Keeping this around in case # switched to single-file after the prototype. Keeping this around in case

View File

@@ -4,6 +4,8 @@ loglevel=info
[program:jobs] [program:jobs]
user=www-data user=www-data
# setup a temp home folder for the job, required by chromium
environment=HOME=/tmp/home
command=python manage.py run_huey -f command=python manage.py run_huey -f
stdout_logfile=background_tasks.log stdout_logfile=background_tasks.log
stdout_logfile_maxbytes=10MB stdout_logfile_maxbytes=10MB

View File

@@ -1 +1 @@
1.28.0 1.29.0

130
web-types.json Normal file
View File

@@ -0,0 +1,130 @@
{
"$schema": "https://raw.githubusercontent.com/JetBrains/web-types/master/schema/web-types.json",
"name": "linkding",
"version": "1.0.0",
"contributions": {
"html": {
"attributes": [
{
"name": "ld-fetch",
"description": "Fetches the HTML content of the given URL and replaces the content of an element with it. Fires events afterwards to notify other behaviors.",
"value": {
"required": false
}
},
{
"name": "ld-on",
"description": "The event that triggers a fetch, such as `click` or a custom event name fired by another behavior",
"value": {
"required": false
}
},
{
"name": "ld-target",
"description": "The target element to replace the content of and the replacement strategy, for example `body|append`",
"value": {
"required": false
}
},
{
"name": "ld-select",
"description": "The content element(s) to select from the fetched content, for example `#main-content`",
"value": {
"required": false
}
},
{
"name": "ld-interval",
"description": "Automatically fetches the content of the given URL at the given interval, in seconds",
"value": {
"required": false
}
},
{
"name": "ld-fire",
"description": "Fires one or more events once a behavior, such as ld-fetch or ld-form, is finished",
"value": {
"required": false
}
},
{
"name": "ld-form",
"description": "Converts a form into a fetch request. Fires events afterwards to notify other behaviors.",
"value": {
"required": false
}
},
{
"name": "ld-auto-submit",
"description": "Automatically submits the nearest form when the value of the input changes",
"value": {
"required": false
}
},
{
"name": "ld-modal",
"description": "Adds Javascript behavior to a modal HTML component",
"value": {
"required": false
}
},
{
"name": "ld-dropdown",
"description": "Adds Javascript behavior to a dropdown HTML component",
"value": {
"required": false
}
},
{
"name": "ld-confirm-button",
"description": "Converts a button into a confirmation button that shows confirm / cancel buttons when clicked",
"value": {
"required": false
}
},
{
"name": "ld-confirm-icon",
"description": "Icon to show when the confirm button is clicked",
"value": {
"required": false
}
},
{
"name": "ld-confirm-question",
"description": "Question to show when the confirm button is clicked",
"value": {
"required": false
}
},
{
"name": "ld-bookmark-item",
"description": "Adds Javascript behavior to a bookmark list item",
"value": {
"required": false
}
},
{
"name": "ld-bulk-edit",
"description": "Adds Javascript behavior for bulk editing the bookmark list",
"value": {
"required": false
}
},
{
"name": "ld-global-shortcuts",
"description": "Adds Javascript behavior for global shortcuts",
"value": {
"required": false
}
},
{
"name": "ld-tag-autocomplete",
"description": "Adds Javascript behavior for converting a plain input into a tag autocomplete Svelte component",
"value": {
"required": false
}
}
]
}
}
}