mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-10 06:04:15 +00:00
Speed up response times for certain actions (#829)
* return updated HTML from bookmark actions * open details through URL * fix details update * improve modal behavior * use a frame * make behaviors properly destroy themselves * remove page and details params from tag urls * use separate behavior for details and tags * remove separate details view * make it work with other views * add asset actions * remove asset refresh for now * remove details partial * fix tests * remove old partials * update tests * cache and reuse tags * extract search autocomplete behavior * remove details param from pagination * fix tests * only return details modal when navigating in frame * fix link target * remove unused behaviors * use auto submit behavior for user select * fix import
This commit is contained in:
parent
db225d5267
commit
ffaaf0521d
65 changed files with 1419 additions and 1444 deletions
|
@ -121,8 +121,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
with self.page.expect_navigation():
|
with self.page.expect_navigation():
|
||||||
details_modal.get_by_text("Edit").click()
|
details_modal.get_by_text("Edit").click()
|
||||||
|
|
||||||
# Cancel edit, verify return url
|
# Cancel edit, verify return to details url
|
||||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
details_url = url + f"&details={bookmark.id}"
|
||||||
|
with self.page.expect_navigation(url=self.live_server_url + details_url):
|
||||||
self.page.get_by_text("Nevermind").click()
|
self.page.get_by_text("Nevermind").click()
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
|
@ -167,7 +168,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
# Has new snapshots
|
# Has new snapshots
|
||||||
expect(snapshot).to_be_visible()
|
expect(snapshot).to_be_visible()
|
||||||
|
|
||||||
# Create snapshot
|
# Remove snapshot
|
||||||
asset_list.get_by_text("Remove", exact=False).click()
|
asset_list.get_by_text("Remove", exact=False).click()
|
||||||
asset_list.get_by_text("Confirm", exact=False).click()
|
asset_list.get_by_text("Confirm", exact=False).click()
|
||||||
|
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
from django.urls import reverse
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
|
||||||
def test_edit_return_url(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
|
||||||
|
|
||||||
# Navigate to edit page
|
|
||||||
with self.page.expect_navigation():
|
|
||||||
self.page.get_by_text("Edit").click()
|
|
||||||
|
|
||||||
# Cancel edit, verify return url
|
|
||||||
with self.page.expect_navigation(
|
|
||||||
url=self.live_server_url
|
|
||||||
+ reverse("bookmarks:details", args=[bookmark.id])
|
|
||||||
):
|
|
||||||
self.page.get_by_text("Nevermind").click()
|
|
||||||
|
|
||||||
def test_delete_return_url(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
|
||||||
|
|
||||||
# Trigger delete, verify return url
|
|
||||||
# Should probably return to last bookmark list page, but for now just returns to index
|
|
||||||
with self.page.expect_navigation(
|
|
||||||
url=self.live_server_url + reverse("bookmarks:index")
|
|
||||||
):
|
|
||||||
self.page.get_by_text("Delete...").click()
|
|
||||||
self.page.get_by_text("Confirm").click()
|
|
|
@ -1,9 +1,7 @@
|
||||||
from django.test import override_settings
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from playwright.sync_api import sync_playwright, expect, Locator
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
from bookmarks.models import Bookmark
|
|
||||||
|
|
||||||
|
|
||||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
# verify modal is visible
|
# verify modal is visible
|
||||||
modal = page.locator(".modal")
|
modal = page.locator(".modal")
|
||||||
expect(modal).to_be_visible()
|
expect(modal).to_be_visible()
|
||||||
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
expect(modal.locator("h2")).to_have_text("Tags")
|
||||||
|
|
||||||
# close with close button
|
# close with close button
|
||||||
modal.locator("button.close").click()
|
modal.locator("button.close").click()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export class ApiClient {
|
export class Api {
|
||||||
constructor(baseUrl) {
|
constructor(baseUrl) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
@ -27,3 +27,6 @@ export class ApiClient {
|
||||||
.then((data) => data.results);
|
.then((data) => data.results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||||
|
export const api = new Api(apiBaseUrl);
|
||||||
|
|
|
@ -5,9 +5,10 @@ class BookmarkItem extends Behavior {
|
||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
// Toggle notes
|
// Toggle notes
|
||||||
const notesToggle = element.querySelector(".toggle-notes");
|
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||||
if (notesToggle) {
|
this.notesToggle = element.querySelector(".toggle-notes");
|
||||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
if (this.notesToggle) {
|
||||||
|
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tooltip to title if it is truncated
|
// Add tooltip to title if it is truncated
|
||||||
|
@ -20,6 +21,12 @@ class BookmarkItem extends Behavior {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.notesToggle) {
|
||||||
|
this.notesToggle.removeEventListener("click", this.onToggleNotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onToggleNotes(event) {
|
onToggleNotes(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
|
@ -13,12 +13,13 @@ class BulkEdit extends Behavior {
|
||||||
this.onActionSelected = this.onActionSelected.bind(this);
|
this.onActionSelected = this.onActionSelected.bind(this);
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
// Reset when bookmarks are refreshed
|
// Reset when bookmarks are updated
|
||||||
document.addEventListener("refresh-bookmark-list-done", this.init);
|
document.addEventListener("bookmark-list-updated", this.init);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
document.removeEventListener("refresh-bookmark-list-done", this.init);
|
this.removeListeners();
|
||||||
|
document.removeEventListener("bookmark-list-updated", this.init);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
@ -36,13 +37,9 @@ class BulkEdit extends Behavior {
|
||||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove previous listeners if elements are the same
|
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
||||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
this.removeListeners();
|
||||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
this.addListeners();
|
||||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
|
||||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
|
||||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset checkbox states
|
// Reset checkbox states
|
||||||
this.reset();
|
this.reset();
|
||||||
|
@ -52,8 +49,9 @@ class BulkEdit extends Behavior {
|
||||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||||
totalSpan.textContent = total;
|
totalSpan.textContent = total;
|
||||||
|
}
|
||||||
|
|
||||||
// Add new listeners
|
addListeners() {
|
||||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||||
|
@ -62,6 +60,15 @@ class BulkEdit extends Behavior {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeListeners() {
|
||||||
|
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||||
|
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||||
|
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onToggleActive() {
|
onToggleActive() {
|
||||||
this.active = !this.active;
|
this.active = !this.active;
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
|
|
|
@ -3,20 +3,14 @@ import { Behavior, registerBehavior } from "./index";
|
||||||
class ConfirmButtonBehavior extends Behavior {
|
class ConfirmButtonBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
element.dataset.type = element.type;
|
|
||||||
element.dataset.name = element.name;
|
this.onClick = this.onClick.bind(this);
|
||||||
element.dataset.value = element.value;
|
element.addEventListener("click", this.onClick);
|
||||||
element.removeAttribute("type");
|
|
||||||
element.removeAttribute("name");
|
|
||||||
element.removeAttribute("value");
|
|
||||||
element.addEventListener("click", this.onClick.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.reset();
|
this.reset();
|
||||||
this.element.setAttribute("type", this.element.dataset.type);
|
this.element.removeEventListener("click", this.onClick);
|
||||||
this.element.setAttribute("name", this.element.dataset.name);
|
|
||||||
this.element.setAttribute("value", this.element.dataset.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(event) {
|
onClick(event) {
|
||||||
|
@ -56,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
|
||||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
const confirmButton = document.createElement(this.element.nodeName);
|
const confirmButton = document.createElement(this.element.nodeName);
|
||||||
confirmButton.type = this.element.dataset.type;
|
confirmButton.type = this.element.type;
|
||||||
confirmButton.name = this.element.dataset.name;
|
confirmButton.name = this.element.name;
|
||||||
confirmButton.value = this.element.dataset.value;
|
confirmButton.value = this.element.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));
|
||||||
|
|
62
bookmarks/frontend/behaviors/details-modal.js
Normal file
62
bookmarks/frontend/behaviors/details-modal.js
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class DetailsModalBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
|
||||||
|
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||||
|
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||||
|
|
||||||
|
this.overlayLink.addEventListener("click", this.onClose);
|
||||||
|
this.buttonLink.addEventListener("click", this.onClose);
|
||||||
|
document.addEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.overlayLink.removeEventListener("click", this.onClose);
|
||||||
|
this.buttonLink.removeEventListener("click", this.onClose);
|
||||||
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget =
|
||||||
|
targetNodeName === "INPUT" ||
|
||||||
|
targetNodeName === "SELECT" ||
|
||||||
|
targetNodeName === "TEXTAREA";
|
||||||
|
|
||||||
|
if (isInputTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
this.onClose(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.element.classList.add("closing");
|
||||||
|
this.element.addEventListener(
|
||||||
|
"animationend",
|
||||||
|
(event) => {
|
||||||
|
if (event.animationName === "fade-out") {
|
||||||
|
this.element.remove();
|
||||||
|
|
||||||
|
const closeUrl = this.overlayLink.href;
|
||||||
|
Turbo.visit(closeUrl, {
|
||||||
|
action: "replace",
|
||||||
|
frame: "details-modal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-details-modal", DetailsModalBehavior);
|
|
@ -4,20 +4,16 @@ class DropdownBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
this.opened = false;
|
this.opened = false;
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||||
|
|
||||||
const toggle = element.querySelector(".dropdown-toggle");
|
this.toggle = element.querySelector(".dropdown-toggle");
|
||||||
toggle.addEventListener("click", () => {
|
this.toggle.addEventListener("click", this.onClick);
|
||||||
if (this.opened) {
|
|
||||||
this.close();
|
|
||||||
} else {
|
|
||||||
this.open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.toggle.removeEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
@ -30,6 +26,14 @@ class DropdownBehavior extends Behavior {
|
||||||
document.removeEventListener("click", this.onOutsideClick);
|
document.removeEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
if (this.opened) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onOutsideClick(event) {
|
onOutsideClick(event) {
|
||||||
if (!this.element.contains(event.target)) {
|
if (!this.element.contains(event.target)) {
|
||||||
this.close();
|
this.close();
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
|
||||||
|
|
||||||
class FetchBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
const eventName = element.getAttribute("ld-on");
|
|
||||||
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
|
||||||
|
|
||||||
this.onFetch = this.onFetch.bind(this);
|
|
||||||
this.onInterval = this.onInterval.bind(this);
|
|
||||||
|
|
||||||
element.addEventListener(eventName, this.onFetch);
|
|
||||||
if (interval) {
|
|
||||||
this.intervalId = setInterval(this.onInterval, interval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.intervalId) {
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onFetch(maybeEvent) {
|
|
||||||
if (maybeEvent) {
|
|
||||||
maybeEvent.preventDefault();
|
|
||||||
}
|
|
||||||
const url = this.element.getAttribute("ld-fetch");
|
|
||||||
const html = await fetch(url).then((response) => response.text());
|
|
||||||
|
|
||||||
const target = this.element.getAttribute("ld-target");
|
|
||||||
const select = this.element.getAttribute("ld-select");
|
|
||||||
swap(this.element, html, { target, select });
|
|
||||||
|
|
||||||
const events = this.element.getAttribute("ld-fire");
|
|
||||||
fireEvents(events);
|
|
||||||
}
|
|
||||||
|
|
||||||
onInterval() {
|
|
||||||
if (Behavior.interacting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.onFetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-fetch", FetchBehavior);
|
|
|
@ -1,64 +1,55 @@
|
||||||
import { Behavior, fireEvents, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
class FormBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
element.addEventListener("submit", this.onSubmit.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
async onSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const url = this.element.action;
|
|
||||||
const formData = new FormData(this.element);
|
|
||||||
if (event.submitter) {
|
|
||||||
formData.append(event.submitter.name, event.submitter.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
redirect: "manual", // ignore redirect
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = this.element.getAttribute("ld-fire");
|
|
||||||
if (fireEvents) {
|
|
||||||
fireEvents(events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AutoSubmitBehavior extends Behavior {
|
class AutoSubmitBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
element.addEventListener("change", () => {
|
this.submit = this.submit.bind(this);
|
||||||
const form = element.closest("form");
|
element.addEventListener("change", this.submit);
|
||||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
}
|
||||||
});
|
|
||||||
|
destroy() {
|
||||||
|
this.element.removeEventListener("change", this.submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
this.element.closest("form").requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadButton extends Behavior {
|
class UploadButton extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
this.fileInput = element.nextElementSibling;
|
||||||
|
|
||||||
const fileInput = element.nextElementSibling;
|
this.onClick = this.onClick.bind(this);
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
|
||||||
element.addEventListener("click", () => {
|
element.addEventListener("click", this.onClick);
|
||||||
fileInput.click();
|
this.fileInput.addEventListener("change", this.onChange);
|
||||||
});
|
}
|
||||||
|
|
||||||
fileInput.addEventListener("change", () => {
|
destroy() {
|
||||||
const form = fileInput.closest("form");
|
this.element.removeEventListener("click", this.onClick);
|
||||||
const event = new Event("submit", { cancelable: true });
|
this.fileInput.removeEventListener("change", this.onChange);
|
||||||
event.submitter = element;
|
}
|
||||||
form.dispatchEvent(event);
|
|
||||||
});
|
onClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange() {
|
||||||
|
// Check if the file input has a file selected
|
||||||
|
if (!this.fileInput.files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = this.fileInput.closest("form");
|
||||||
|
form.requestSubmit(this.element);
|
||||||
|
// remove selected file so it doesn't get submitted again
|
||||||
|
this.fileInput.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerBehavior("ld-form", FormBehavior);
|
|
||||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||||
registerBehavior("ld-upload-button", UploadButton);
|
registerBehavior("ld-upload-button", UploadButton);
|
||||||
|
|
|
@ -103,51 +103,3 @@ export function destroyBehaviors(element) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function swap(element, html, options) {
|
|
||||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
|
||||||
|
|
||||||
let targetElement = element;
|
|
||||||
let strategy = "innerHTML";
|
|
||||||
if (options.target) {
|
|
||||||
const parts = options.target.split("|");
|
|
||||||
targetElement =
|
|
||||||
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
|
||||||
strategy = parts[1] || "innerHTML";
|
|
||||||
}
|
|
||||||
|
|
||||||
let contents = Array.from(dom.body.children);
|
|
||||||
if (options.select) {
|
|
||||||
contents = Array.from(dom.querySelectorAll(options.select));
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (strategy) {
|
|
||||||
case "append":
|
|
||||||
targetElement.append(...contents);
|
|
||||||
break;
|
|
||||||
case "outerHTML":
|
|
||||||
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
|
||||||
break;
|
|
||||||
case "innerHTML":
|
|
||||||
default:
|
|
||||||
Array.from(targetElement.children).forEach((child) => {
|
|
||||||
child.remove();
|
|
||||||
});
|
|
||||||
targetElement.append(...contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fireEvents(events) {
|
|
||||||
if (!events) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
events.split(",").forEach((eventName) => {
|
|
||||||
const targets = Array.from(
|
|
||||||
document.querySelectorAll(`[ld-on='${eventName}']`),
|
|
||||||
);
|
|
||||||
targets.push(document);
|
|
||||||
targets.forEach((target) => {
|
|
||||||
target.dispatchEvent(new CustomEvent(eventName));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class ModalBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeyDown(event) {
|
|
||||||
// Skip if event occurred within an input element
|
|
||||||
const targetNodeName = event.target.nodeName;
|
|
||||||
const isInputTarget =
|
|
||||||
targetNodeName === "INPUT" ||
|
|
||||||
targetNodeName === "SELECT" ||
|
|
||||||
targetNodeName === "TEXTAREA";
|
|
||||||
|
|
||||||
if (isInputTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
this.onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
this.element.classList.add("closing");
|
|
||||||
this.element.addEventListener("animationend", (event) => {
|
|
||||||
if (event.animationName === "fade-out") {
|
|
||||||
this.element.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-modal", ModalBehavior);
|
|
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||||
|
|
||||||
|
class SearchAutocomplete extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
const input = element.querySelector("input");
|
||||||
|
if (!input) {
|
||||||
|
console.warn("SearchAutocomplete: input element not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
|
||||||
|
new SearchAutoCompleteComponent({
|
||||||
|
target: container,
|
||||||
|
props: {
|
||||||
|
name: "q",
|
||||||
|
placeholder: input.getAttribute("placeholder") || "",
|
||||||
|
value: input.value,
|
||||||
|
linkTarget: input.dataset.linkTarget,
|
||||||
|
mode: input.dataset.mode,
|
||||||
|
search: {
|
||||||
|
user: input.dataset.user,
|
||||||
|
shared: input.dataset.shared,
|
||||||
|
unread: input.dataset.unread,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input = input;
|
||||||
|
this.autocomplete = container.firstElementChild;
|
||||||
|
input.replaceWith(this.autocomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.autocomplete.replaceWith(this.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-search-autocomplete", SearchAutocomplete);
|
|
@ -1,19 +1,16 @@
|
||||||
import { Behavior, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||||
import { ApiClient } from "../api";
|
|
||||||
|
|
||||||
class TagAutocomplete extends Behavior {
|
class TagAutocomplete extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
const input = element.querySelector("input");
|
const input = element.querySelector("input");
|
||||||
if (!input) {
|
if (!input) {
|
||||||
console.warning("TagAutocomplete: input element not found");
|
console.warn("TagAutocomplete: input element not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
|
||||||
const apiClient = new ApiClient(apiBaseUrl);
|
|
||||||
|
|
||||||
new TagAutoCompleteComponent({
|
new TagAutoCompleteComponent({
|
||||||
target: container,
|
target: container,
|
||||||
|
@ -22,7 +19,6 @@ class TagAutocomplete extends Behavior {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
value: input.value,
|
value: input.value,
|
||||||
placeholder: input.getAttribute("placeholder") || "",
|
placeholder: input.getAttribute("placeholder") || "",
|
||||||
apiClient: apiClient,
|
|
||||||
variant: input.getAttribute("variant"),
|
variant: input.getAttribute("variant"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
68
bookmarks/frontend/behaviors/tag-modal.js
Normal file
68
bookmarks/frontend/behaviors/tag-modal.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class TagModalBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
|
||||||
|
element.addEventListener("click", this.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.onClose();
|
||||||
|
this.element.removeEventListener("click", this.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
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">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<button class="close" aria-label="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 tagCloud = document.querySelector(".tag-cloud");
|
||||||
|
const tagCloudContainer = tagCloud.parentElement;
|
||||||
|
|
||||||
|
const content = modal.querySelector(".content");
|
||||||
|
content.appendChild(tagCloud);
|
||||||
|
|
||||||
|
const overlay = modal.querySelector(".modal-overlay");
|
||||||
|
const closeButton = modal.querySelector(".close");
|
||||||
|
overlay.addEventListener("click", this.onClose);
|
||||||
|
closeButton.addEventListener("click", this.onClose);
|
||||||
|
|
||||||
|
this.modal = modal;
|
||||||
|
this.tagCloud = tagCloud;
|
||||||
|
this.tagCloudContainer = tagCloudContainer;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
if (!this.modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modal.remove();
|
||||||
|
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-tag-modal", TagModalBehavior);
|
35
bookmarks/frontend/cache.js
Normal file
35
bookmarks/frontend/cache.js
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { api } from "./api.js";
|
||||||
|
|
||||||
|
class Cache {
|
||||||
|
constructor(api) {
|
||||||
|
this.api = api;
|
||||||
|
|
||||||
|
// Reset cached tags after a form submission
|
||||||
|
document.addEventListener("turbo:submit-end", () => {
|
||||||
|
this.tagsPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTags() {
|
||||||
|
if (!this.tagsPromise) {
|
||||||
|
this.tagsPromise = this.api
|
||||||
|
.getTags({
|
||||||
|
limit: 5000,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
.then((tags) =>
|
||||||
|
tags.sort((left, right) =>
|
||||||
|
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn("Cache: Error loading tags", e);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = new Cache(api);
|
|
@ -1,5 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import {SearchHistory} from "./SearchHistory";
|
import {SearchHistory} from "./SearchHistory";
|
||||||
|
import {api} from "../api";
|
||||||
|
import {cache} from "../cache";
|
||||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||||
|
|
||||||
const searchHistory = new SearchHistory()
|
const searchHistory = new SearchHistory()
|
||||||
|
@ -7,9 +9,7 @@
|
||||||
export let name;
|
export let name;
|
||||||
export let placeholder;
|
export let placeholder;
|
||||||
export let value;
|
export let value;
|
||||||
export let tags;
|
|
||||||
export let mode = '';
|
export let mode = '';
|
||||||
export let apiClient;
|
|
||||||
export let search;
|
export let search;
|
||||||
export let linkTarget = '_blank';
|
export let linkTarget = '_blank';
|
||||||
|
|
||||||
|
@ -88,17 +88,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag suggestions
|
// Tag suggestions
|
||||||
|
const tags = await cache.getTags();
|
||||||
let tagSuggestions = []
|
let tagSuggestions = []
|
||||||
const currentWord = getCurrentWord(input)
|
const currentWord = getCurrentWord(input)
|
||||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||||
const searchTag = currentWord.substring(1, currentWord.length)
|
const searchTag = currentWord.substring(1, currentWord.length)
|
||||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(tagName => ({
|
.map(tag => ({
|
||||||
type: 'tag',
|
type: 'tag',
|
||||||
index: nextIndex(),
|
index: nextIndex(),
|
||||||
label: `#${tagName}`,
|
label: `#${tag.name}`,
|
||||||
tagName: tagName
|
tagName: tag.name
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,7 +120,7 @@
|
||||||
...search,
|
...search,
|
||||||
q: value
|
q: value
|
||||||
}
|
}
|
||||||
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||||
const label = clampText(fullLabel, 60)
|
const label = clampText(fullLabel, 60)
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
|
import {cache} from "../cache";
|
||||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||||
|
|
||||||
export let id;
|
export let id;
|
||||||
export let name;
|
export let name;
|
||||||
export let value;
|
export let value;
|
||||||
export let placeholder;
|
export let placeholder;
|
||||||
export let apiClient;
|
|
||||||
export let variant = 'default';
|
export let variant = 'default';
|
||||||
|
|
||||||
let tags = [];
|
|
||||||
let isFocus = false;
|
let isFocus = false;
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let input = null;
|
let input = null;
|
||||||
|
@ -17,18 +16,6 @@
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
let selectedIndex = 0;
|
let selectedIndex = 0;
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
// For now we cache all tags on load as the template did before
|
|
||||||
try {
|
|
||||||
tags = await apiClient.getTags({limit: 5000, offset: 0});
|
|
||||||
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('TagAutocomplete: Error loading tag list');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
isFocus = true;
|
isFocus = true;
|
||||||
}
|
}
|
||||||
|
@ -38,9 +25,10 @@
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput(e) {
|
async function handleInput(e) {
|
||||||
input = e.target;
|
input = e.target;
|
||||||
|
|
||||||
|
const tags = await cache.getTags();
|
||||||
const word = getCurrentWord(input);
|
const word = getCurrentWord(input);
|
||||||
|
|
||||||
suggestions = word
|
suggestions = word
|
||||||
|
|
|
@ -3,11 +3,14 @@ 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/details-modal";
|
||||||
import "./behaviors/global-shortcuts";
|
import "./behaviors/global-shortcuts";
|
||||||
|
import "./behaviors/search-autocomplete";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./behaviors/tag-autocomplete";
|
||||||
|
import "./behaviors/tag-modal";
|
||||||
|
|
||||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||||
export { ApiClient } from "./api";
|
export { api } from "./api";
|
||||||
|
export { cache } from "./cache";
|
||||||
|
|
|
@ -282,7 +282,7 @@ class BookmarkSearchForm(forms.Form):
|
||||||
]
|
]
|
||||||
|
|
||||||
q = forms.CharField()
|
q = forms.CharField()
|
||||||
user = forms.ChoiceField()
|
user = forms.ChoiceField(required=False)
|
||||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||||
|
|
|
@ -1,12 +1,5 @@
|
||||||
/* Common styles */
|
/* Common styles */
|
||||||
.bookmark-details {
|
.bookmark-details {
|
||||||
& h2 {
|
|
||||||
flex: 1 1 0;
|
|
||||||
align-items: flex-start;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .weblinks {
|
& .weblinks {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -134,17 +127,3 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--unit-6);
|
gap: var(--unit-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bookmark details modal specific */
|
|
||||||
.bookmark-details.modal {
|
|
||||||
& .modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--unit-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .modal-body {
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -65,8 +65,18 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
& .modal-header {
|
& .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--unit-2);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
||||||
|
& h2 {
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
& button.close {
|
& button.close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
@ -11,24 +11,20 @@
|
||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Archived bookmarks</h2>
|
<h2>Archived bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
|
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||||
class="btn ml-2 show-md">Tags
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
<form class="bookmark-actions"
|
||||||
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 ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
<div id="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>
|
||||||
|
@ -39,10 +35,16 @@
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
<div id="tag-cloud-container">
|
||||||
class="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -78,10 +78,7 @@
|
||||||
{% 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-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||||
ld-on="click" ld-target="body|append"
|
|
||||||
data-turbo-prefetch="false"
|
|
||||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.is_editable %}
|
{% if bookmark_item.is_editable %}
|
||||||
{# Bookmark owner actions #}
|
{# Bookmark owner actions #}
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
{% extends 'bookmarks/layout.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="bookmark-details page">
|
|
||||||
{% if details.is_editable %}
|
|
||||||
{% include 'bookmarks/details/actions.html' %}
|
|
||||||
{% endif %}
|
|
||||||
{% include 'bookmarks/details/title.html' %}
|
|
||||||
<div>
|
|
||||||
{% include 'bookmarks/details/form.html' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<div class="actions">
|
|
||||||
<div class="left-actions">
|
|
||||||
<a class="btn btn-wide"
|
|
||||||
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
|
||||||
</div>
|
|
||||||
<div class="right-actions">
|
|
||||||
<form action="{% url 'bookmarks:index.action' %}?return_url={{ details.delete_return_url|urlencode }}"
|
|
||||||
method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
|
|
||||||
class="btn btn-error btn-wide">
|
|
||||||
Delete...
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,7 +1,4 @@
|
||||||
<div {% if details.has_pending_assets %}
|
<div>
|
||||||
ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
|
|
||||||
ld-interval="5" ld-target="self|outerHTML"
|
|
||||||
{% endif %}>
|
|
||||||
{% if details.assets %}
|
{% if details.assets %}
|
||||||
<div class="assets">
|
<div class="assets">
|
||||||
{% for asset in details.assets %}
|
{% for asset in details.assets %}
|
||||||
|
@ -36,10 +33,10 @@
|
||||||
|
|
||||||
{% 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-sm"
|
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||||
</button>
|
</button>
|
||||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
|
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||||
class="btn btn-sm">Upload file
|
class="btn btn-sm">Upload file
|
||||||
</button>
|
</button>
|
||||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load shared %}
|
{% load shared %}
|
||||||
|
|
||||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
|
<form action="{{ details.action_url }}" method="post" enctype="multipart/form-data">
|
||||||
action="{% url 'bookmarks:details' details.bookmark.id %}"
|
{% csrf_token %}
|
||||||
method="post">
|
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
|
||||||
|
|
||||||
<div class="weblinks">
|
<div class="weblinks">
|
||||||
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
|
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
|
||||||
target="{{ details.profile.bookmark_link_target }}">
|
target="{{ details.profile.bookmark_link_target }}">
|
||||||
|
@ -47,7 +48,6 @@
|
||||||
<div class="status col-2">
|
<div class="status col-2">
|
||||||
<dt>Status</dt>
|
<dt>Status</dt>
|
||||||
<dd class="d-flex" style="gap: .8rem">
|
<dd class="d-flex" style="gap: .8rem">
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-switch">
|
<label class="form-switch">
|
||||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||||
|
|
47
bookmarks/templates/bookmarks/details/modal.html
Normal file
47
bookmarks/templates/bookmarks/details/modal.html
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<div class="modal active bookmark-details"
|
||||||
|
ld-details-modal>
|
||||||
|
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||||
|
<div class="modal-overlay" aria-label="Close"></div>
|
||||||
|
</a>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||||
|
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="content">
|
||||||
|
{% include 'bookmarks/details/form.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if details.is_editable %}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="actions">
|
||||||
|
<div class="left-actions">
|
||||||
|
<a class="btn btn-wide"
|
||||||
|
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||||
|
</div>
|
||||||
|
<div class="right-actions">
|
||||||
|
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="disable_turbo" value="true">
|
||||||
|
<button ld-confirm-button class="btn btn-error btn-wide"
|
||||||
|
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
||||||
|
Delete...
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,3 +0,0 @@
|
||||||
<h2>
|
|
||||||
{{ details.bookmark.resolved_title }}
|
|
||||||
</h2>
|
|
|
@ -1,30 +0,0 @@
|
||||||
<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-container">
|
|
||||||
<div class="modal-header">
|
|
||||||
{% include 'bookmarks/details/title.html' %}
|
|
||||||
<button class="close">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
||||||
<path d="M18 6l-12 12"></path>
|
|
||||||
<path d="M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="content">
|
|
||||||
{% include 'bookmarks/details/form.html' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if details.is_editable %}
|
|
||||||
<div class="modal-footer">
|
|
||||||
{% include 'bookmarks/details/actions.html' %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
40
bookmarks/templates/bookmarks/head.html
Normal file
40
bookmarks/templates/bookmarks/head.html
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
|
||||||
|
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
|
||||||
|
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
|
||||||
|
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||||
|
<meta name="description" content="Self-hosted bookmark service">
|
||||||
|
<meta name="robots" content="index,follow">
|
||||||
|
<meta name="author" content="Sascha Ißbrücker">
|
||||||
|
<title>linkding</title>
|
||||||
|
{# Include specific theme variant based on user profile setting #}
|
||||||
|
{% if request.user_profile.theme == 'light' %}
|
||||||
|
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
|
<meta name="theme-color" content="#5856e0">
|
||||||
|
{% elif request.user_profile.theme == 'dark' %}
|
||||||
|
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
|
<meta name="theme-color" content="#161822">
|
||||||
|
{% else %}
|
||||||
|
{# Use auto theme as fallback #}
|
||||||
|
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
|
media="(prefers-color-scheme: dark)"/>
|
||||||
|
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
|
media="(prefers-color-scheme: light)"/>
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||||
|
{% endif %}
|
||||||
|
{% if request.user_profile.custom_css %}
|
||||||
|
<style>{{ request.user_profile.custom_css }}</style>
|
||||||
|
{% endif %}
|
||||||
|
<meta name="turbo-cache-control" content="no-preview">
|
||||||
|
{% if not request.global_settings.enable_link_prefetch %}
|
||||||
|
<meta name="turbo-prefetch" content="false">
|
||||||
|
{% endif %}
|
||||||
|
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||||
|
</head>
|
|
@ -11,24 +11,19 @@
|
||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Bookmarks</h2>
|
<h2>Bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search tag_cloud.tags %}
|
{% bookmark_search bookmark_list.search %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
|
||||||
class="btn ml-2 show-md">Tags
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
<form class="bookmark-actions"
|
||||||
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 ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
<div id="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>
|
||||||
|
@ -39,10 +34,16 @@
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
<div id="tag-cloud-container">
|
||||||
class="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -3,44 +3,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
{# Use data attributes as storage for access in static scripts #}
|
{# Use data attributes as storage for access in static scripts #}
|
||||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||||
<head>
|
{% include 'bookmarks/head.html' %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
|
|
||||||
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
|
|
||||||
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
|
|
||||||
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
|
||||||
<meta name="description" content="Self-hosted bookmark service">
|
|
||||||
<meta name="robots" content="index,follow">
|
|
||||||
<meta name="author" content="Sascha Ißbrücker">
|
|
||||||
<title>linkding</title>
|
|
||||||
{# Include specific theme variant based on user profile setting #}
|
|
||||||
{% if request.user_profile.theme == 'light' %}
|
|
||||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
|
||||||
<meta name="theme-color" content="#5856e0">
|
|
||||||
{% elif request.user_profile.theme == 'dark' %}
|
|
||||||
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
|
||||||
<meta name="theme-color" content="#161822">
|
|
||||||
{% else %}
|
|
||||||
{# Use auto theme as fallback #}
|
|
||||||
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
|
||||||
media="(prefers-color-scheme: dark)"/>
|
|
||||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
|
||||||
media="(prefers-color-scheme: light)"/>
|
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
|
||||||
{% endif %}
|
|
||||||
{% if request.user_profile.custom_css %}
|
|
||||||
<style>{{ request.user_profile.custom_css }}</style>
|
|
||||||
{% endif %}
|
|
||||||
<meta name="turbo-cache-control" content="no-preview">
|
|
||||||
{% if not request.global_settings.enable_link_prefetch %}
|
|
||||||
<meta name="turbo-prefetch" content="false">
|
|
||||||
{% endif %}
|
|
||||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
|
||||||
</head>
|
|
||||||
<body ld-global-shortcuts>
|
<body ld-global-shortcuts>
|
||||||
|
|
||||||
<div class="d-none">
|
<div class="d-none">
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
{% load shared %}
|
{% load shared %}
|
||||||
|
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if page.has_previous %}
|
{% if prev_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
|
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
|
@ -11,10 +11,10 @@
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_number in visible_page_numbers %}
|
{% for page_link in page_links %}
|
||||||
{% if page_number >= 0 %}
|
{% if page_link %}
|
||||||
<li class="page-item {% if page.number == page_number %}active{% endif %}">
|
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||||
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
|
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
|
@ -23,9 +23,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page.has_next %}
|
{% if next_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
|
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
{% load widget_tweaks %}
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
<div class="search-container">
|
<div ld-search-autocomplete class="search-container">
|
||||||
<form id="search" action="" method="get" role="search">
|
<form id="search" action="" method="get" role="search">
|
||||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||||
value="{{ search.q }}">
|
value="{{ search.q }}"
|
||||||
|
data-link-target="{{ request.user_profile.bookmark_link_target }}"
|
||||||
|
data-mode="{{ mode }}"
|
||||||
|
data-user="{{ search.user }}"
|
||||||
|
data-shared="{{ search.shared }}"
|
||||||
|
data-unread="{{ search.unread }}">
|
||||||
<input type="submit" value="Search" class="d-none">
|
<input type="submit" value="Search" class="d-none">
|
||||||
{% for hidden_field in search_form.hidden_fields %}
|
{% for hidden_field in search_form.hidden_fields %}
|
||||||
{{ hidden_field }}
|
{{ hidden_field }}
|
||||||
|
@ -73,42 +78,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{# Replace search input with auto-complete component #}
|
|
||||||
<script type="application/javascript">
|
|
||||||
(function init() {
|
|
||||||
const currentTagsString = '{{ tags_string }}';
|
|
||||||
const currentTags = currentTagsString.split(' ');
|
|
||||||
const uniqueTags = [...new Set(currentTags)]
|
|
||||||
const search = {
|
|
||||||
q: '{{ search.q }}',
|
|
||||||
user: '{{ search.user }}',
|
|
||||||
shared: '{{ search.shared }}',
|
|
||||||
unread: '{{ search.unread }}',
|
|
||||||
}
|
|
||||||
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
|
||||||
const input = document.querySelector('#search input[name="q"]')
|
|
||||||
const container = document.createElement('div')
|
|
||||||
new linkding.SearchAutoComplete({
|
|
||||||
target: container,
|
|
||||||
props: {
|
|
||||||
name: 'q',
|
|
||||||
placeholder: 'Search for words or #tags',
|
|
||||||
value: input.value,
|
|
||||||
tags: uniqueTags,
|
|
||||||
mode: '{{ mode }}',
|
|
||||||
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
|
|
||||||
apiClient,
|
|
||||||
search,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const autoComplete = container.firstElementChild;
|
|
||||||
input.replaceWith(autoComplete);
|
|
||||||
|
|
||||||
document.addEventListener("turbo:before-cache", () => {
|
|
||||||
autoComplete.replaceWith(input);
|
|
||||||
}, {once: true});
|
|
||||||
})();
|
|
||||||
</script>
|
|
|
@ -11,21 +11,17 @@
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<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 mode='shared' %}
|
||||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||||
class="btn ml-2 show-md">Tags
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
<form class="bookmark-actions"
|
||||||
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 %}
|
||||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
<div id="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>
|
||||||
|
@ -43,10 +39,16 @@
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
<div id="tag-cloud-container">
|
||||||
class="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
<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>
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
<turbo-stream action="update" target="bookmark-list-container">
|
||||||
|
<template>
|
||||||
|
{% include 'bookmarks/bookmark_list.html' %}
|
||||||
|
<script>
|
||||||
|
document.dispatchEvent(new CustomEvent('bookmark-list-updated'));
|
||||||
|
</script>
|
||||||
|
</template>
|
||||||
|
</turbo-stream>
|
||||||
|
<turbo-stream action="update" target="tag-cloud-container">
|
||||||
|
<template>
|
||||||
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
|
</template>
|
||||||
|
</turbo-stream>
|
||||||
|
|
||||||
|
<turbo-stream action="update" method="morph" target="details-modal">
|
||||||
|
<template>
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</template>
|
||||||
|
</turbo-stream>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html>
|
||||||
|
{% include 'bookmarks/head.html' %}
|
||||||
|
<body>
|
||||||
|
<turbo-frame id="details-modal">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -6,17 +6,10 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
{{ form.user|add_class:"form-select" }}
|
{% render_field form.user class+="form-select" ld-auto-submit="" %}
|
||||||
<noscript>
|
<noscript>
|
||||||
<button type="submit" class="btn btn-link ml-2">Apply</button>
|
<button type="submit" class="btn btn-link ml-2">Apply</button>
|
||||||
</noscript>
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
|
||||||
const form = document.getElementById('user-select');
|
|
||||||
const select = form.querySelector('select');
|
|
||||||
select.addEventListener('change', () => {
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
||||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
|
||||||
class="btn btn-primary">📎 Add bookmark</a>
|
class="btn btn-primary">📎 Add bookmark</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
<strong>Please treat this token as you would any other credential.</strong>
|
<strong>Please treat this token as you would any other credential.</strong>
|
||||||
Any party with access to this token can access and manage all your bookmarks.
|
Any party with access to this token can access and manage all your bookmarks.
|
||||||
If you think that a token was compromised you can revoke (delete) it in the <a
|
If you think that a token was compromised you can revoke (delete) it in the <a
|
||||||
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
target="_blank" href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
||||||
After deleting the token, a new one will be generated when you reload this settings page.
|
After deleting the token, a new one will be generated when you reload this settings page.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -6,8 +6,6 @@ from bookmarks.models import (
|
||||||
BookmarkForm,
|
BookmarkForm,
|
||||||
BookmarkSearch,
|
BookmarkSearch,
|
||||||
BookmarkSearchForm,
|
BookmarkSearchForm,
|
||||||
Tag,
|
|
||||||
build_tag_string,
|
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -34,9 +32,7 @@ def bookmark_form(
|
||||||
@register.inclusion_tag(
|
@register.inclusion_tag(
|
||||||
"bookmarks/search.html", name="bookmark_search", takes_context=True
|
"bookmarks/search.html", name="bookmark_search", takes_context=True
|
||||||
)
|
)
|
||||||
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""):
|
def bookmark_search(context, search: BookmarkSearch, mode: str = ""):
|
||||||
tag_names = [tag.name for tag in tags]
|
|
||||||
tags_string = build_tag_string(tag_names, " ")
|
|
||||||
search_form = BookmarkSearchForm(search, editable_fields=["q"])
|
search_form = BookmarkSearchForm(search, editable_fields=["q"])
|
||||||
|
|
||||||
if mode == "shared":
|
if mode == "shared":
|
||||||
|
@ -50,7 +46,6 @@ def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""
|
||||||
"search": search,
|
"search": search,
|
||||||
"search_form": search_form,
|
"search_form": search_form,
|
||||||
"preferences_form": preferences_form,
|
"preferences_form": preferences_form,
|
||||||
"tags_string": tags_string,
|
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ from functools import reduce
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.core.paginator import Page
|
from django.core.paginator import Page
|
||||||
|
from django.http import QueryDict
|
||||||
|
|
||||||
NUM_ADJACENT_PAGES = 2
|
NUM_ADJACENT_PAGES = 2
|
||||||
|
|
||||||
|
@ -12,11 +13,44 @@ register = template.Library()
|
||||||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||||
)
|
)
|
||||||
def pagination(context, page: Page):
|
def pagination(context, page: Page):
|
||||||
|
# remove page number and details from query parameters
|
||||||
|
query_params = context["request"].GET.copy()
|
||||||
|
query_params.pop("page", None)
|
||||||
|
query_params.pop("details", None)
|
||||||
|
|
||||||
|
prev_link = (
|
||||||
|
_generate_link(query_params, page.previous_page_number())
|
||||||
|
if page.has_previous()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
next_link = (
|
||||||
|
_generate_link(query_params, page.next_page_number())
|
||||||
|
if page.has_next()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
visible_page_numbers = get_visible_page_numbers(
|
visible_page_numbers = get_visible_page_numbers(
|
||||||
page.number, page.paginator.num_pages
|
page.number, page.paginator.num_pages
|
||||||
)
|
)
|
||||||
|
page_links = []
|
||||||
|
for page_number in visible_page_numbers:
|
||||||
|
if page_number == -1:
|
||||||
|
page_links.append(None)
|
||||||
|
else:
|
||||||
|
link = _generate_link(query_params, page_number)
|
||||||
|
page_links.append(
|
||||||
|
{
|
||||||
|
"active": page_number == page.number,
|
||||||
|
"number": page_number,
|
||||||
|
"link": link,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"page": page, "visible_page_numbers": visible_page_numbers}
|
return {
|
||||||
|
"prev_link": prev_link,
|
||||||
|
"next_link": next_link,
|
||||||
|
"page_links": page_links,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||||
|
@ -56,3 +90,8 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return reduce(append_page, visible_pages, [])
|
return reduce(append_page, visible_pages, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_link(query_params: QueryDict, page_number: int) -> str:
|
||||||
|
query_params["page"] = page_number
|
||||||
|
return query_params.urlencode()
|
||||||
|
|
|
@ -28,12 +28,13 @@ def add_tag_to_query(context, tag_name: str):
|
||||||
params = context.request.GET.copy()
|
params = context.request.GET.copy()
|
||||||
|
|
||||||
# Append to or create query string
|
# Append to or create query string
|
||||||
if params.__contains__("q"):
|
query_string = params.get("q", "")
|
||||||
query_string = params.__getitem__("q") + " "
|
query_string = (query_string + " #" + tag_name).strip()
|
||||||
else:
|
params.setlist("q", [query_string])
|
||||||
query_string = ""
|
|
||||||
query_string = query_string + "#" + tag_name
|
# Remove details ID and page number
|
||||||
params.__setitem__("q", query_string)
|
params.pop("details", None)
|
||||||
|
params.pop("page", None)
|
||||||
|
|
||||||
return params.urlencode()
|
return params.urlencode()
|
||||||
|
|
||||||
|
@ -62,6 +63,10 @@ def remove_tag_from_query(context, tag_name: str):
|
||||||
query_string = " ".join(query_parts)
|
query_string = " ".join(query_parts)
|
||||||
params.__setitem__("q", query_string)
|
params.__setitem__("q", query_string)
|
||||||
|
|
||||||
|
# Remove details ID and page number
|
||||||
|
params.pop("details", None)
|
||||||
|
params.pop("page", None)
|
||||||
|
|
||||||
return params.urlencode()
|
return params.urlencode()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import random
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
@ -220,6 +221,75 @@ class HtmlTestMixin:
|
||||||
return BeautifulSoup(html, features="html.parser")
|
return BeautifulSoup(html, features="html.parser")
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkListTestMixin(TestCase, HtmlTestMixin):
|
||||||
|
def assertVisibleBookmarks(
|
||||||
|
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
||||||
|
):
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
bookmark_list = soup.select_one(
|
||||||
|
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(bookmark_list)
|
||||||
|
|
||||||
|
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
||||||
|
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
bookmark_item = bookmark_list.select_one(
|
||||||
|
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(bookmark_item)
|
||||||
|
|
||||||
|
def assertInvisibleBookmarks(
|
||||||
|
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
||||||
|
):
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
bookmark_item = soup.select_one(
|
||||||
|
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||||
|
)
|
||||||
|
self.assertIsNone(bookmark_item)
|
||||||
|
|
||||||
|
|
||||||
|
class TagCloudTestMixin(TestCase, HtmlTestMixin):
|
||||||
|
def assertVisibleTags(self, response, tags: List[Tag]):
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
tag_cloud = soup.select_one("div.tag-cloud")
|
||||||
|
self.assertIsNotNone(tag_cloud)
|
||||||
|
|
||||||
|
tag_items = tag_cloud.select("a[data-is-tag-item]")
|
||||||
|
self.assertEqual(len(tag_items), len(tags))
|
||||||
|
|
||||||
|
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
self.assertTrue(tag.name in tag_item_names)
|
||||||
|
|
||||||
|
def assertInvisibleTags(self, response, tags: List[Tag]):
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
tag_items = soup.select("a[data-is-tag-item]")
|
||||||
|
|
||||||
|
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
self.assertFalse(tag.name in tag_item_names)
|
||||||
|
|
||||||
|
def assertSelectedTags(self, response, tags: List[Tag]):
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
selected_tags = soup.select_one("p.selected-tags")
|
||||||
|
self.assertIsNotNone(selected_tags)
|
||||||
|
|
||||||
|
tag_list = selected_tags.select("a")
|
||||||
|
self.assertEqual(len(tag_list), len(tags))
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
self.assertTrue(
|
||||||
|
tag.name in selected_tags.text,
|
||||||
|
msg=f"Selected tags do not contain: {tag.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LinkdingApiTestCase(APITestCase):
|
class LinkdingApiTestCase(APITestCase):
|
||||||
def get(self, url, expected_status_code=status.HTTP_200_OK):
|
def get(self, url, expected_status_code=status.HTTP_200_OK):
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
|
@ -1,13 +1,24 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.forms import model_to_dict
|
from django.forms import model_to_dict
|
||||||
from django.test import TestCase
|
from django.http import HttpResponse
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import Bookmark
|
from bookmarks.models import Bookmark, BookmarkAsset
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.services import tasks, bookmarks
|
||||||
|
from bookmarks.tests.helpers import (
|
||||||
|
BookmarkFactoryMixin,
|
||||||
|
BookmarkListTestMixin,
|
||||||
|
TagCloudTestMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
class BookmarkActionViewTestCase(
|
||||||
|
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
|
||||||
|
):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
|
@ -156,6 +167,129 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertTrue(bookmark.shared)
|
self.assertTrue(bookmark.shared)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_html_snapshot(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
with patch.object(tasks, "_create_html_snapshot_task"):
|
||||||
|
self.client.post(
|
||||||
|
reverse("bookmarks:index.action"),
|
||||||
|
{
|
||||||
|
"create_html_snapshot": [bookmark.id],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
|
||||||
|
asset = bookmark.bookmarkasset_set.first()
|
||||||
|
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_can_only_create_html_snapshot_for_own_bookmarks(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
with patch.object(tasks, "_create_html_snapshot_task"):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:index.action"),
|
||||||
|
{
|
||||||
|
"create_html_snapshot": [bookmark.id],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(bookmark.bookmarkasset_set.count(), 0)
|
||||||
|
|
||||||
|
def test_upload_asset(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(
|
||||||
|
reverse("bookmarks:index.action"),
|
||||||
|
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
mock_upload_asset.assert_called_once()
|
||||||
|
|
||||||
|
args, _ = mock_upload_asset.call_args
|
||||||
|
self.assertEqual(args[0], bookmark)
|
||||||
|
|
||||||
|
upload_file = args[1]
|
||||||
|
self.assertEqual(upload_file.name, "test.txt")
|
||||||
|
|
||||||
|
def test_can_only_upload_asset_for_own_bookmarks(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
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(
|
||||||
|
reverse("bookmarks:index.action"),
|
||||||
|
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
mock_upload_asset.assert_not_called()
|
||||||
|
|
||||||
|
def test_remove_asset(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
|
||||||
|
def test_can_only_remove_own_asset(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
|
||||||
|
def test_update_state(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:index.action"),
|
||||||
|
{
|
||||||
|
"update_state": bookmark.id,
|
||||||
|
"is_archived": "on",
|
||||||
|
"unread": "on",
|
||||||
|
"shared": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertTrue(bookmark.unread)
|
||||||
|
self.assertTrue(bookmark.is_archived)
|
||||||
|
self.assertTrue(bookmark.shared)
|
||||||
|
|
||||||
|
def test_can_only_update_own_bookmark_state(self):
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:index.action"),
|
||||||
|
{
|
||||||
|
"update_state": bookmark.id,
|
||||||
|
"is_archived": "on",
|
||||||
|
"unread": "on",
|
||||||
|
"shared": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertFalse(bookmark.unread)
|
||||||
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
self.assertFalse(bookmark.shared)
|
||||||
|
|
||||||
def test_bulk_archive(self):
|
def test_bulk_archive(self):
|
||||||
bookmark1 = self.setup_bookmark()
|
bookmark1 = self.setup_bookmark()
|
||||||
bookmark2 = self.setup_bookmark()
|
bookmark2 = self.setup_bookmark()
|
||||||
|
@ -791,58 +925,119 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||||
|
|
||||||
def test_should_redirect_to_return_url(self):
|
def test_index_action_redirects_to_index_with_query_params(self):
|
||||||
bookmark1 = self.setup_bookmark()
|
url = reverse("bookmarks:index.action") + "?q=foo&page=2"
|
||||||
bookmark2 = self.setup_bookmark()
|
redirect_url = reverse("bookmarks:index") + "?q=foo&page=2"
|
||||||
bookmark3 = self.setup_bookmark()
|
response = self.client.post(url)
|
||||||
|
|
||||||
url = (
|
self.assertRedirects(response, redirect_url)
|
||||||
reverse("bookmarks:index.action")
|
|
||||||
+ "?return_url="
|
def test_archived_action_redirects_to_archived_with_query_params(self):
|
||||||
+ reverse("bookmarks:settings.index")
|
url = reverse("bookmarks:archived.action") + "?q=foo&page=2"
|
||||||
|
redirect_url = reverse("bookmarks:archived") + "?q=foo&page=2"
|
||||||
|
response = self.client.post(url)
|
||||||
|
|
||||||
|
self.assertRedirects(response, redirect_url)
|
||||||
|
|
||||||
|
def test_shared_action_redirects_to_shared_with_query_params(self):
|
||||||
|
url = reverse("bookmarks:shared.action") + "?q=foo&page=2"
|
||||||
|
redirect_url = reverse("bookmarks:shared") + "?q=foo&page=2"
|
||||||
|
response = self.client.post(url)
|
||||||
|
|
||||||
|
self.assertRedirects(response, redirect_url)
|
||||||
|
|
||||||
|
def bookmark_update_fixture(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
profile = user.profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active": self.setup_numbered_bookmarks(3),
|
||||||
|
"archived": self.setup_numbered_bookmarks(3, archived=True),
|
||||||
|
"shared": self.setup_numbered_bookmarks(3, shared=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
def assertBookmarkUpdateResponse(self, response: HttpResponse):
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
html = response.content.decode("utf-8")
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
# bookmark list update
|
||||||
|
self.assertIsNotNone(
|
||||||
|
soup.select_one(
|
||||||
|
"turbo-stream[action='update'][target='bookmark-list-container']"
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# tag cloud update
|
||||||
|
self.assertIsNotNone(
|
||||||
|
soup.select_one(
|
||||||
|
"turbo-stream[action='update'][target='tag-cloud-container']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# update event
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<script>
|
||||||
|
document.dispatchEvent(new CustomEvent('bookmark-list-updated'));
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_index_action_with_turbo_returns_bookmark_update(self):
|
||||||
|
fixture = self.bookmark_update_fixture()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
url,
|
reverse("bookmarks:index.action"),
|
||||||
{
|
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||||
"bulk_action": ["bulk_archive"],
|
|
||||||
"bulk_execute": [""],
|
|
||||||
"bookmark_id": [
|
|
||||||
str(bookmark1.id),
|
|
||||||
str(bookmark2.id),
|
|
||||||
str(bookmark3.id),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("bookmarks:settings.index"))
|
visible_tags = self.get_tags_from_bookmarks(
|
||||||
|
fixture["active"] + fixture["shared"]
|
||||||
|
)
|
||||||
|
invisible_tags = self.get_tags_from_bookmarks(fixture["archived"])
|
||||||
|
|
||||||
def test_should_not_redirect_to_external_url(self):
|
self.assertBookmarkUpdateResponse(response)
|
||||||
bookmark1 = self.setup_bookmark()
|
self.assertVisibleBookmarks(response, fixture["active"] + fixture["shared"])
|
||||||
bookmark2 = self.setup_bookmark()
|
self.assertInvisibleBookmarks(response, fixture["archived"])
|
||||||
bookmark3 = self.setup_bookmark()
|
self.assertVisibleTags(response, visible_tags)
|
||||||
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
def post_with(return_url, follow=None):
|
def test_archived_action_with_turbo_returns_bookmark_update(self):
|
||||||
url = reverse("bookmarks:index.action") + f"?return_url={return_url}"
|
fixture = self.bookmark_update_fixture()
|
||||||
return self.client.post(
|
response = self.client.post(
|
||||||
url,
|
reverse("bookmarks:archived.action"),
|
||||||
{
|
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||||
"bulk_action": ["bulk_archive"],
|
|
||||||
"bulk_execute": [""],
|
|
||||||
"bookmark_id": [
|
|
||||||
str(bookmark1.id),
|
|
||||||
str(bookmark2.id),
|
|
||||||
str(bookmark3.id),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
follow=follow,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = post_with("https://example.com")
|
visible_tags = self.get_tags_from_bookmarks(fixture["archived"])
|
||||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
invisible_tags = self.get_tags_from_bookmarks(
|
||||||
response = post_with("//example.com")
|
fixture["active"] + fixture["shared"]
|
||||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
)
|
||||||
response = post_with("://example.com")
|
|
||||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
|
||||||
|
|
||||||
response = post_with("/foo//example.com", follow=True)
|
self.assertBookmarkUpdateResponse(response)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertVisibleBookmarks(response, fixture["archived"])
|
||||||
|
self.assertInvisibleBookmarks(response, fixture["active"] + fixture["shared"])
|
||||||
|
self.assertVisibleTags(response, visible_tags)
|
||||||
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
|
def test_shared_action_with_turbo_returns_bookmark_update(self):
|
||||||
|
fixture = self.bookmark_update_fixture()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:shared.action"),
|
||||||
|
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
visible_tags = self.get_tags_from_bookmarks(fixture["shared"])
|
||||||
|
invisible_tags = self.get_tags_from_bookmarks(
|
||||||
|
fixture["active"] + fixture["archived"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertBookmarkUpdateResponse(response)
|
||||||
|
self.assertVisibleBookmarks(response, fixture["shared"])
|
||||||
|
self.assertInvisibleBookmarks(response, fixture["active"] + fixture["archived"])
|
||||||
|
self.assertVisibleTags(response, visible_tags)
|
||||||
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
|
@ -1,89 +1,26 @@
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
from bookmarks.models import BookmarkSearch, UserProfile
|
||||||
from bookmarks.tests.helpers import (
|
from bookmarks.tests.helpers import (
|
||||||
BookmarkFactoryMixin,
|
BookmarkFactoryMixin,
|
||||||
HtmlTestMixin,
|
BookmarkListTestMixin,
|
||||||
|
TagCloudTestMixin,
|
||||||
collapse_whitespace,
|
collapse_whitespace,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class BookmarkArchivedViewTestCase(
|
||||||
|
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
|
||||||
|
):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
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 assertVisibleBookmarks(
|
|
||||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
|
||||||
):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
bookmark_list = soup.select_one(
|
|
||||||
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(bookmark_list)
|
|
||||||
|
|
||||||
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
|
||||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
|
||||||
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
bookmark_item = bookmark_list.select_one(
|
|
||||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(bookmark_item)
|
|
||||||
|
|
||||||
def assertInvisibleBookmarks(
|
|
||||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
|
||||||
):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
bookmark_item = soup.select_one(
|
|
||||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNone(bookmark_item)
|
|
||||||
|
|
||||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
tag_cloud = soup.select_one("div.tag-cloud")
|
|
||||||
self.assertIsNotNone(tag_cloud)
|
|
||||||
|
|
||||||
tag_items = tag_cloud.select("a[data-is-tag-item]")
|
|
||||||
self.assertEqual(len(tag_items), len(tags))
|
|
||||||
|
|
||||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertTrue(tag.name in tag_item_names)
|
|
||||||
|
|
||||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
tag_items = soup.select("a[data-is-tag-item]")
|
|
||||||
|
|
||||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertFalse(tag.name in tag_item_names)
|
|
||||||
|
|
||||||
def assertSelectedTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
selected_tags = soup.select_one("p.selected-tags")
|
|
||||||
self.assertIsNotNone(selected_tags)
|
|
||||||
|
|
||||||
tag_list = selected_tags.select("a")
|
|
||||||
self.assertEqual(len(tag_list), len(tags))
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertTrue(
|
|
||||||
tag.name in selected_tags.text,
|
|
||||||
msg=f"Selected tags do not contain: {tag.name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def assertEditLink(self, response, url):
|
def assertEditLink(self, response, url):
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
|
@ -307,24 +244,21 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
base_url = reverse("bookmarks:archived")
|
base_url = reverse("bookmarks:archived")
|
||||||
|
|
||||||
# without params
|
# without params
|
||||||
return_url = urllib.parse.quote_plus(base_url)
|
url = f"{action_url}"
|
||||||
url = f"{action_url}?return_url={return_url}"
|
|
||||||
|
|
||||||
response = self.client.get(base_url)
|
response = self.client.get(base_url)
|
||||||
self.assertBulkActionForm(response, url)
|
self.assertBulkActionForm(response, url)
|
||||||
|
|
||||||
# with query
|
# with query
|
||||||
url_params = "?q=foo"
|
url_params = "?q=foo"
|
||||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
url = f"{action_url}?q=foo"
|
||||||
url = f"{action_url}?q=foo&return_url={return_url}"
|
|
||||||
|
|
||||||
response = self.client.get(base_url + url_params)
|
response = self.client.get(base_url + url_params)
|
||||||
self.assertBulkActionForm(response, url)
|
self.assertBulkActionForm(response, url)
|
||||||
|
|
||||||
# with query and sort
|
# with query and sort
|
||||||
url_params = "?q=foo&sort=title_asc"
|
url_params = "?q=foo&sort=title_asc"
|
||||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
url = f"{action_url}?q=foo&sort=title_asc"
|
||||||
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
|
|
||||||
|
|
||||||
response = self.client.get(base_url + url_params)
|
response = self.client.get(base_url + url_params)
|
||||||
self.assertBulkActionForm(response, url)
|
self.assertBulkActionForm(response, url)
|
||||||
|
@ -527,7 +461,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
actions_form.attrs["action"],
|
actions_form.attrs["action"],
|
||||||
"/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo",
|
"/bookmarks/archived/action?q=%23foo",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_encode_search_params(self):
|
def test_encode_search_params(self):
|
||||||
|
@ -557,3 +491,15 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
|
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertNotContains(response, "alert('xss')")
|
self.assertNotContains(response, "alert('xss')")
|
||||||
|
|
||||||
|
def test_turbo_frame_details_modal_renders_details_modal_update(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
url = reverse("bookmarks:archived") + f"?bookmark_id={bookmark.id}"
|
||||||
|
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
|
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
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, timezone
|
from django.utils import formats, timezone
|
||||||
|
|
||||||
from bookmarks.models import BookmarkAsset, UserProfile
|
from bookmarks.models import BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import bookmarks, tasks
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,23 +14,23 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
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 get_view_name(self):
|
|
||||||
return "bookmarks:details_modal"
|
|
||||||
|
|
||||||
def get_base_url(self, bookmark):
|
|
||||||
return reverse(self.get_view_name(), args=[bookmark.id])
|
|
||||||
|
|
||||||
def get_details_form(self, soup, bookmark):
|
def get_details_form(self, soup, bookmark):
|
||||||
expected_url = reverse("bookmarks:details", args=[bookmark.id])
|
form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}"
|
||||||
return soup.find("form", {"action": expected_url})
|
return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"})
|
||||||
|
|
||||||
def get_details(self, bookmark, return_url=""):
|
def get_index_details_modal(self, bookmark):
|
||||||
url = self.get_base_url(bookmark)
|
url = reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||||
if return_url:
|
|
||||||
url += f"?return_url={return_url}"
|
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
soup = self.make_soup(response.content)
|
soup = self.make_soup(response.content)
|
||||||
return soup
|
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||||
|
return modal
|
||||||
|
|
||||||
|
def get_shared_details_modal(self, bookmark):
|
||||||
|
url = reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
response = self.client.get(url)
|
||||||
|
soup = self.make_soup(response.content)
|
||||||
|
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||||
|
return modal
|
||||||
|
|
||||||
def find_section(self, soup, section_name):
|
def find_section(self, soup, section_name):
|
||||||
dt = soup.find("dt", string=section_name)
|
dt = soup.find("dt", string=section_name)
|
||||||
|
@ -54,35 +51,68 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
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})
|
||||||
|
|
||||||
def details_route_access_test(self, view_name: str, shareable: bool):
|
def details_route_access_test(self):
|
||||||
# own bookmark
|
# own bookmark
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
response = self.client.get(
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# other user's bookmark
|
# other user's bookmark
|
||||||
other_user = self.setup_user()
|
other_user = self.setup_user()
|
||||||
bookmark = self.setup_bookmark(user=other_user)
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
response = self.client.get(
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
# non-existent bookmark
|
# non-existent bookmark - just returns without modal in response
|
||||||
response = self.client.get(reverse(view_name, args=[9999]))
|
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# guest user
|
# guest user
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
response = self.client.get(
|
||||||
self.assertEqual(response.status_code, 404 if shareable else 302)
|
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def details_route_sharing_access_test(self, view_name: str, shareable: bool):
|
def test_access(self):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# other user's bookmark
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# non-existent bookmark - just returns without modal in response
|
||||||
|
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_access_with_sharing(self):
|
||||||
# shared bookmark, sharing disabled
|
# shared bookmark, sharing disabled
|
||||||
other_user = self.setup_user()
|
other_user = self.setup_user()
|
||||||
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||||
|
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
# shared bookmark, sharing enabled
|
# shared bookmark, sharing enabled
|
||||||
|
@ -90,37 +120,31 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.enable_sharing = True
|
profile.enable_sharing = True
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
response = self.client.get(
|
||||||
self.assertEqual(response.status_code, 200 if shareable else 404)
|
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# shared bookmark, guest user, no public sharing
|
# shared bookmark, guest user, no public sharing
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
response = self.client.get(
|
||||||
self.assertEqual(response.status_code, 404 if shareable else 302)
|
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
# shared bookmark, guest user, public sharing
|
# shared bookmark, guest user, public sharing
|
||||||
profile.enable_public_sharing = True
|
profile.enable_public_sharing = True
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
response = self.client.get(
|
||||||
self.assertEqual(response.status_code, 200 if shareable else 302)
|
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||||
|
)
|
||||||
def test_access(self):
|
self.assertEqual(response.status_code, 200)
|
||||||
self.details_route_access_test(self.get_view_name(), True)
|
|
||||||
|
|
||||||
def test_access_with_sharing(self):
|
|
||||||
self.details_route_sharing_access_test(self.get_view_name(), True)
|
|
||||||
|
|
||||||
def test_assets_access(self):
|
|
||||||
self.details_route_access_test("bookmarks:details_assets", True)
|
|
||||||
|
|
||||||
def test_assets_access_with_sharing(self):
|
|
||||||
self.details_route_sharing_access_test("bookmarks:details_assets", True)
|
|
||||||
|
|
||||||
def test_displays_title(self):
|
def test_displays_title(self):
|
||||||
# with title
|
# with title
|
||||||
bookmark = self.setup_bookmark(title="Test title")
|
bookmark = self.setup_bookmark(title="Test title")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
title = soup.find("h2")
|
title = soup.find("h2")
|
||||||
self.assertIsNotNone(title)
|
self.assertIsNotNone(title)
|
||||||
|
@ -128,7 +152,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# with website title
|
# with website title
|
||||||
bookmark = self.setup_bookmark(title="", website_title="Website title")
|
bookmark = self.setup_bookmark(title="", website_title="Website title")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
title = soup.find("h2")
|
title = soup.find("h2")
|
||||||
self.assertIsNotNone(title)
|
self.assertIsNotNone(title)
|
||||||
|
@ -136,7 +160,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# with URL only
|
# with URL only
|
||||||
bookmark = self.setup_bookmark(title="", website_title="")
|
bookmark = self.setup_bookmark(title="", website_title="")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
title = soup.find("h2")
|
title = soup.find("h2")
|
||||||
self.assertIsNotNone(title)
|
self.assertIsNotNone(title)
|
||||||
|
@ -145,7 +169,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_website_link(self):
|
def test_website_link(self):
|
||||||
# basics
|
# basics
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.url)
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
self.assertIsNotNone(link)
|
self.assertIsNotNone(link)
|
||||||
self.assertEqual(link["href"], bookmark.url)
|
self.assertEqual(link["href"], bookmark.url)
|
||||||
|
@ -153,7 +177,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# favicons disabled
|
# favicons disabled
|
||||||
bookmark = self.setup_bookmark(favicon_file="example.png")
|
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.url)
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
image = link.select_one("img")
|
image = link.select_one("img")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
@ -164,14 +188,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
bookmark = self.setup_bookmark(favicon_file="")
|
bookmark = self.setup_bookmark(favicon_file="")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.url)
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
image = link.select_one("img")
|
image = link.select_one("img")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
|
||||||
# favicons enabled, favicon present
|
# favicons enabled, favicon present
|
||||||
bookmark = self.setup_bookmark(favicon_file="example.png")
|
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.url)
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
image = link.select_one("img")
|
image = link.select_one("img")
|
||||||
self.assertIsNotNone(image)
|
self.assertIsNotNone(image)
|
||||||
|
@ -180,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_reader_mode_link(self):
|
def test_reader_mode_link(self):
|
||||||
# no latest snapshot
|
# no latest snapshot
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 2)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# snapshot is not complete
|
# snapshot is not complete
|
||||||
|
@ -194,7 +218,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||||
status=BookmarkAsset.STATUS_FAILURE,
|
status=BookmarkAsset.STATUS_FAILURE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 2)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# not a snapshot
|
# not a snapshot
|
||||||
|
@ -203,7 +227,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
asset_type="upload",
|
asset_type="upload",
|
||||||
status=BookmarkAsset.STATUS_COMPLETE,
|
status=BookmarkAsset.STATUS_COMPLETE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 2)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# snapshot is complete
|
# snapshot is complete
|
||||||
|
@ -212,7 +236,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||||
status=BookmarkAsset.STATUS_COMPLETE,
|
status=BookmarkAsset.STATUS_COMPLETE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 3)
|
self.assertEqual(self.count_weblinks(soup), 3)
|
||||||
|
|
||||||
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
||||||
|
@ -221,7 +245,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
def test_internet_archive_link_with_snapshot_url(self):
|
def test_internet_archive_link_with_snapshot_url(self):
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
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)
|
||||||
|
@ -231,7 +255,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
image = link.select_one("svg")
|
image = link.select_one("svg")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
@ -244,7 +268,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
web_archive_snapshot_url="https://example.com/", favicon_file=""
|
web_archive_snapshot_url="https://example.com/", favicon_file=""
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
image = link.select_one("svg")
|
image = link.select_one("svg")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
@ -253,7 +277,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
image = link.select_one("svg")
|
image = link.select_one("svg")
|
||||||
self.assertIsNotNone(image)
|
self.assertIsNotNone(image)
|
||||||
|
@ -267,7 +291,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
"https://web.archive.org/web/20230811214511/https://example.com/"
|
"https://web.archive.org/web/20230811214511/https://example.com/"
|
||||||
)
|
)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
link = self.find_weblink(soup, fallback_web_archive_url)
|
link = self.find_weblink(soup, fallback_web_archive_url)
|
||||||
self.assertIsNotNone(link)
|
self.assertIsNotNone(link)
|
||||||
self.assertEqual(link["href"], fallback_web_archive_url)
|
self.assertEqual(link["href"], fallback_web_archive_url)
|
||||||
|
@ -281,7 +305,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
website_link = self.find_weblink(soup, bookmark.url)
|
website_link = self.find_weblink(soup, bookmark.url)
|
||||||
self.assertIsNotNone(website_link)
|
self.assertIsNotNone(website_link)
|
||||||
|
@ -297,7 +321,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
website_link = self.find_weblink(soup, bookmark.url)
|
website_link = self.find_weblink(soup, bookmark.url)
|
||||||
self.assertIsNotNone(website_link)
|
self.assertIsNotNone(website_link)
|
||||||
|
@ -312,13 +336,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_preview_image(self):
|
def test_preview_image(self):
|
||||||
# without image
|
# without image
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
image = soup.select_one("div.preview-image img")
|
image = soup.select_one("div.preview-image img")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
|
||||||
# with image
|
# with image
|
||||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
image = soup.select_one("div.preview-image img")
|
image = soup.select_one("div.preview-image img")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
@ -328,13 +352,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
image = soup.select_one("div.preview-image img")
|
image = soup.select_one("div.preview-image img")
|
||||||
self.assertIsNone(image)
|
self.assertIsNone(image)
|
||||||
|
|
||||||
# preview images enabled, image present
|
# preview images enabled, image present
|
||||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
image = soup.select_one("div.preview-image img")
|
image = soup.select_one("div.preview-image img")
|
||||||
self.assertIsNotNone(image)
|
self.assertIsNotNone(image)
|
||||||
self.assertEqual(image["src"], "/static/example.png")
|
self.assertEqual(image["src"], "/static/example.png")
|
||||||
|
@ -342,18 +366,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_status(self):
|
def test_status(self):
|
||||||
# renders form
|
# renders form
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
form = self.get_details_form(soup, bookmark)
|
form = self.get_details_form(soup, bookmark)
|
||||||
self.assertIsNotNone(form)
|
self.assertIsNotNone(form)
|
||||||
self.assertEqual(
|
|
||||||
form["action"], reverse("bookmarks:details", args=[bookmark.id])
|
|
||||||
)
|
|
||||||
self.assertEqual(form["method"], "post")
|
self.assertEqual(form["method"], "post")
|
||||||
|
|
||||||
# sharing disabled
|
# sharing disabled
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Status")
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
@ -369,7 +390,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Status")
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
@ -381,7 +402,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# unchecked
|
# unchecked
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Status")
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
@ -393,7 +414,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# checked
|
# checked
|
||||||
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Status")
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
@ -406,106 +427,29 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_status_visibility(self):
|
def test_status_visibility(self):
|
||||||
# own bookmark
|
# own bookmark
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section(soup, "Status")
|
||||||
self.assertIsNotNone(section)
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
# other user's bookmark
|
# other user's bookmark
|
||||||
other_user = self.setup_user(enable_sharing=True)
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section(soup, "Status")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# guest user
|
# guest user
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
other_user.profile.enable_public_sharing = True
|
||||||
|
other_user.profile.save()
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section(soup, "Status")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
def test_status_update(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
# update status
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
self.assertTrue(bookmark.is_archived)
|
|
||||||
self.assertTrue(bookmark.unread)
|
|
||||||
self.assertTrue(bookmark.shared)
|
|
||||||
|
|
||||||
# update individual status
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "", "unread": "on", "shared": ""},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
self.assertFalse(bookmark.is_archived)
|
|
||||||
self.assertTrue(bookmark.unread)
|
|
||||||
self.assertFalse(bookmark.shared)
|
|
||||||
|
|
||||||
def test_status_update_access(self):
|
|
||||||
# no sharing
|
|
||||||
other_user = self.setup_user()
|
|
||||||
bookmark = self.setup_bookmark(user=other_user)
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
# shared, sharing disabled
|
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
# shared, sharing enabled
|
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
|
||||||
profile = other_user.profile
|
|
||||||
profile.enable_sharing = True
|
|
||||||
profile.save()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
# shared, public sharing enabled
|
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
|
||||||
profile = other_user.profile
|
|
||||||
profile.enable_public_sharing = True
|
|
||||||
profile.save()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
# guest user
|
|
||||||
self.client.logout()
|
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark),
|
|
||||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
def test_date_added(self):
|
def test_date_added(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Date added")
|
section = self.get_section(soup, "Date added")
|
||||||
|
|
||||||
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||||
|
@ -515,7 +459,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_tags(self):
|
def test_tags(self):
|
||||||
# without tags
|
# without tags
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Tags")
|
section = self.find_section(soup, "Tags")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
@ -523,7 +467,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
# with tags
|
# with tags
|
||||||
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Tags")
|
section = self.get_section(soup, "Tags")
|
||||||
|
|
||||||
for tag in bookmark.tags.all():
|
for tag in bookmark.tags.all():
|
||||||
|
@ -535,14 +479,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_description(self):
|
def test_description(self):
|
||||||
# without description
|
# without description
|
||||||
bookmark = self.setup_bookmark(description="", website_description="")
|
bookmark = self.setup_bookmark(description="", website_description="")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Description")
|
section = self.find_section(soup, "Description")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with description
|
# with description
|
||||||
bookmark = self.setup_bookmark(description="Test description")
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.get_section(soup, "Description")
|
section = self.get_section(soup, "Description")
|
||||||
self.assertEqual(section.text.strip(), bookmark.description)
|
self.assertEqual(section.text.strip(), bookmark.description)
|
||||||
|
@ -551,7 +495,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
description="", website_description="Website description"
|
description="", website_description="Website description"
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.get_section(soup, "Description")
|
section = self.get_section(soup, "Description")
|
||||||
self.assertEqual(section.text.strip(), bookmark.website_description)
|
self.assertEqual(section.text.strip(), bookmark.website_description)
|
||||||
|
@ -559,14 +503,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_notes(self):
|
def test_notes(self):
|
||||||
# without notes
|
# without notes
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Notes")
|
section = self.find_section(soup, "Notes")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with notes
|
# with notes
|
||||||
bookmark = self.setup_bookmark(notes="Test notes")
|
bookmark = self.setup_bookmark(notes="Test notes")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.get_section(soup, "Notes")
|
section = self.get_section(soup, "Notes")
|
||||||
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||||
|
@ -575,52 +519,42 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
# with default return URL
|
# with default return URL
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
edit_link = soup.find("a", string="Edit")
|
edit_link = soup.find("a", string="Edit")
|
||||||
self.assertIsNotNone(edit_link)
|
self.assertIsNotNone(edit_link)
|
||||||
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
details_url = reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||||
expected_url = (
|
expected_url = "/bookmarks/1/edit?return_url=/bookmarks%3Fdetails%3D1"
|
||||||
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url
|
self.assertEqual(expected_url, edit_link["href"])
|
||||||
)
|
|
||||||
self.assertEqual(edit_link["href"], expected_url)
|
|
||||||
|
|
||||||
# with custom return URL
|
|
||||||
soup = self.get_details(bookmark, return_url="/custom")
|
|
||||||
edit_link = soup.find("a", string="Edit")
|
|
||||||
self.assertIsNotNone(edit_link)
|
|
||||||
expected_url = (
|
|
||||||
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
|
|
||||||
)
|
|
||||||
self.assertEqual(edit_link["href"], expected_url)
|
|
||||||
|
|
||||||
def test_delete_button(self):
|
def test_delete_button(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
# basics
|
modal = self.get_index_details_modal(bookmark)
|
||||||
soup = self.get_details(bookmark)
|
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
|
||||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
|
||||||
self.assertIsNotNone(delete_button)
|
self.assertIsNotNone(delete_button)
|
||||||
self.assertEqual(delete_button.text.strip(), "Delete...")
|
self.assertEqual("Delete...", delete_button.text.strip())
|
||||||
self.assertEqual(delete_button["value"], str(bookmark.id))
|
self.assertEqual(str(bookmark.id), delete_button["value"])
|
||||||
|
|
||||||
form = delete_button.find_parent("form")
|
form = delete_button.find_parent("form")
|
||||||
self.assertIsNotNone(form)
|
self.assertIsNotNone(form)
|
||||||
expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks"
|
expected_url = reverse("bookmarks:index.action")
|
||||||
self.assertEqual(form["action"], expected_url)
|
self.assertEqual(expected_url, form["action"])
|
||||||
|
|
||||||
# with custom return URL
|
|
||||||
soup = self.get_details(bookmark, return_url="/custom")
|
|
||||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
|
||||||
form = delete_button.find_parent("form")
|
|
||||||
expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
|
|
||||||
self.assertEqual(form["action"], expected_url)
|
|
||||||
|
|
||||||
def test_actions_visibility(self):
|
def test_actions_visibility(self):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNotNone(edit_link)
|
||||||
|
self.assertIsNotNone(delete_button)
|
||||||
|
|
||||||
# with sharing
|
# with sharing
|
||||||
other_user = self.setup_user(enable_sharing=True)
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
edit_link = soup.find("a", string="Edit")
|
edit_link = soup.find("a", string="Edit")
|
||||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
self.assertIsNone(edit_link)
|
self.assertIsNone(edit_link)
|
||||||
|
@ -632,7 +566,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
profile.save()
|
profile.save()
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
edit_link = soup.find("a", string="Edit")
|
edit_link = soup.find("a", string="Edit")
|
||||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
self.assertIsNone(edit_link)
|
self.assertIsNone(edit_link)
|
||||||
|
@ -642,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
edit_link = soup.find("a", string="Edit")
|
edit_link = soup.find("a", string="Edit")
|
||||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
self.assertIsNone(edit_link)
|
self.assertIsNone(edit_link)
|
||||||
|
@ -651,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_assets_visibility_no_snapshot_support(self):
|
def test_assets_visibility_no_snapshot_support(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Files")
|
section = self.find_section(soup, "Files")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
@ -659,7 +593,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
def test_assets_visibility_with_snapshot_support(self):
|
def test_assets_visibility_with_snapshot_support(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Files")
|
section = self.find_section(soup, "Files")
|
||||||
self.assertIsNotNone(section)
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
|
@ -668,7 +602,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
# no assets
|
# no assets
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Files")
|
section = self.get_section(soup, "Files")
|
||||||
asset_list = section.find("div", {"class": "assets"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
self.assertIsNone(asset_list)
|
self.assertIsNone(asset_list)
|
||||||
|
@ -677,7 +611,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
self.setup_asset(bookmark)
|
self.setup_asset(bookmark)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Files")
|
section = self.get_section(soup, "Files")
|
||||||
asset_list = section.find("div", {"class": "assets"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
self.assertIsNotNone(asset_list)
|
self.assertIsNotNone(asset_list)
|
||||||
|
@ -691,7 +625,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
self.setup_asset(bookmark),
|
self.setup_asset(bookmark),
|
||||||
]
|
]
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.get_section(soup, "Files")
|
section = self.get_section(soup, "Files")
|
||||||
asset_list = section.find("div", {"class": "assets"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
|
|
||||||
|
@ -717,7 +651,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
asset.file = ""
|
asset.file = ""
|
||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
asset_item = self.find_asset(soup, asset)
|
asset_item = self.find_asset(soup, asset)
|
||||||
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
||||||
view_link = asset_item.find("a", {"href": view_url})
|
view_link = asset_item.find("a", {"href": view_url})
|
||||||
|
@ -729,7 +663,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
|
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
|
||||||
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
|
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, pending_asset)
|
asset_item = self.find_asset(soup, pending_asset)
|
||||||
asset_text = asset_item.select_one(".asset-text span")
|
asset_text = asset_item.select_one(".asset-text span")
|
||||||
|
@ -746,7 +680,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
asset2 = self.setup_asset(bookmark, file_size=54639)
|
asset2 = self.setup_asset(bookmark, file_size=54639)
|
||||||
asset3 = self.setup_asset(bookmark, file_size=11492020)
|
asset3 = self.setup_asset(bookmark, file_size=11492020)
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset1)
|
asset_item = self.find_asset(soup, asset1)
|
||||||
asset_text = asset_item.select_one(".asset-text")
|
asset_text = asset_item.select_one(".asset-text")
|
||||||
|
@ -766,7 +700,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# with file
|
# with file
|
||||||
asset = self.setup_asset(bookmark)
|
asset = self.setup_asset(bookmark)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset)
|
asset_item = self.find_asset(soup, asset)
|
||||||
view_link = asset_item.find("a", string="View")
|
view_link = asset_item.find("a", string="View")
|
||||||
|
@ -779,7 +713,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
# without file
|
# without file
|
||||||
asset.file = ""
|
asset.file = ""
|
||||||
asset.save()
|
asset.save()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset)
|
asset_item = self.find_asset(soup, asset)
|
||||||
view_link = asset_item.find("a", string="View")
|
view_link = asset_item.find("a", string="View")
|
||||||
|
@ -793,7 +727,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||||
asset = self.setup_asset(bookmark)
|
asset = self.setup_asset(bookmark)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset)
|
asset_item = self.find_asset(soup, asset)
|
||||||
view_link = asset_item.find("a", string="View")
|
view_link = asset_item.find("a", string="View")
|
||||||
|
@ -805,7 +739,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
# shared bookmark, guest user
|
# shared bookmark, guest user
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset)
|
asset_item = self.find_asset(soup, asset)
|
||||||
view_link = asset_item.find("a", string="View")
|
view_link = asset_item.find("a", string="View")
|
||||||
|
@ -815,77 +749,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
self.assertIsNotNone(view_link)
|
self.assertIsNotNone(view_link)
|
||||||
self.assertIsNone(delete_button)
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
def test_remove_asset(self):
|
|
||||||
# remove asset
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
asset = self.setup_asset(bookmark)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark), {"remove_asset": asset.id}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
|
||||||
|
|
||||||
# non-existent asset
|
|
||||||
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
# post without asset ID does not remove
|
|
||||||
asset = self.setup_asset(bookmark)
|
|
||||||
response = self.client.post(self.get_base_url(bookmark))
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
|
||||||
|
|
||||||
# guest user
|
|
||||||
asset = self.setup_asset(bookmark)
|
|
||||||
self.client.logout()
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark), {"remove_asset": asset.id}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
|
||||||
|
|
||||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
|
||||||
def test_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)
|
|
||||||
def test_create_snapshot(self):
|
|
||||||
with patch.object(
|
|
||||||
tasks, "_create_html_snapshot_task"
|
|
||||||
) as mock_create_html_snapshot_task:
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
response = self.client.post(
|
|
||||||
self.get_base_url(bookmark), {"create_snapshot": ""}
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
|
|
||||||
|
|
||||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
|
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
|
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
|
||||||
|
|
||||||
# no pending asset
|
# no pending asset
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
files_section = self.find_section(soup, "Files")
|
files_section = self.find_section(soup, "Files")
|
||||||
create_button = files_section.find(
|
create_button = files_section.find(
|
||||||
"button", string=re.compile("Create HTML snapshot")
|
"button", string=re.compile("Create HTML snapshot")
|
||||||
|
@ -896,40 +766,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
asset.status = BookmarkAsset.STATUS_PENDING
|
asset.status = BookmarkAsset.STATUS_PENDING
|
||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
files_section = self.find_section(soup, "Files")
|
files_section = self.find_section(soup, "Files")
|
||||||
create_button = files_section.find(
|
create_button = files_section.find(
|
||||||
"button", string=re.compile("Create HTML snapshot")
|
"button", string=re.compile("Create HTML snapshot")
|
||||||
)
|
)
|
||||||
self.assertTrue(create_button.has_attr("disabled"))
|
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()
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
|
|
||||||
def get_view_name(self):
|
|
||||||
return "bookmarks:details"
|
|
|
@ -1,85 +1,24 @@
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
from bookmarks.models import BookmarkSearch, UserProfile
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
from bookmarks.tests.helpers import (
|
||||||
|
BookmarkFactoryMixin,
|
||||||
|
BookmarkListTestMixin,
|
||||||
|
TagCloudTestMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class BookmarkIndexViewTestCase(
|
||||||
|
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
|
||||||
|
):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
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 assertVisibleBookmarks(
|
|
||||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
|
||||||
):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
bookmark_list = soup.select_one(
|
|
||||||
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(bookmark_list)
|
|
||||||
|
|
||||||
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
|
||||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
|
||||||
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
bookmark_item = bookmark_list.select_one(
|
|
||||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(bookmark_item)
|
|
||||||
|
|
||||||
def assertInvisibleBookmarks(
|
|
||||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
|
||||||
):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
bookmark_item = soup.select_one(
|
|
||||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNone(bookmark_item)
|
|
||||||
|
|
||||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
tag_cloud = soup.select_one("div.tag-cloud")
|
|
||||||
self.assertIsNotNone(tag_cloud)
|
|
||||||
|
|
||||||
tag_items = tag_cloud.select("a[data-is-tag-item]")
|
|
||||||
self.assertEqual(len(tag_items), len(tags))
|
|
||||||
|
|
||||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertTrue(tag.name in tag_item_names)
|
|
||||||
|
|
||||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
tag_items = soup.select("a[data-is-tag-item]")
|
|
||||||
|
|
||||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertFalse(tag.name in tag_item_names)
|
|
||||||
|
|
||||||
def assertSelectedTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
selected_tags = soup.select_one("p.selected-tags")
|
|
||||||
self.assertIsNotNone(selected_tags)
|
|
||||||
|
|
||||||
tag_list = selected_tags.select("a")
|
|
||||||
self.assertEqual(len(tag_list), len(tags))
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertTrue(
|
|
||||||
tag.name in selected_tags.text,
|
|
||||||
msg=f"Selected tags do not contain: {tag.name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def assertEditLink(self, response, url):
|
def assertEditLink(self, response, url):
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
|
@ -285,24 +224,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
base_url = reverse("bookmarks:index")
|
base_url = reverse("bookmarks:index")
|
||||||
|
|
||||||
# without params
|
# without params
|
||||||
return_url = urllib.parse.quote_plus(base_url)
|
url = f"{action_url}"
|
||||||
url = f"{action_url}?return_url={return_url}"
|
|
||||||
|
|
||||||
response = self.client.get(base_url)
|
response = self.client.get(base_url)
|
||||||
self.assertBulkActionForm(response, url)
|
self.assertBulkActionForm(response, url)
|
||||||
|
|
||||||
# with query
|
# with query
|
||||||
url_params = "?q=foo"
|
url_params = "?q=foo"
|
||||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
url = f"{action_url}?q=foo"
|
||||||
url = f"{action_url}?q=foo&return_url={return_url}"
|
|
||||||
|
|
||||||
response = self.client.get(base_url + url_params)
|
response = self.client.get(base_url + url_params)
|
||||||
self.assertBulkActionForm(response, url)
|
self.assertBulkActionForm(response, url)
|
||||||
|
|
||||||
# with query and sort
|
# with query and sort
|
||||||
url_params = "?q=foo&sort=title_asc"
|
url_params = "?q=foo&sort=title_asc"
|
||||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
url = f"{action_url}?q=foo&sort=title_asc"
|
||||||
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
|
|
||||||
|
|
||||||
response = self.client.get(base_url + url_params)
|
response = self.client.get(base_url + url_params)
|
||||||
self.assertBulkActionForm(response, url)
|
self.assertBulkActionForm(response, url)
|
||||||
|
@ -503,7 +439,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
actions_form.attrs["action"],
|
actions_form.attrs["action"],
|
||||||
"/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo",
|
"/bookmarks/action?q=%23foo",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_encode_search_params(self):
|
def test_encode_search_params(self):
|
||||||
|
@ -533,3 +469,15 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
|
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertNotContains(response, "alert('xss')")
|
self.assertNotContains(response, "alert('xss')")
|
||||||
|
|
||||||
|
def test_turbo_frame_details_modal_renders_details_modal_update(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
url = reverse("bookmarks:index") + f"?bookmark_id={bookmark.id}"
|
||||||
|
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
|
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.template import Template, RequestContext
|
from django.template import Template, RequestContext
|
||||||
from django.test import TestCase, RequestFactory
|
from django.test import TestCase, RequestFactory
|
||||||
|
|
||||||
from bookmarks.models import BookmarkSearch, Tag
|
from bookmarks.models import BookmarkSearch
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
def render_template(
|
def render_template(self, url: str, mode: str = ""):
|
||||||
self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ""
|
|
||||||
):
|
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
request.user = self.get_or_create_test_user()
|
request.user = self.get_or_create_test_user()
|
||||||
|
@ -21,32 +18,31 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"search": search,
|
"search": search,
|
||||||
"tags": tags,
|
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
template_to_render = Template(
|
template_to_render = Template(
|
||||||
"{% load bookmarks %}" "{% bookmark_search search tags mode %}"
|
"{% load bookmarks %} {% bookmark_search search mode %}"
|
||||||
)
|
)
|
||||||
return template_to_render.render(context)
|
return template_to_render.render(context)
|
||||||
|
|
||||||
def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):
|
def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):
|
||||||
input = form.select_one(f'input[name="{name}"][type="hidden"]')
|
element = form.select_one(f'input[name="{name}"][type="hidden"]')
|
||||||
self.assertIsNotNone(input)
|
self.assertIsNotNone(element)
|
||||||
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
self.assertEqual(input["value"], value)
|
self.assertEqual(element["value"], value)
|
||||||
|
|
||||||
def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
|
def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
|
||||||
input = form.select_one(f'input[name="{name}"][type="hidden"]')
|
element = form.select_one(f'input[name="{name}"][type="hidden"]')
|
||||||
self.assertIsNone(input)
|
self.assertIsNone(element)
|
||||||
|
|
||||||
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
|
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
|
||||||
input = form.select_one(f'input[name="{name}"][type="search"]')
|
element = form.select_one(f'input[name="{name}"][type="search"]')
|
||||||
self.assertIsNotNone(input)
|
self.assertIsNotNone(element)
|
||||||
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
self.assertEqual(input["value"], value)
|
self.assertEqual(element["value"], value)
|
||||||
|
|
||||||
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
|
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
|
||||||
select = form.select_one(f'select[name="{name}"]')
|
select = form.select_one(f'select[name="{name}"]')
|
||||||
|
|
|
@ -6,11 +6,16 @@ from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
from bookmarks.tests.helpers import (
|
||||||
|
BookmarkFactoryMixin,
|
||||||
|
BookmarkListTestMixin,
|
||||||
|
TagCloudTestMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class BookmarkSharedViewTestCase(
|
||||||
|
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
|
||||||
|
):
|
||||||
def authenticate(self) -> None:
|
def authenticate(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
@ -24,57 +29,6 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
count=count,
|
count=count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertVisibleBookmarks(
|
|
||||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
|
||||||
):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
bookmark_list = soup.select_one(
|
|
||||||
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(bookmark_list)
|
|
||||||
|
|
||||||
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
|
||||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
|
||||||
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
bookmark_item = bookmark_list.select_one(
|
|
||||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(bookmark_item)
|
|
||||||
|
|
||||||
def assertInvisibleBookmarks(
|
|
||||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
|
||||||
):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
bookmark_item = soup.select_one(
|
|
||||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
|
||||||
)
|
|
||||||
self.assertIsNone(bookmark_item)
|
|
||||||
|
|
||||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
tag_cloud = soup.select_one("div.tag-cloud")
|
|
||||||
self.assertIsNotNone(tag_cloud)
|
|
||||||
|
|
||||||
tag_items = tag_cloud.select("a[data-is-tag-item]")
|
|
||||||
self.assertEqual(len(tag_items), len(tags))
|
|
||||||
|
|
||||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertTrue(tag.name in tag_item_names)
|
|
||||||
|
|
||||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
|
||||||
soup = self.make_soup(response.content.decode())
|
|
||||||
tag_items = soup.select("a[data-is-tag-item]")
|
|
||||||
|
|
||||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
self.assertFalse(tag.name in tag_item_names)
|
|
||||||
|
|
||||||
def assertVisibleUserOptions(self, response, users: List[User]):
|
def assertVisibleUserOptions(self, response, users: List[User]):
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
|
@ -84,7 +38,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
f'<option value="{user.username}">{user.username}</option>'
|
f'<option value="{user.username}">{user.username}</option>'
|
||||||
)
|
)
|
||||||
user_select_html = f"""
|
user_select_html = f"""
|
||||||
<select name="user" class="form-select" required="" id="id_user">
|
<select name="user" class="form-select" id="id_user" ld-auto-submit>
|
||||||
{''.join(user_options)}
|
{''.join(user_options)}
|
||||||
</select>
|
</select>
|
||||||
"""
|
"""
|
||||||
|
@ -593,7 +547,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
actions_form.attrs["action"],
|
actions_form.attrs["action"],
|
||||||
"/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo",
|
"/bookmarks/shared/action?q=%23foo",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_encode_search_params(self):
|
def test_encode_search_params(self):
|
||||||
|
@ -627,3 +581,15 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
|
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertNotContains(response, "alert('xss')")
|
self.assertNotContains(response, "alert('xss')")
|
||||||
|
|
||||||
|
def test_turbo_frame_details_modal_renders_details_modal_update(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
url = reverse("bookmarks:shared") + f"?bookmark_id={bookmark.id}"
|
||||||
|
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
|
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||||
|
|
|
@ -12,7 +12,7 @@ from django.utils import timezone, formats
|
||||||
from bookmarks.middlewares import LinkdingMiddleware
|
from bookmarks.middlewares import LinkdingMiddleware
|
||||||
from bookmarks.models import Bookmark, UserProfile, User
|
from bookmarks.models import Bookmark, UserProfile, User
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
from bookmarks.views.partials import contexts
|
from bookmarks.views import contexts
|
||||||
|
|
||||||
|
|
||||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
@ -51,31 +51,25 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertViewLink(
|
def assertViewLink(self, html: str, bookmark: Bookmark, base_url=None):
|
||||||
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
self.assertViewLinkCount(html, bookmark, base_url)
|
||||||
):
|
|
||||||
self.assertViewLinkCount(html, bookmark, return_url=return_url)
|
|
||||||
|
|
||||||
def assertNoViewLink(
|
def assertNoViewLink(self, html: str, bookmark: Bookmark, base_url=None):
|
||||||
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
self.assertViewLinkCount(html, bookmark, base_url, count=0)
|
||||||
):
|
|
||||||
self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
|
|
||||||
|
|
||||||
def assertViewLinkCount(
|
def assertViewLinkCount(
|
||||||
self,
|
self,
|
||||||
html: str,
|
html: str,
|
||||||
bookmark: Bookmark,
|
bookmark: Bookmark,
|
||||||
|
base_url: str = None,
|
||||||
count=1,
|
count=1,
|
||||||
return_url=reverse("bookmarks:index"),
|
|
||||||
):
|
):
|
||||||
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
if base_url is None:
|
||||||
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
|
base_url = reverse("bookmarks:index")
|
||||||
|
details_url = base_url + f"?details={bookmark.id}"
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<a ld-fetch="{details_modal_url}?return_url={return_url}"
|
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||||
ld-on="click" ld-target="body|append"
|
|
||||||
data-turbo-prefetch="false"
|
|
||||||
href="{details_url}">View</a>
|
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
count=count,
|
count=count,
|
||||||
|
@ -652,7 +646,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||||
|
|
||||||
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
|
||||||
self.assertNoBookmarkActions(html, bookmark)
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
self.assertShareInfo(html, bookmark)
|
self.assertShareInfo(html, bookmark)
|
||||||
|
|
||||||
|
@ -944,7 +938,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
self.assertWebArchiveLink(
|
self.assertWebArchiveLink(
|
||||||
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
||||||
)
|
)
|
||||||
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
|
||||||
self.assertNoBookmarkActions(html, bookmark)
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
self.assertShareInfo(html, bookmark)
|
self.assertShareInfo(html, bookmark)
|
||||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||||
|
|
|
@ -172,3 +172,12 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||||
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
|
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
|
||||||
)
|
)
|
||||||
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
|
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
|
||||||
|
|
||||||
|
def test_removes_details_parameter(self):
|
||||||
|
rendered_template = self.render_template(
|
||||||
|
100, 10, 2, url="/test?details=1&page=2"
|
||||||
|
)
|
||||||
|
self.assertPrevLink(rendered_template, 1, href="?page=1")
|
||||||
|
self.assertPageLink(rendered_template, 1, False, href="?page=1")
|
||||||
|
self.assertPageLink(rendered_template, 2, True, href="?page=2")
|
||||||
|
self.assertNextLink(rendered_template, 3, href="?page=3")
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.test import TestCase, RequestFactory
|
||||||
from bookmarks.middlewares import LinkdingMiddleware
|
from bookmarks.middlewares import LinkdingMiddleware
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import UserProfile
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
from bookmarks.views.partials import contexts
|
from bookmarks.views import contexts
|
||||||
|
|
||||||
|
|
||||||
class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
@ -203,13 +203,28 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
tag = self.setup_tag(name="tag1")
|
tag = self.setup_tag(name="tag1")
|
||||||
self.setup_bookmark(tags=[tag], title="term1")
|
self.setup_bookmark(tags=[tag], title="term1")
|
||||||
|
|
||||||
|
rendered_template = self.render_template(url="/test?q=term1&sort=title_asc")
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<a href="?q=term1+%23tag1&sort=title_asc" class="mr-2" data-is-tag-item>
|
||||||
|
<span class="highlight-char">t</span><span>ag1</span>
|
||||||
|
</a>
|
||||||
|
""",
|
||||||
|
rendered_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tag_url_removes_page_number_and_details_id(self):
|
||||||
|
tag = self.setup_tag(name="tag1")
|
||||||
|
self.setup_bookmark(tags=[tag], title="term1")
|
||||||
|
|
||||||
rendered_template = self.render_template(
|
rendered_template = self.render_template(
|
||||||
url="/test?q=term1&sort=title_asc&page=2"
|
url="/test?q=term1&sort=title_asc&page=2&details=5"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<a href="?q=term1+%23tag1&sort=title_asc&page=2" class="mr-2" data-is-tag-item>
|
<a href="?q=term1+%23tag1&sort=title_asc" class="mr-2" data-is-tag-item>
|
||||||
<span class="highlight-char">t</span><span>ag1</span>
|
<span class="highlight-char">t</span><span>ag1</span>
|
||||||
</a>
|
</a>
|
||||||
""",
|
""",
|
||||||
|
@ -347,12 +362,30 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
self.setup_bookmark(tags=[tag], title="term1", description="term2")
|
self.setup_bookmark(tags=[tag], title="term1", description="term2")
|
||||||
|
|
||||||
rendered_template = self.render_template(
|
rendered_template = self.render_template(
|
||||||
url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2"
|
url="/test?q=term1 %23tag1 term2&sort=title_asc"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<a href="?q=term1+term2&sort=title_asc&page=2"
|
<a href="?q=term1+term2&sort=title_asc"
|
||||||
|
class="text-bold mr-2">
|
||||||
|
<span>-tag1</span>
|
||||||
|
</a>
|
||||||
|
""",
|
||||||
|
rendered_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selected_tag_url_removes_page_number_and_details_id(self):
|
||||||
|
tag = self.setup_tag(name="tag1")
|
||||||
|
self.setup_bookmark(tags=[tag], title="term1", description="term2")
|
||||||
|
|
||||||
|
rendered_template = self.render_template(
|
||||||
|
url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2&details=5"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<a href="?q=term1+term2&sort=title_asc"
|
||||||
class="text-bold mr-2">
|
class="text-bold mr-2">
|
||||||
<span>-tag1</span>
|
<span>-tag1</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -9,7 +9,6 @@ from bookmarks.feeds import (
|
||||||
SharedBookmarksFeed,
|
SharedBookmarksFeed,
|
||||||
PublicSharedBookmarksFeed,
|
PublicSharedBookmarksFeed,
|
||||||
)
|
)
|
||||||
from bookmarks.views import partials
|
|
||||||
|
|
||||||
app_name = "bookmarks"
|
app_name = "bookmarks"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -31,21 +30,6 @@ urlpatterns = [
|
||||||
path("bookmarks/new", views.bookmarks.new, name="new"),
|
path("bookmarks/new", views.bookmarks.new, name="new"),
|
||||||
path("bookmarks/close", views.bookmarks.close, name="close"),
|
path("bookmarks/close", views.bookmarks.close, name="close"),
|
||||||
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
|
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
|
||||||
path(
|
|
||||||
"bookmarks/<int:bookmark_id>/details",
|
|
||||||
views.bookmarks.details,
|
|
||||||
name="details",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/<int:bookmark_id>/details_modal",
|
|
||||||
views.bookmarks.details_modal,
|
|
||||||
name="details_modal",
|
|
||||||
),
|
|
||||||
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>",
|
||||||
|
@ -57,52 +41,6 @@ urlpatterns = [
|
||||||
views.assets.read,
|
views.assets.read,
|
||||||
name="assets.read",
|
name="assets.read",
|
||||||
),
|
),
|
||||||
# Partials
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/bookmark-list/active",
|
|
||||||
partials.active_bookmark_list,
|
|
||||||
name="partials.bookmark_list.active",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/tag-cloud/active",
|
|
||||||
partials.active_tag_cloud,
|
|
||||||
name="partials.tag_cloud.active",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/tag-modal/active",
|
|
||||||
partials.active_tag_modal,
|
|
||||||
name="partials.tag_modal.active",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/bookmark-list/archived",
|
|
||||||
partials.archived_bookmark_list,
|
|
||||||
name="partials.bookmark_list.archived",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/tag-cloud/archived",
|
|
||||||
partials.archived_tag_cloud,
|
|
||||||
name="partials.tag_cloud.archived",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/tag-modal/archived",
|
|
||||||
partials.archived_tag_modal,
|
|
||||||
name="partials.tag_modal.archived",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/bookmark-list/shared",
|
|
||||||
partials.shared_bookmark_list,
|
|
||||||
name="partials.bookmark_list.shared",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/tag-cloud/shared",
|
|
||||||
partials.shared_tag_cloud,
|
|
||||||
name="partials.tag_cloud.shared",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"bookmarks/partials/tag-modal/shared",
|
|
||||||
partials.shared_tag_modal,
|
|
||||||
name="partials.tag_modal.shared",
|
|
||||||
),
|
|
||||||
# Settings
|
# Settings
|
||||||
path("settings", views.settings.general, name="settings.index"),
|
path("settings", views.settings.general, name="settings.index"),
|
||||||
path("settings/general", views.settings.general, name="settings.general"),
|
path("settings/general", views.settings.general, name="settings.general"),
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import urllib.parse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
from django.template.defaultfilters import pluralize
|
from django.template.defaultfilters import pluralize
|
||||||
from django.utils import timezone, formats
|
from django.utils import timezone, formats
|
||||||
|
|
||||||
|
@ -114,6 +116,14 @@ def get_safe_return_url(return_url: str, fallback_url: str):
|
||||||
return return_url
|
return return_url
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_with_query(request, redirect_url):
|
||||||
|
query_string = urllib.parse.urlencode(request.GET)
|
||||||
|
if query_string:
|
||||||
|
redirect_url += "?" + query_string
|
||||||
|
|
||||||
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
def generate_username(email):
|
def generate_username(email):
|
||||||
# taken from mozilla-django-oidc docs :)
|
# taken from mozilla-django-oidc docs :)
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ from django.http import (
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries, utils
|
||||||
from bookmarks.models import (
|
from bookmarks.models import (
|
||||||
Bookmark,
|
Bookmark,
|
||||||
BookmarkAsset,
|
BookmarkAsset,
|
||||||
|
@ -19,6 +19,7 @@ from bookmarks.models import (
|
||||||
BookmarkSearch,
|
BookmarkSearch,
|
||||||
build_tag_string,
|
build_tag_string,
|
||||||
)
|
)
|
||||||
|
from bookmarks.services import bookmarks as bookmark_actions, tasks
|
||||||
from bookmarks.services.bookmarks import (
|
from bookmarks.services.bookmarks import (
|
||||||
create_bookmark,
|
create_bookmark,
|
||||||
update_bookmark,
|
update_bookmark,
|
||||||
|
@ -34,9 +35,8 @@ from bookmarks.services.bookmarks import (
|
||||||
share_bookmarks,
|
share_bookmarks,
|
||||||
unshare_bookmarks,
|
unshare_bookmarks,
|
||||||
)
|
)
|
||||||
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 import contexts, partials, turbo
|
||||||
|
|
||||||
_default_page_size = 30
|
_default_page_size = 30
|
||||||
|
|
||||||
|
@ -48,12 +48,17 @@ def index(request):
|
||||||
|
|
||||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||||
return render(
|
bookmark_details = contexts.get_details_context(
|
||||||
|
request, contexts.ActiveBookmarkDetailsContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_bookmarks_view(
|
||||||
request,
|
request,
|
||||||
"bookmarks/index.html",
|
"bookmarks/index.html",
|
||||||
{
|
{
|
||||||
"bookmark_list": bookmark_list,
|
"bookmark_list": bookmark_list,
|
||||||
"tag_cloud": tag_cloud,
|
"tag_cloud": tag_cloud,
|
||||||
|
"details": bookmark_details,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -65,12 +70,17 @@ def archived(request):
|
||||||
|
|
||||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||||
return render(
|
bookmark_details = contexts.get_details_context(
|
||||||
|
request, contexts.ArchivedBookmarkDetailsContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_bookmarks_view(
|
||||||
request,
|
request,
|
||||||
"bookmarks/archive.html",
|
"bookmarks/archive.html",
|
||||||
{
|
{
|
||||||
"bookmark_list": bookmark_list,
|
"bookmark_list": bookmark_list,
|
||||||
"tag_cloud": tag_cloud,
|
"tag_cloud": tag_cloud,
|
||||||
|
"details": bookmark_details,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -81,14 +91,37 @@ def shared(request):
|
||||||
|
|
||||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||||
|
bookmark_details = contexts.get_details_context(
|
||||||
|
request, contexts.SharedBookmarkDetailsContext
|
||||||
|
)
|
||||||
public_only = not request.user.is_authenticated
|
public_only = not request.user.is_authenticated
|
||||||
users = queries.query_shared_bookmark_users(
|
users = queries.query_shared_bookmark_users(
|
||||||
request.user_profile, bookmark_list.search, public_only
|
request.user_profile, bookmark_list.search, public_only
|
||||||
)
|
)
|
||||||
return render(
|
return render_bookmarks_view(
|
||||||
request,
|
request,
|
||||||
"bookmarks/shared.html",
|
"bookmarks/shared.html",
|
||||||
{"bookmark_list": bookmark_list, "tag_cloud": tag_cloud, "users": users},
|
{
|
||||||
|
"bookmark_list": bookmark_list,
|
||||||
|
"tag_cloud": tag_cloud,
|
||||||
|
"details": bookmark_details,
|
||||||
|
"users": users,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_bookmarks_view(request, template_name, context):
|
||||||
|
if turbo.is_frame(request, "details-modal"):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"bookmarks/updates/details-modal-frame.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
template_name,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -111,76 +144,6 @@ def search_action(request):
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
|
|
||||||
|
|
||||||
def _details(request, bookmark_id: int, template: str):
|
|
||||||
try:
|
|
||||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
|
||||||
except Bookmark.DoesNotExist:
|
|
||||||
raise Http404("Bookmark does not exist")
|
|
||||||
|
|
||||||
is_owner = bookmark.owner == request.user
|
|
||||||
is_shared = (
|
|
||||||
request.user.is_authenticated
|
|
||||||
and bookmark.shared
|
|
||||||
and bookmark.owner.profile.enable_sharing
|
|
||||||
)
|
|
||||||
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
|
|
||||||
if not is_owner and not is_shared and not is_public_shared:
|
|
||||||
raise Http404("Bookmark does not exist")
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
if not is_owner:
|
|
||||||
raise Http404("Bookmark does not exist")
|
|
||||||
|
|
||||||
return_url = get_safe_return_url(
|
|
||||||
request.GET.get("return_url"),
|
|
||||||
reverse("bookmarks:details", args=[bookmark.id]),
|
|
||||||
)
|
|
||||||
|
|
||||||
if "remove_asset" in request.POST:
|
|
||||||
asset_id = request.POST["remove_asset"]
|
|
||||||
try:
|
|
||||||
asset = bookmark.bookmarkasset_set.get(pk=asset_id)
|
|
||||||
except BookmarkAsset.DoesNotExist:
|
|
||||||
raise Http404("Asset does not exist")
|
|
||||||
asset.delete()
|
|
||||||
if "create_snapshot" in request.POST:
|
|
||||||
tasks.create_html_snapshot(bookmark)
|
|
||||||
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:
|
|
||||||
bookmark.is_archived = request.POST.get("is_archived") == "on"
|
|
||||||
bookmark.unread = request.POST.get("unread") == "on"
|
|
||||||
bookmark.shared = request.POST.get("shared") == "on"
|
|
||||||
bookmark.save()
|
|
||||||
|
|
||||||
return HttpResponseRedirect(return_url)
|
|
||||||
|
|
||||||
details_context = contexts.BookmarkDetailsContext(request, bookmark)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
template,
|
|
||||||
{
|
|
||||||
"details": details_context,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def details(request, bookmark_id: int):
|
|
||||||
return _details(request, bookmark_id, "bookmarks/details.html")
|
|
||||||
|
|
||||||
|
|
||||||
def details_modal(request, bookmark_id: int):
|
|
||||||
return _details(request, bookmark_id, "bookmarks/details_modal.html")
|
|
||||||
|
|
||||||
|
|
||||||
def 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
|
||||||
|
@ -307,26 +270,87 @@ def mark_as_read(request, bookmark_id: int):
|
||||||
bookmark.save()
|
bookmark.save()
|
||||||
|
|
||||||
|
|
||||||
|
def create_html_snapshot(request, bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset(request, bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
file = request.FILES.get("upload_asset_file")
|
||||||
|
if not file:
|
||||||
|
raise ValueError("No file uploaded")
|
||||||
|
|
||||||
|
bookmark_actions.upload_asset(bookmark, file)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_asset(request, asset_id: int):
|
||||||
|
try:
|
||||||
|
asset = BookmarkAsset.objects.get(pk=asset_id, bookmark__owner=request.user)
|
||||||
|
except BookmarkAsset.DoesNotExist:
|
||||||
|
raise Http404("Asset does not exist")
|
||||||
|
|
||||||
|
asset.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def update_state(request, bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
bookmark.is_archived = request.POST.get("is_archived") == "on"
|
||||||
|
bookmark.unread = request.POST.get("unread") == "on"
|
||||||
|
bookmark.shared = request.POST.get("shared") == "on"
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index_action(request):
|
def index_action(request):
|
||||||
search = BookmarkSearch.from_request(request.GET)
|
search = BookmarkSearch.from_request(request.GET)
|
||||||
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
||||||
return action(request, query)
|
handle_action(request, query)
|
||||||
|
|
||||||
|
if turbo.accept(request):
|
||||||
|
return partials.active_bookmark_update(request)
|
||||||
|
|
||||||
|
return utils.redirect_with_query(request, reverse("bookmarks:index"))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def archived_action(request):
|
def archived_action(request):
|
||||||
search = BookmarkSearch.from_request(request.GET)
|
search = BookmarkSearch.from_request(request.GET)
|
||||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||||
return action(request, query)
|
handle_action(request, query)
|
||||||
|
|
||||||
|
if turbo.accept(request):
|
||||||
|
return partials.archived_bookmark_update(request)
|
||||||
|
|
||||||
|
return utils.redirect_with_query(request, reverse("bookmarks:archived"))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def shared_action(request):
|
def shared_action(request):
|
||||||
return action(request)
|
if "bulk_execute" in request.POST:
|
||||||
|
return HttpResponseBadRequest("View does not support bulk actions")
|
||||||
|
|
||||||
|
handle_action(request)
|
||||||
|
|
||||||
|
if turbo.accept(request):
|
||||||
|
return partials.shared_bookmark_update(request)
|
||||||
|
|
||||||
|
return utils.redirect_with_query(request, reverse("bookmarks:shared"))
|
||||||
|
|
||||||
|
|
||||||
def action(request, query: QuerySet[Bookmark] = None):
|
def handle_action(request, query: QuerySet[Bookmark] = None):
|
||||||
# Single bookmark actions
|
# Single bookmark actions
|
||||||
if "archive" in request.POST:
|
if "archive" in request.POST:
|
||||||
archive(request, request.POST["archive"])
|
archive(request, request.POST["archive"])
|
||||||
|
@ -338,11 +362,21 @@ def action(request, query: QuerySet[Bookmark] = None):
|
||||||
mark_as_read(request, request.POST["mark_as_read"])
|
mark_as_read(request, request.POST["mark_as_read"])
|
||||||
if "unshare" in request.POST:
|
if "unshare" in request.POST:
|
||||||
unshare(request, request.POST["unshare"])
|
unshare(request, request.POST["unshare"])
|
||||||
|
if "create_html_snapshot" in request.POST:
|
||||||
|
create_html_snapshot(request, request.POST["create_html_snapshot"])
|
||||||
|
if "upload_asset" in request.POST:
|
||||||
|
upload_asset(request, request.POST["upload_asset"])
|
||||||
|
if "remove_asset" in request.POST:
|
||||||
|
remove_asset(request, request.POST["remove_asset"])
|
||||||
|
|
||||||
|
# State updates
|
||||||
|
if "update_state" in request.POST:
|
||||||
|
update_state(request, request.POST["update_state"])
|
||||||
|
|
||||||
# Bulk actions
|
# Bulk actions
|
||||||
if "bulk_execute" in request.POST:
|
if "bulk_execute" in request.POST:
|
||||||
if query is None:
|
if query is None:
|
||||||
return HttpResponseBadRequest("View does not support bulk actions")
|
raise ValueError("Query must be provided for bulk actions")
|
||||||
|
|
||||||
bulk_action = request.POST["bulk_action"]
|
bulk_action = request.POST["bulk_action"]
|
||||||
|
|
||||||
|
@ -375,11 +409,6 @@ def action(request, query: QuerySet[Bookmark] = None):
|
||||||
if "bulk_unshare" == bulk_action:
|
if "bulk_unshare" == bulk_action:
|
||||||
unshare_bookmarks(bookmark_ids, request.user)
|
unshare_bookmarks(bookmark_ids, request.user)
|
||||||
|
|
||||||
return_url = get_safe_return_url(
|
|
||||||
request.GET.get("return_url"), reverse("bookmarks:index")
|
|
||||||
)
|
|
||||||
return HttpResponseRedirect(return_url)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def close(request):
|
def close(request):
|
||||||
|
|
|
@ -6,6 +6,7 @@ from django.conf import settings
|
||||||
from django.core.handlers.wsgi import WSGIRequest
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.http import Http404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
|
@ -27,17 +28,11 @@ CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
||||||
class RequestContext:
|
class RequestContext:
|
||||||
index_view = "bookmarks:index"
|
index_view = "bookmarks:index"
|
||||||
action_view = "bookmarks:index.action"
|
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):
|
def __init__(self, request: WSGIRequest):
|
||||||
self.request = request
|
self.request = request
|
||||||
self.index_url = reverse(self.index_view)
|
self.index_url = reverse(self.index_view)
|
||||||
self.action_url = reverse(self.action_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 = request.GET.copy()
|
||||||
self.query_params.pop("details", None)
|
self.query_params.pop("details", None)
|
||||||
|
|
||||||
|
@ -51,34 +46,25 @@ class RequestContext:
|
||||||
encoded_params = query_params.urlencode()
|
encoded_params = query_params.urlencode()
|
||||||
return view_url + "?" + encoded_params if encoded_params else view_url
|
return view_url + "?" + encoded_params if encoded_params else view_url
|
||||||
|
|
||||||
def index(self) -> str:
|
def index(self, add: dict = None, remove: dict = None) -> str:
|
||||||
return self.get_url(self.index_url)
|
return self.get_url(self.index_url, add=add, remove=remove)
|
||||||
|
|
||||||
def action(self, return_url: str) -> str:
|
def action(self, add: dict = None, remove: dict = None) -> str:
|
||||||
return self.get_url(self.action_url, add={"return_url": return_url})
|
return self.get_url(self.action_url, add=add, remove=remove)
|
||||||
|
|
||||||
def bookmark_list_partial(self) -> str:
|
def details(self, bookmark_id: int) -> str:
|
||||||
return self.get_url(self.bookmark_list_partial_url)
|
return self.get_url(self.index_url, add={"details": bookmark_id})
|
||||||
|
|
||||||
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):
|
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||||
raise Exception("Must be implemented by subclass")
|
raise NotImplementedError("Must be implemented by subclass")
|
||||||
|
|
||||||
def get_tag_query_set(self, search: BookmarkSearch):
|
def get_tag_query_set(self, search: BookmarkSearch):
|
||||||
raise Exception("Must be implemented by subclass")
|
raise NotImplementedError("Must be implemented by subclass")
|
||||||
|
|
||||||
|
|
||||||
class ActiveBookmarksContext(RequestContext):
|
class ActiveBookmarksContext(RequestContext):
|
||||||
index_view = "bookmarks:index"
|
index_view = "bookmarks:index"
|
||||||
action_view = "bookmarks:index.action"
|
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):
|
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||||
return queries.query_bookmarks(
|
return queries.query_bookmarks(
|
||||||
|
@ -94,9 +80,6 @@ class ActiveBookmarksContext(RequestContext):
|
||||||
class ArchivedBookmarksContext(RequestContext):
|
class ArchivedBookmarksContext(RequestContext):
|
||||||
index_view = "bookmarks:archived"
|
index_view = "bookmarks:archived"
|
||||||
action_view = "bookmarks:archived.action"
|
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):
|
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||||
return queries.query_archived_bookmarks(
|
return queries.query_archived_bookmarks(
|
||||||
|
@ -112,9 +95,6 @@ class ArchivedBookmarksContext(RequestContext):
|
||||||
class SharedBookmarksContext(RequestContext):
|
class SharedBookmarksContext(RequestContext):
|
||||||
index_view = "bookmarks:shared"
|
index_view = "bookmarks:shared"
|
||||||
action_view = "bookmarks:shared.action"
|
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):
|
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||||
user = User.objects.filter(username=search.user).first()
|
user = User.objects.filter(username=search.user).first()
|
||||||
|
@ -132,7 +112,13 @@ class SharedBookmarksContext(RequestContext):
|
||||||
|
|
||||||
|
|
||||||
class BookmarkItem:
|
class BookmarkItem:
|
||||||
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
bookmark: Bookmark,
|
||||||
|
user: User,
|
||||||
|
profile: UserProfile,
|
||||||
|
) -> None:
|
||||||
self.bookmark = bookmark
|
self.bookmark = bookmark
|
||||||
|
|
||||||
is_editable = bookmark.owner == user
|
is_editable = bookmark.owner == user
|
||||||
|
@ -154,6 +140,7 @@ class BookmarkItem:
|
||||||
self.is_archived = bookmark.is_archived
|
self.is_archived = bookmark.is_archived
|
||||||
self.unread = bookmark.unread
|
self.unread = bookmark.unread
|
||||||
self.owner = bookmark.owner
|
self.owner = bookmark.owner
|
||||||
|
self.details_url = context.details(bookmark.id)
|
||||||
|
|
||||||
css_classes = []
|
css_classes = []
|
||||||
if bookmark.unread:
|
if bookmark.unread:
|
||||||
|
@ -200,16 +187,15 @@ class BookmarkListContext:
|
||||||
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
|
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
|
||||||
|
|
||||||
self.items = [
|
self.items = [
|
||||||
BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page
|
BookmarkItem(request_context, 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 = request_context.index()
|
self.return_url = request_context.index()
|
||||||
self.action_url = request_context.action(return_url=self.return_url)
|
self.action_url = request_context.action()
|
||||||
self.refresh_url = request_context.bookmark_list_partial()
|
|
||||||
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
|
||||||
|
@ -344,8 +330,6 @@ 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
|
||||||
|
|
||||||
self.refresh_url = request_context.tag_cloud_partial()
|
|
||||||
|
|
||||||
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)
|
||||||
tag_names = parsed_query["tag_names"]
|
tag_names = parsed_query["tag_names"]
|
||||||
|
@ -396,17 +380,18 @@ class BookmarkAssetItem:
|
||||||
|
|
||||||
|
|
||||||
class BookmarkDetailsContext:
|
class BookmarkDetailsContext:
|
||||||
|
request_context = RequestContext
|
||||||
|
|
||||||
def __init__(self, request: WSGIRequest, bookmark: Bookmark):
|
def __init__(self, request: WSGIRequest, bookmark: Bookmark):
|
||||||
|
request_context = self.request_context(request)
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
user_profile = request.user_profile
|
user_profile = request.user_profile
|
||||||
|
|
||||||
self.edit_return_url = utils.get_safe_return_url(
|
self.edit_return_url = request_context.details(bookmark.id)
|
||||||
request.GET.get("return_url"),
|
self.action_url = request_context.action(add={"details": bookmark.id})
|
||||||
reverse("bookmarks:details", args=[bookmark.id]),
|
self.delete_url = request_context.action()
|
||||||
)
|
self.close_url = request_context.index()
|
||||||
self.delete_return_url = utils.get_safe_return_url(
|
|
||||||
request.GET.get("return_url"), reverse("bookmarks:index")
|
|
||||||
)
|
|
||||||
|
|
||||||
self.bookmark = bookmark
|
self.bookmark = bookmark
|
||||||
self.profile = request.user_profile
|
self.profile = request.user_profile
|
||||||
|
@ -438,3 +423,44 @@ class BookmarkDetailsContext:
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveBookmarkDetailsContext(BookmarkDetailsContext):
|
||||||
|
request_context = ActiveBookmarksContext
|
||||||
|
|
||||||
|
|
||||||
|
class ArchivedBookmarkDetailsContext(BookmarkDetailsContext):
|
||||||
|
request_context = ArchivedBookmarksContext
|
||||||
|
|
||||||
|
|
||||||
|
class SharedBookmarkDetailsContext(BookmarkDetailsContext):
|
||||||
|
request_context = SharedBookmarksContext
|
||||||
|
|
||||||
|
|
||||||
|
def get_details_context(
|
||||||
|
request: WSGIRequest, context_type
|
||||||
|
) -> BookmarkDetailsContext | None:
|
||||||
|
bookmark_id = request.GET.get("details")
|
||||||
|
if not bookmark_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=int(bookmark_id))
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
# just ignore, might end up in a situation where the bookmark was deleted
|
||||||
|
# in between navigating back and forth
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_owner = bookmark.owner == request.user
|
||||||
|
is_shared = (
|
||||||
|
request.user.is_authenticated
|
||||||
|
and bookmark.shared
|
||||||
|
and bookmark.owner.profile.enable_sharing
|
||||||
|
)
|
||||||
|
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
|
||||||
|
if not is_owner and not is_shared and not is_public_shared:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
if request.method == "POST" and not is_owner:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
return context_type(request, bookmark)
|
40
bookmarks/views/partials.py
Normal file
40
bookmarks/views/partials.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from bookmarks.views import contexts, turbo
|
||||||
|
|
||||||
|
|
||||||
|
def render_bookmark_update(request, bookmark_list, tag_cloud, details):
|
||||||
|
return turbo.stream(
|
||||||
|
request,
|
||||||
|
"bookmarks/updates/bookmark_view_stream.html",
|
||||||
|
{
|
||||||
|
"bookmark_list": bookmark_list,
|
||||||
|
"tag_cloud": tag_cloud,
|
||||||
|
"details": details,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def active_bookmark_update(request):
|
||||||
|
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||||
|
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||||
|
details = contexts.get_details_context(
|
||||||
|
request, contexts.ActiveBookmarkDetailsContext
|
||||||
|
)
|
||||||
|
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
|
||||||
|
|
||||||
|
|
||||||
|
def archived_bookmark_update(request):
|
||||||
|
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||||
|
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||||
|
details = contexts.get_details_context(
|
||||||
|
request, contexts.ArchivedBookmarkDetailsContext
|
||||||
|
)
|
||||||
|
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
|
||||||
|
|
||||||
|
|
||||||
|
def shared_bookmark_update(request):
|
||||||
|
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||||
|
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||||
|
details = contexts.get_details_context(
|
||||||
|
request, contexts.SharedBookmarkDetailsContext
|
||||||
|
)
|
||||||
|
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
|
|
@ -1,76 +0,0 @@
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
from bookmarks.views.partials import contexts
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def active_bookmark_list(request):
|
|
||||||
bookmark_list_context = contexts.ActiveBookmarkListContext(request)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"bookmarks/bookmark_list.html",
|
|
||||||
{"bookmark_list": bookmark_list_context},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def active_tag_cloud(request):
|
|
||||||
tag_cloud_context = contexts.ActiveTagCloudContext(request)
|
|
||||||
|
|
||||||
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
|
|
||||||
def archived_bookmark_list(request):
|
|
||||||
bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"bookmarks/bookmark_list.html",
|
|
||||||
{"bookmark_list": bookmark_list_context},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def archived_tag_cloud(request):
|
|
||||||
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
|
|
||||||
|
|
||||||
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
|
|
||||||
|
|
||||||
|
|
||||||
@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):
|
|
||||||
bookmark_list_context = contexts.SharedBookmarkListContext(request)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"bookmarks/bookmark_list.html",
|
|
||||||
{"bookmark_list": bookmark_list_context},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def shared_tag_cloud(request):
|
|
||||||
tag_cloud_context = contexts.SharedTagCloudContext(request)
|
|
||||||
|
|
||||||
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
|
|
||||||
|
|
||||||
|
|
||||||
def shared_tag_modal(request):
|
|
||||||
tag_cloud_context = contexts.SharedTagCloudContext(request)
|
|
||||||
|
|
||||||
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
|
|
19
bookmarks/views/turbo.py
Normal file
19
bookmarks/views/turbo.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import render as django_render
|
||||||
|
|
||||||
|
|
||||||
|
def accept(request: HttpRequest):
|
||||||
|
is_turbo_request = "text/vnd.turbo-stream.html" in request.headers.get("Accept", "")
|
||||||
|
disable_turbo = request.POST.get("disable_turbo", "false") == "true"
|
||||||
|
|
||||||
|
return is_turbo_request and not disable_turbo
|
||||||
|
|
||||||
|
|
||||||
|
def is_frame(request: HttpRequest, frame: str) -> bool:
|
||||||
|
return request.headers.get("Turbo-Frame") == frame
|
||||||
|
|
||||||
|
|
||||||
|
def stream(request: HttpRequest, template_name: str, context: dict) -> HttpResponse:
|
||||||
|
response = django_render(request, template_name, context)
|
||||||
|
response["Content-Type"] = "text/vnd.turbo-stream.html"
|
||||||
|
return response
|
|
@ -10,8 +10,8 @@ from .base import *
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
# Enable debug toolbar
|
# Enable debug toolbar
|
||||||
INSTALLED_APPS.append("debug_toolbar")
|
# INSTALLED_APPS.append("debug_toolbar")
|
||||||
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
|
# MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||||
|
|
||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|
Loading…
Reference in a new issue