mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-07 02:13:12 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e50912df12 | ||
|
|
393d688247 | ||
|
|
6e38587174 | ||
|
|
123c6fe02a | ||
|
|
1b7731e506 | ||
|
|
df9f0095cc | ||
|
|
25470edb2c | ||
|
|
22a1fc80ad | ||
|
|
65f0eb2a04 | ||
|
|
82f86bf537 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -191,3 +191,6 @@ typings/
|
|||||||
/tmp
|
/tmp
|
||||||
# Database file
|
# Database file
|
||||||
/data
|
/data
|
||||||
|
# ublock + chromium
|
||||||
|
/uBlock0.chromium
|
||||||
|
/chromium-profile
|
||||||
|
|||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal 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()
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
48
bookmarks/frontend/behaviors/fetch.js
Normal file
48
bookmarks/frontend/behaviors/fetch.js
Normal 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);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
21
bookmarks/templates/bookmarks/tag_modal.html
Normal file
21
bookmarks/templates/bookmarks/tag_modal.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 = '<script>alert("test")</script>'
|
note_html = '<script>alert("test")</script>'
|
||||||
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
13
scripts/setup-ublock.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.28.0
|
1.29.0
|
||||||
|
|||||||
130
web-types.json
Normal file
130
web-types.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user