Compare commits

...

19 Commits

Author SHA1 Message Date
Sascha Ißbrücker
23d97db016 Bump version 2024-04-20 14:11:14 +02:00
dependabot[bot]
0fb1bbd0e2 Bump sqlparse from 0.4.4 to 0.5.0 (#704)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.4 to 0.5.0.
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.4...0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-20 12:17:01 +02:00
Sascha Ißbrücker
5d2acca122 Allow uploading custom files for bookmarks (#713) 2024-04-20 12:14:11 +02:00
Sascha Ißbrücker
0cbaf927e4 Add reader mode (#703)
* Add reader mode view

* Show link for latest snapshot instead
2024-04-20 09:18:57 +02:00
ab623
0586983602 Show proper name for bookmark assets in admin (#708) 2024-04-17 23:18:23 +02:00
ab623
9dc3521d5e Add option for marking bookmarks as unread by default (#706)
* Added new option to set Mark as unread with a default

* Added additional test

* tweak test a bit

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-04-17 23:08:18 +02:00
Sascha Ißbrücker
a1822e2091 Close bookmark details with escape (#702) 2024-04-15 19:41:18 +02:00
Sascha Ißbrücker
22ffecbb9d Make blocking cookie banners more reliable (#699) 2024-04-15 19:33:25 +02:00
Sascha Ißbrücker
d9096eacd6 Update CHANGELOG.md 2024-04-14 21:10:27 +02:00
Sascha Ißbrücker
e50912df12 Bump version 2024-04-14 20:48:30 +02:00
Sascha Ißbrücker
393d688247 Fix directory name 2024-04-14 20:31:53 +02:00
Sascha Ißbrücker
6e38587174 Fix missing home directory in background tasks 2024-04-14 20:28:39 +02:00
dependabot[bot]
123c6fe02a Bump idna from 3.6 to 3.7 (#694)
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

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

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

* refresh details modal in interval

* refactor to refresh assets list

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

* refactor messages in settings view

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

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

* preserve query string when refreshing content

* refactor details modal refresh

* refactor bulk edit

* update tests

* restore tag modal

* Make IntelliJ aware of custom attributes

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

3
.gitignore vendored
View File

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

View File

@@ -1,5 +1,36 @@
# Changelog # Changelog
## v1.29.0 (14/04/2024)
### What's Changed
* Remove ads and cookie banners from HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/695
* Add button for creating missing HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/696
* Refresh file list when there are queued snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/697
* Bump idna from 3.6 to 3.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/694
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.28.0...v1.29.0
---
## v1.28.0 (09/04/2024)
### What's Changed
* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684
* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686
* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687
* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688
* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691
* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689
### New Contributors
* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684
* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0
---
## v1.27.1 (07/04/2024) ## v1.27.1 (07/04/2024)
### What's Changed ### What's Changed

View File

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

View File

@@ -200,9 +200,13 @@ class AdminBookmark(admin.ModelAdmin):
class AdminBookmarkAsset(admin.ModelAdmin): class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("display_name", "date_created", "status") @admin.display(description="Display Name")
def custom_display_name(self, obj):
return str(obj)
list_display = ("custom_display_name", "date_created", "status")
search_fields = ( search_fields = (
"display_name", "custom_display_name",
"file", "file",
) )
list_filter = ("status",) list_filter = ("status",)

View File

@@ -1,3 +1,4 @@
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect from playwright.sync_api import sync_playwright, expect
@@ -33,6 +34,11 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
overlay.click(position={"x": 0, "y": 0}) overlay.click(position={"x": 0, "y": 0})
expect(details_modal).to_be_hidden() expect(details_modal).to_be_hidden()
# close with escape
details_modal = self.open_details_modal(bookmark)
self.page.keyboard.press("Escape")
expect(details_modal).to_be_hidden()
def test_toggle_archived(self): def test_toggle_archived(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -44,14 +50,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 +75,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 +100,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 +144,33 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertEqual(Bookmark.objects.count(), 0) self.assertEqual(Bookmark.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_remove_snapshot(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
asset_list = details_modal.locator(".assets")
# No snapshots initially
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
expect(snapshot).not_to_be_visible()
# Create snapshot
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
self.assertReloads(0)
# Has new snapshots
expect(snapshot).to_be_visible()
# Create snapshot
asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click()
# Snapshot is removed
expect(snapshot).not_to_be_visible()
self.assertReloads(0)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { registerBehavior, swap } from "./index"; import { Behavior, fireEvents, registerBehavior } from "./index";
class FormBehavior { class FormBehavior extends Behavior {
constructor(element) { constructor(element) {
this.element = element; super(element);
element.addEventListener("submit", this.onFormSubmit.bind(this));
element.addEventListener("submit", this.onSubmit.bind(this));
} }
async onFormSubmit(event) { async onSubmit(event) {
event.preventDefault(); event.preventDefault();
const url = this.element.action; const url = this.element.action;
@@ -21,34 +22,43 @@ 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 }));
}); });
} }
} }
class UploadButton extends Behavior {
constructor(element) {
super(element);
const fileInput = element.nextElementSibling;
element.addEventListener("click", () => {
fileInput.click();
});
fileInput.addEventListener("change", () => {
const form = fileInput.closest("form");
const event = new Event("submit", { cancelable: true });
event.submitter = element;
form.dispatchEvent(event);
});
}
}
registerBehavior("ld-form", FormBehavior); registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

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

View File

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

View File

@@ -1,97 +1,48 @@
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; this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
} }
async onToggleClick(event) { destroy() {
// Ignore Ctrl + click document.removeEventListener("keydown", this.onKeyDown);
if (event.ctrlKey || event.metaKey) { }
return;
}
event.preventDefault();
event.stopPropagation();
// Create modal either by teleporting existing content or fetching from URL onKeyDown(event) {
const modal = this.toggle.hasAttribute("modal-content") // Skip if event occurred within an input element
? this.createFromContent() const targetNodeName = event.target.nodeName;
: await this.createFromUrl(); const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (!modal) { if (isInputTarget) {
return; return;
} }
// Register close handlers if (event.key === "Escape") {
const modalOverlay = modal.querySelector(".modal-overlay"); event.preventDefault();
const closeButton = modal.querySelector("button.close"); this.onClose();
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
applyBehaviors(document.body);
this.modal = modal;
}
async createFromUrl() {
const url = this.toggle.getAttribute("modal-url");
const modalHtml = await fetch(url).then((response) => response.text());
const parser = new DOMParser();
const doc = parser.parseFromString(modalHtml, "text/html");
return doc.querySelector(".modal");
}
createFromContent() {
const contentSelector = this.toggle.getAttribute("modal-content");
const content = document.querySelector(contentSelector);
if (!content) {
return;
} }
// 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 document.removeEventListener("keydown", this.onKeyDown);
if (this.content && this.contentOwner) { this.element.classList.add("closing");
this.contentOwner.append(this.content); this.element.addEventListener("animationend", (event) => {
}
// Remove modal
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") { if (event.animationName === "fade-out") {
this.modal.remove(); this.element.remove();
} }
}); });
} }

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-04-17 19:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0032_html_snapshots_hint_toast"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_mark_unread",
field=models.BooleanField(default=False),
),
]

View File

@@ -91,6 +91,7 @@ class Bookmark(models.Model):
class BookmarkAsset(models.Model): class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot" TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload"
CONTENT_TYPE_HTML = "text/html" CONTENT_TYPE_HTML = "text/html"
@@ -118,6 +119,9 @@ class BookmarkAsset(models.Model):
pass pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self):
return self.display_name or f"Bookmark Asset #{self.pk}"
@receiver(post_delete, sender=BookmarkAsset) @receiver(post_delete, sender=BookmarkAsset)
def bookmark_asset_deleted(sender, instance, **kwargs): def bookmark_asset_deleted(sender, instance, **kwargs):
@@ -399,6 +403,7 @@ class UserProfile(models.Model):
custom_css = models.TextField(blank=True, null=False) custom_css = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False) search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
@@ -422,6 +427,7 @@ class UserProfileForm(forms.ModelForm):
"display_archive_bookmark_action", "display_archive_bookmark_action",
"display_remove_bookmark_action", "display_remove_bookmark_action",
"permanent_notes", "permanent_notes",
"default_mark_unread",
"custom_css", "custom_css",
] ]

View File

@@ -1,12 +1,18 @@
import logging
import os
from typing import Union from typing import Union
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
from bookmarks.services.tags import get_or_create_tags
from bookmarks.services import website_loader
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
@@ -176,6 +182,46 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
) )
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
asset.save()
try:
filename = _generate_upload_asset_filename(asset, upload_file.name)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.file_size = upload_file.size
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
return asset
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

View File

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

View File

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

2314
bookmarks/static/vendor/Readability.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -61,16 +61,22 @@
.assets .asset-text { .assets .asset-text {
flex: 1 1 0; flex: 1 1 0;
gap: $unit-2;
min-width: 0;
display: flex;
}
.assets .asset-text .truncate {
flex-shrink: 1;
} }
.assets .asset-text .filesize { .assets .asset-text .filesize {
color: $gray-color; color: $gray-color;
margin-left: $unit-2;
} }
.assets .asset-actions, .assets-actions { .assets .asset-actions, .assets-actions {
display: flex; display: flex;
gap: $unit-3; gap: $unit-4;
align-items: center; align-items: center;
} }

View File

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

View File

@@ -0,0 +1,27 @@
html.reader-mode {
--font-size: 1rem;
line-height: 1.6;
body {
margin: 3rem 2rem;
}
.container {
max-width: 600px;
}
.byline {
font-style: italic;
font-size: 0.8rem;
}
.reading-time {
font-size: 0.7rem;
}
img {
max-width: 100%;
height: auto;
}
}

View File

@@ -12,6 +12,7 @@
@import "bookmark-form"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "markdown"; @import "markdown";
@import "reader-mode";
/* Dark theme overrides */ /* Dark theme overrides */

View File

@@ -12,3 +12,4 @@
@import "bookmark-form"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "markdown"; @import "markdown";
@import "reader-mode";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
<form ld-form action="{% url 'bookmarks:details' details.bookmark.id %}" <form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
refresh-url="{% url 'bookmarks:partials.details_form' details.bookmark.id %}" action="{% url 'bookmarks:details' details.bookmark.id %}"
refresh-events="bookmark-page-refresh"
method="post"> method="post">
<div class="weblinks"> <div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener" <a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
@@ -13,6 +12,17 @@
{% endif %} {% endif %}
<span>{{ details.bookmark.url }}</span> <span>{{ details.bookmark.url }}</span>
</a> </a>
{% if details.latest_snapshot %}
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}"
target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %}
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
<use xlink:href="#ld-icon-unread"></use>
</svg>
{% endif %}
<span>Reader mode</span>
</a>
{% endif %}
{% if details.bookmark.web_archive_snapshot_url %} {% if details.bookmark.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}" <a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
@@ -23,7 +33,7 @@
fill="currentColor" fill-rule="evenodd"/> fill="currentColor" fill-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
<span>View on Internet Archive</span> <span>Internet Archive</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -35,14 +45,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 +60,7 @@
{% if details.profile.enable_sharing %} {% if details.profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-form-auto-submit type="checkbox" name="shared" <input ld-auto-submit type="checkbox" name="shared"
{% if details.bookmark.shared %}checked{% endif %}> {% if details.bookmark.shared %}checked{% endif %}>
<i class="form-icon"></i> Shared <i class="form-icon"></i> Shared
</label> </label>

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
{% load sass_tags %}
{% load static %}
<!DOCTYPE html>
<html lang="en" class="reader-mode">
<head>
<meta charset="UTF-8">
<title>Reader view</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
{% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
{% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
{% endif %}
</head>
<body>
<template id="content">{{ content|safe }}</template>
<script src="{% static 'vendor/Readability.js' %}" type="application/javascript"></script>
<script type="application/javascript">
function estimateReadingTime(charCount, wordsPerMinute) {
const avgWordLength = 5;
const totalWords = charCount / avgWordLength;
return Math.ceil(totalWords / wordsPerMinute);
}
function postProcess(articleContent) {
articleContent.querySelectorAll('table').forEach(table => {
table.classList.add('table');
});
}
function makeReadable() {
const content = document.getElementById('content');
const contentHtml = content.innerHTML;
const dom = new DOMParser().parseFromString(contentHtml, 'text/html');
const article = new Readability(dom).parse();
document.title = article.title;
const container = document.createElement('div');
container.classList.add('container');
const articleTitle = document.createElement('h1');
articleTitle.textContent = article.title;
container.append(articleTitle);
const byline = [article.byline, article.siteName].filter(Boolean);
if (byline.length > 0) {
const articleByline = document.createElement('p');
articleByline.textContent = byline.join(' | ');
articleByline.classList.add('byline');
container.append(articleByline);
}
if(article.length) {
const minTime = estimateReadingTime(article.length, 225);
const maxTime = estimateReadingTime(article.length, 175);
const articleReadingTime = document.createElement('p');
articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;
articleReadingTime.classList.add('reading-time');
container.append(articleReadingTime);
}
const divider = document.createElement('hr');
container.append(divider);
const articleContent = document.createElement('div');
articleContent.innerHTML = article.content;
postProcess(articleContent);
container.append(articleContent);
content.replaceWith(container);
}
makeReadable();
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,21 @@
<div ld-modal class="modal active">
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,12 @@
{# Profile section #} {# Profile section #}
<section class="content-area"> <section class="content-area">
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
@@ -120,13 +126,6 @@
{% if request.user_profile.enable_favicons and enable_refresh_favicons %} {% if request.user_profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button> <button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %} {% endif %}
{% if refresh_favicons_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ refresh_favicons_success_message }}
</p>
</div>
{% endif %}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
@@ -173,8 +172,20 @@
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">
<label for="{{ form.default_mark_unread.id_for_label }}" class="form-checkbox">
{{ form.default_mark_unread }}
<i class="form-icon"></i> Create bookmarks as unread by default
</label>
<div class="form-input-hint">
Sets the default state for the "Mark as unread" option when creating a new bookmark.
Setting this option will make all new bookmarks default to unread.
This can be overridden when creating each new bookmark.
</div>
</div>
<div class="form-group"> <div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}> <details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary> <summary>Custom CSS</summary>
@@ -189,13 +200,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 +228,6 @@
<input class="form-input" type="file" name="import_file"> <input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn btn btn-primary" value="Upload"> <input type="submit" class="input-group-btn btn btn-primary" value="Upload">
</div> </div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>

View File

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

View File

@@ -34,12 +34,12 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
asset = self.setup_asset(bookmark=bookmark, file=filename) asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset return asset
def test_view_access(self): def view_access_test(self, view_name: str):
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# other user's bookmark # other user's bookmark
@@ -47,14 +47,14 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing disabled # shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, sharing enabled # unshared, sharing enabled
@@ -64,31 +64,31 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user, shared=False) bookmark = self.setup_bookmark(user=other_user, shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing enabled # shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_view_access_guest_user(self): def view_access_guest_user_test(self, view_name: str):
self.client.logout() self.client.logout()
# unshared, sharing disabled # unshared, sharing disabled
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing disabled # shared, sharing disabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, sharing enabled # unshared, sharing enabled
@@ -98,14 +98,14 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(shared=False) bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing enabled # shared, sharing enabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, public sharing enabled # unshared, public sharing enabled
@@ -114,12 +114,24 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(shared=False) bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, public sharing enabled # shared, public sharing enabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_view_access(self):
self.view_access_test("bookmarks:assets.view")
def test_view_access_guest_user(self):
self.view_access_guest_user_test("bookmarks:assets.view")
def test_reader_view_access(self):
self.view_access_test("bookmarks:assets.read")
def test_reader_view_access_guest_user(self):
self.view_access_guest_user_test("bookmarks:assets.read")

View File

@@ -1,11 +1,13 @@
import re
from unittest.mock import patch from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import formats from django.utils import formats
from bookmarks.models import BookmarkAsset, UserProfile from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks from bookmarks.services import bookmarks, tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@@ -45,6 +47,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_weblink(self, soup, url): def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url}) return soup.find("a", {"class": "weblink", "href": url})
def count_weblinks(self, soup):
return len(soup.find_all("a", {"class": "weblink"}))
def find_asset(self, soup, asset): def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id}) return soup.find("div", {"data-asset-id": asset.id})
@@ -105,17 +110,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
@@ -177,6 +176,48 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(image) self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png") self.assertEqual(image["src"], "/static/example.png")
def test_reader_mode_link(self):
# no latest snapshot
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
# snapshot is not complete
self.setup_asset(
bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_PENDING,
)
self.setup_asset(
bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_FAILURE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
# not a snapshot
self.setup_asset(
bookmark,
asset_type="upload",
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
# snapshot is complete
asset = self.setup_asset(
bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 2)
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
link = self.find_weblink(soup, reader_mode_url)
self.assertIsNotNone(link)
def test_internet_archive_link(self): def test_internet_archive_link(self):
# without snapshot url # without snapshot url
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -190,7 +231,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url) self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
self.assertEqual(link.text.strip(), "View on Internet Archive") self.assertEqual(link.text.strip(), "Internet Archive")
# favicons disabled # favicons disabled
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
@@ -765,6 +806,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 +839,58 @@ 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"))
def test_upload_file(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": "", "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 302)
mock_upload_asset.assert_called_once()
args, kwargs = mock_upload_asset.call_args
self.assertEqual(args[0], bookmark)
upload_file = args[1]
self.assertEqual(upload_file.name, "test.txt")
def test_upload_file_without_file(self):
bookmark = self.setup_bookmark()
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": ""},
)
self.assertEqual(response.status_code, 400)
mock_upload_asset.assert_not_called()

View File

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

View File

@@ -210,3 +210,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1) self.assertContains(response, '<details class="notes">', count=1)
def test_should_not_check_unread_by_default(self):
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML(
'<input type="checkbox" name="unread" id="id_unread">',
html,
)
def test_should_check_unread_when_configured_in_profile(self):
self.user.profile.default_mark_unread = True
self.user.profile.save()
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML(
'<input type="checkbox" name="unread" value="true" '
'id="id_unread" checked="">',
html,
)

View File

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

View File

@@ -1,10 +1,13 @@
import os
import tempfile
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, BookmarkAsset, Tag
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services.bookmarks import ( from bookmarks.services.bookmarks import (
@@ -21,6 +24,7 @@ from bookmarks.services.bookmarks import (
mark_bookmarks_as_unread, mark_bookmarks_as_unread,
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
upload_asset,
) )
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -835,3 +839,50 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_upload_asset_should_save_file(self):
bookmark = self.setup_bookmark()
with tempfile.TemporaryDirectory() as temp_assets:
with override_settings(LD_ASSET_FOLDER=temp_assets):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertEqual(upload_file.size, asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_COMPLETE, asset.status)
self.assertTrue(asset.file.startswith("upload_"))
self.assertTrue(asset.file.endswith(upload_file.name))
# check file exists
filepath = os.path.join(temp_assets, asset.file)
self.assertTrue(os.path.exists(filepath))
with open(filepath, "rb") as f:
self.assertEqual(file_content, f.read())
def test_upload_asset_should_be_failed_if_saving_file_fails(self):
bookmark = self.setup_bookmark()
# Use an invalid path to force an error
with override_settings(LD_ASSET_FOLDER="/non/existing/folder"):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertIsNone(asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
self.assertEqual("", asset.file)

View File

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

View File

@@ -44,6 +44,24 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
return {**form_data, **overrides} return {**form_data, **overrides}
def assertSuccessMessage(self, html, message: str, count=1):
self.assertInHTML(
f"""
<div class="toast toast-success mb-4">{ message }</div>
""",
html,
count=count,
)
def assertErrorMessage(self, html, message: str, count=1):
self.assertInHTML(
f"""
<div class="toast toast-error mb-4">{ message }</div>
""",
html,
count=count,
)
def test_should_render_successfully(self): def test_should_render_successfully(self):
response = self.client.get(reverse("bookmarks:settings.general")) response = self.client.get(reverse("bookmarks:settings.general"))
@@ -78,6 +96,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"display_archive_bookmark_action": False, "display_archive_bookmark_action": False,
"display_remove_bookmark_action": False, "display_remove_bookmark_action": False,
"permanent_notes": True, "permanent_notes": True,
"default_mark_unread": True,
"custom_css": "body { background-color: #000; }", "custom_css": "body { background-color: #000; }",
} }
response = self.client.post(reverse("bookmarks:settings.general"), form_data) response = self.client.post(reverse("bookmarks:settings.general"), form_data)
@@ -137,13 +156,11 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
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.assertInHTML( self.user.profile.default_mark_unread, form_data["default_mark_unread"]
"""
<p class="form-input-hint">Profile updated</p>
""",
html,
) )
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertSuccessMessage(html, "Profile updated")
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 +173,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 +221,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 +236,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 +365,57 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(requests, "get", return_value=latest_version_response_mock): with patch.object(requests, "get", return_value=latest_version_response_mock):
version_info = get_version_info(random.random()) version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version) self.assertEqual(version_info, app_version)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots(self):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 5
form_data = {
"create_missing_html_snapshots": "",
}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while..."
)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_missing_html_snapshots_no_missing_snapshots(self):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 0
form_data = {
"create_missing_html_snapshots": "",
}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_called_once()
self.assertSuccessMessage(html, "No missing snapshots found.")
def test_create_missing_html_snapshots_should_not_be_called_without_respective_form_action(
self,
):
with patch.object(
tasks, "create_missing_html_snapshots"
) as mock_create_missing_html_snapshots:
mock_create_missing_html_snapshots.return_value = 5
form_data = {}
response = self.client.post(
reverse("bookmarks:settings.general"), form_data
)
html = response.content.decode()
mock_create_missing_html_snapshots.assert_not_called()
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0
)

View File

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

View File

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

View File

@@ -44,12 +44,22 @@ 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>",
views.assets.view, views.assets.view,
name="assets.view", name="assets.view",
), ),
path(
"assets/<int:asset_id>/read",
views.assets.read,
name="assets.read",
),
# Partials # Partials
path( path(
"bookmarks/partials/bookmark-list/active", "bookmarks/partials/bookmark-list/active",
@@ -61,6 +71,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 +86,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 +102,9 @@ urlpatterns = [
name="partials.tag_cloud.shared", name="partials.tag_cloud.shared",
), ),
path( path(
"bookmarks/partials/details-form/<int:bookmark_id>", "bookmarks/partials/tag-modal/shared",
partials.details_form, partials.shared_tag_modal,
name="partials.details_form", name="partials.tag_modal.shared",
), ),
# Settings # Settings
path("settings", views.settings.general, name="settings.index"), path("settings", views.settings.general, name="settings.index"),

View File

@@ -6,11 +6,12 @@ from django.http import (
HttpResponse, HttpResponse,
Http404, Http404,
) )
from django.shortcuts import render
from bookmarks.models import BookmarkAsset from bookmarks.models import BookmarkAsset
def view(request, asset_id: int): def _access_asset(request, asset_id: int):
try: try:
asset = BookmarkAsset.objects.get(pk=asset_id) asset = BookmarkAsset.objects.get(pk=asset_id)
except BookmarkAsset.DoesNotExist: except BookmarkAsset.DoesNotExist:
@@ -28,6 +29,10 @@ def view(request, asset_id: int):
if not is_owner and not is_shared and not is_public_shared: if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist") raise Http404("Bookmark does not exist")
return asset
def _get_asset_content(asset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if not os.path.exists(filepath): if not os.path.exists(filepath):
@@ -40,4 +45,25 @@ def view(request, asset_id: int):
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
content = f.read() content = f.read()
return content
def view(request, asset_id: int):
asset = _access_asset(request, asset_id)
content = _get_asset_content(asset)
return HttpResponse(content, content_type=asset.content_type) return HttpResponse(content, content_type=asset.content_type)
def read(request, asset_id: int):
asset = _access_asset(request, asset_id)
content = _get_asset_content(asset)
content = content.decode("utf-8")
return render(
request,
"bookmarks/read.html",
{
"content": content,
},
)

View File

@@ -34,7 +34,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
) )
from bookmarks.services import tasks from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -145,6 +145,11 @@ def _details(request, bookmark_id: int, template: str):
asset.delete() asset.delete()
if "create_snapshot" in request.POST: if "create_snapshot" in request.POST:
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
if "upload_asset" in request.POST:
file = request.FILES.get("upload_asset_file")
if not file:
return HttpResponseBadRequest("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
else: else:
bookmark.is_archived = request.POST.get("is_archived") == "on" bookmark.is_archived = request.POST.get("is_archived") == "on"
bookmark.unread = request.POST.get("unread") == "on" bookmark.unread = request.POST.get("unread") == "on"
@@ -172,6 +177,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
@@ -184,6 +193,7 @@ def new(request):
initial_title = request.GET.get("title") initial_title = request.GET.get("title")
initial_description = request.GET.get("description") initial_description = request.GET.get("description")
initial_auto_close = "auto_close" in request.GET initial_auto_close = "auto_close" in request.GET
initial_mark_unread = request.user.profile.default_mark_unread
if request.method == "POST": if request.method == "POST":
form = BookmarkForm(request.POST) form = BookmarkForm(request.POST)
@@ -206,6 +216,8 @@ def new(request):
form.initial["description"] = initial_description form.initial["description"] = initial_description
if initial_auto_close: if initial_auto_close:
form.initial["auto_close"] = "true" form.initial["auto_close"] = "true"
if initial_mark_unread:
form.initial["unread"] = "true"
context = { context = {
"form": form, "form": form,

View File

@@ -1,8 +1,6 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.shortcuts import render from django.shortcuts import render
from bookmarks.models import Bookmark
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -24,6 +22,13 @@ def active_tag_cloud(request):
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context}) return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required
def active_tag_modal(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
@login_required @login_required
def archived_bookmark_list(request): def archived_bookmark_list(request):
bookmark_list_context = contexts.ArchivedBookmarkListContext(request) bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
@@ -43,6 +48,12 @@ def archived_tag_cloud(request):
@login_required @login_required
def archived_tag_modal(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
def shared_bookmark_list(request): def shared_bookmark_list(request):
bookmark_list_context = contexts.SharedBookmarkListContext(request) bookmark_list_context = contexts.SharedBookmarkListContext(request)
@@ -53,20 +64,13 @@ def shared_bookmark_list(request):
) )
@login_required
def shared_tag_cloud(request): def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request) tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context}) return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
@login_required def shared_tag_modal(request):
def details_form(request, bookmark_id: int): tag_cloud_context = contexts.SharedTagCloudContext(request)
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
details_context = contexts.BookmarkDetailsContext(request, bookmark) return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
return render(request, "bookmarks/details/form.html", {"details": details_context})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ rcssmin==1.1.1
# via django-compressor # via django-compressor
rjsmin==1.2.1 rjsmin==1.2.1
# via django-compressor # via django-compressor
sqlparse==0.4.4 sqlparse==0.5.0
# via # via
# django # django
# django-debug-toolbar # django-debug-toolbar

View File

@@ -43,7 +43,7 @@ djangorestframework==3.14.0
# via -r requirements.in # via -r requirements.in
huey==2.5.0 huey==2.5.0
# via -r requirements.in # via -r requirements.in
idna==3.6 idna==3.7
# via requests # via requests
josepy==1.14.0 josepy==1.14.0
# via mozilla-django-oidc # via mozilla-django-oidc
@@ -72,7 +72,7 @@ six==1.16.0
# python-dateutil # python-dateutil
soupsieve==2.5 soupsieve==2.5
# via beautifulsoup4 # via beautifulsoup4
sqlparse==0.4.4 sqlparse==0.5.0
# via django # via django
supervisor==4.2.5 supervisor==4.2.5
# via -r requirements.in # via -r requirements.in

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

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

View File

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

View File

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

View File

@@ -1 +1 @@
1.28.0 1.30.0

137
web-types.json Normal file
View File

@@ -0,0 +1,137 @@
{
"$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-upload-button",
"description": "Opens the related file input when clicked, and submits the form when a file is selected",
"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
}
}
]
}
}
}