mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-21 19:03:02 +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():
|
||||
details_modal.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
# Cancel edit, verify return to details 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()
|
||||
|
||||
def test_delete(self):
|
||||
|
@ -167,7 +168,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
|||
# Has new snapshots
|
||||
expect(snapshot).to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
# Remove snapshot
|
||||
asset_list.get_by_text("Remove", 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 playwright.sync_api import sync_playwright, expect, Locator
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
|
@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
|||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
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
|
||||
modal.locator("button.close").click()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export class ApiClient {
|
||||
export class Api {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
@ -27,3 +27,6 @@ export class ApiClient {
|
|||
.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);
|
||||
|
||||
// Toggle notes
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||
this.notesToggle = element.querySelector(".toggle-notes");
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
|
@ -13,12 +13,13 @@ class BulkEdit extends Behavior {
|
|||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.init();
|
||||
// Reset when bookmarks are refreshed
|
||||
document.addEventListener("refresh-bookmark-list-done", this.init);
|
||||
// Reset when bookmarks are updated
|
||||
document.addEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("refresh-bookmark-list-done", this.init);
|
||||
this.removeListeners();
|
||||
document.removeEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
init() {
|
||||
|
@ -36,13 +37,9 @@ class BulkEdit extends Behavior {
|
|||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
// Remove previous listeners if elements are the same
|
||||
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);
|
||||
});
|
||||
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
||||
this.removeListeners();
|
||||
this.addListeners();
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
@ -52,8 +49,9 @@ class BulkEdit extends Behavior {
|
|||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
// Add new listeners
|
||||
addListeners() {
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
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() {
|
||||
this.active = !this.active;
|
||||
if (this.active) {
|
||||
|
|
|
@ -3,20 +3,14 @@ import { Behavior, registerBehavior } from "./index";
|
|||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
element.dataset.type = element.type;
|
||||
element.dataset.name = element.name;
|
||||
element.dataset.value = element.value;
|
||||
element.removeAttribute("type");
|
||||
element.removeAttribute("name");
|
||||
element.removeAttribute("value");
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.reset();
|
||||
this.element.setAttribute("type", this.element.dataset.type);
|
||||
this.element.setAttribute("name", this.element.dataset.name);
|
||||
this.element.setAttribute("value", this.element.dataset.value);
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
|
@ -56,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
|
|||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.dataset.type;
|
||||
confirmButton.name = this.element.dataset.name;
|
||||
confirmButton.value = this.element.dataset.value;
|
||||
confirmButton.type = this.element.type;
|
||||
confirmButton.name = this.element.name;
|
||||
confirmButton.value = this.element.value;
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = buttonClasses;
|
||||
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) {
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
|
||||
const toggle = element.querySelector(".dropdown-toggle");
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
});
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
open() {
|
||||
|
@ -30,6 +26,14 @@ class DropdownBehavior extends Behavior {
|
|||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
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";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("change", () => {
|
||||
const form = element.closest("form");
|
||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
});
|
||||
this.submit = this.submit.bind(this);
|
||||
element.addEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.element.closest("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(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", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
element.addEventListener("click", this.onClick);
|
||||
this.fileInput.addEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const form = fileInput.closest("form");
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
event.submitter = element;
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
this.fileInput.removeEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
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-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 TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import { ApiClient } from "../api";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warning("TagAutocomplete: input element not found");
|
||||
console.warn("TagAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
const apiClient = new ApiClient(apiBaseUrl);
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: container,
|
||||
|
@ -22,7 +19,6 @@ class TagAutocomplete extends Behavior {
|
|||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
apiClient: apiClient,
|
||||
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>
|
||||
import {SearchHistory} from "./SearchHistory";
|
||||
import {api} from "../api";
|
||||
import {cache} from "../cache";
|
||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
const searchHistory = new SearchHistory()
|
||||
|
@ -7,9 +9,7 @@
|
|||
export let name;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let tags;
|
||||
export let mode = '';
|
||||
export let apiClient;
|
||||
export let search;
|
||||
export let linkTarget = '_blank';
|
||||
|
||||
|
@ -88,17 +88,18 @@
|
|||
}
|
||||
|
||||
// Tag suggestions
|
||||
const tags = await cache.getTags();
|
||||
let tagSuggestions = []
|
||||
const currentWord = getCurrentWord(input)
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||
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)
|
||||
.map(tagName => ({
|
||||
.map(tag => ({
|
||||
type: 'tag',
|
||||
index: nextIndex(),
|
||||
label: `#${tagName}`,
|
||||
tagName: tagName
|
||||
label: `#${tag.name}`,
|
||||
tagName: tag.name
|
||||
}))
|
||||
}
|
||||
|
||||
|
@ -119,7 +120,7 @@
|
|||
...search,
|
||||
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 => {
|
||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
<script>
|
||||
import {cache} from "../cache";
|
||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let apiClient;
|
||||
export let variant = 'default';
|
||||
|
||||
let tags = [];
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
|
@ -17,18 +16,6 @@
|
|||
let suggestions = [];
|
||||
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() {
|
||||
isFocus = true;
|
||||
}
|
||||
|
@ -38,9 +25,10 @@
|
|||
close();
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
async function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word
|
||||
|
|
|
@ -3,11 +3,14 @@ import "./behaviors/bookmark-page";
|
|||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/fetch";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.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()
|
||||
user = forms.ChoiceField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
/* Common styles */
|
||||
.bookmark-details {
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -134,17 +127,3 @@
|
|||
flex-direction: column;
|
||||
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%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
flex: 1 1 0;
|
||||
align-items: flex-start;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
@ -11,24 +11,20 @@
|
|||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<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' %}
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
<form class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
<div id="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
|
@ -39,10 +35,16 @@
|
|||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -78,10 +78,7 @@
|
|||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
data-turbo-prefetch="false"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# 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 %}
|
||||
ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
|
||||
ld-interval="5" ld-target="self|outerHTML"
|
||||
{% endif %}>
|
||||
<div>
|
||||
{% if details.assets %}
|
||||
<div class="assets">
|
||||
{% for asset in details.assets %}
|
||||
|
@ -36,10 +33,10 @@
|
|||
|
||||
{% if details.is_editable %}
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
{% load static %}
|
||||
{% load shared %}
|
||||
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
|
||||
action="{% url 'bookmarks:details' details.bookmark.id %}"
|
||||
method="post">
|
||||
<form action="{{ details.action_url }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
|
||||
|
||||
<div class="weblinks">
|
||||
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
|
@ -47,7 +48,6 @@
|
|||
<div class="status col-2">
|
||||
<dt>Status</dt>
|
||||
<dd class="d-flex" style="gap: .8rem">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-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">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags %}
|
||||
{% bookmark_search bookmark_list.search %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
<form class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
|
||||
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
<div id="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
|
@ -39,10 +34,16 @@
|
|||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -3,44 +3,7 @@
|
|||
<!DOCTYPE html>
|
||||
{# Use data attributes as storage for access in static scripts #}
|
||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||
<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>
|
||||
{% include 'bookmarks/head.html' %}
|
||||
<body ld-global-shortcuts>
|
||||
|
||||
<div class="d-none">
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{% load shared %}
|
||||
|
||||
<ul class="pagination">
|
||||
{% if page.has_previous %}
|
||||
{% if prev_link %}
|
||||
<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>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
|
@ -11,10 +11,10 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_number in visible_page_numbers %}
|
||||
{% if page_number >= 0 %}
|
||||
<li class="page-item {% if page.number == page_number %}active{% endif %}">
|
||||
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
|
||||
{% for page_link in page_links %}
|
||||
{% if page_link %}
|
||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
|
@ -23,9 +23,9 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page.has_next %}
|
||||
{% if next_link %}
|
||||
<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>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
{% load widget_tweaks %}
|
||||
|
||||
<div class="search-container">
|
||||
<div ld-search-autocomplete class="search-container">
|
||||
<form id="search" action="" method="get" role="search">
|
||||
<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">
|
||||
{% for hidden_field in search_form.hidden_fields %}
|
||||
{{ hidden_field }}
|
||||
|
@ -73,42 +78,3 @@
|
|||
</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">
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
<form class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
<div id="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
|
@ -43,10 +39,16 @@
|
|||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="form-group">
|
||||
<div class="d-flex">
|
||||
{{ form.user|add_class:"form-select" }}
|
||||
{% render_field form.user class+="form-select" ld-auto-submit="" %}
|
||||
<noscript>
|
||||
<button type="submit" class="btn btn-link ml-2">Apply</button>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</ul>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
@ -43,7 +43,7 @@
|
|||
<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.
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
|
|
@ -6,8 +6,6 @@ from bookmarks.models import (
|
|||
BookmarkForm,
|
||||
BookmarkSearch,
|
||||
BookmarkSearchForm,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
User,
|
||||
)
|
||||
|
||||
|
@ -34,9 +32,7 @@ def bookmark_form(
|
|||
@register.inclusion_tag(
|
||||
"bookmarks/search.html", name="bookmark_search", takes_context=True
|
||||
)
|
||||
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""):
|
||||
tag_names = [tag.name for tag in tags]
|
||||
tags_string = build_tag_string(tag_names, " ")
|
||||
def bookmark_search(context, search: BookmarkSearch, mode: str = ""):
|
||||
search_form = BookmarkSearchForm(search, editable_fields=["q"])
|
||||
|
||||
if mode == "shared":
|
||||
|
@ -50,7 +46,6 @@ def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""
|
|||
"search": search,
|
||||
"search_form": search_form,
|
||||
"preferences_form": preferences_form,
|
||||
"tags_string": tags_string,
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ from functools import reduce
|
|||
|
||||
from django import template
|
||||
from django.core.paginator import Page
|
||||
from django.http import QueryDict
|
||||
|
||||
NUM_ADJACENT_PAGES = 2
|
||||
|
||||
|
@ -12,11 +13,44 @@ register = template.Library()
|
|||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||
)
|
||||
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(
|
||||
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]:
|
||||
|
@ -56,3 +90,8 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
|||
return result
|
||||
|
||||
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()
|
||||
|
||||
# Append to or create query string
|
||||
if params.__contains__("q"):
|
||||
query_string = params.__getitem__("q") + " "
|
||||
else:
|
||||
query_string = ""
|
||||
query_string = query_string + "#" + tag_name
|
||||
params.__setitem__("q", query_string)
|
||||
query_string = params.get("q", "")
|
||||
query_string = (query_string + " #" + tag_name).strip()
|
||||
params.setlist("q", [query_string])
|
||||
|
||||
# Remove details ID and page number
|
||||
params.pop("details", None)
|
||||
params.pop("page", None)
|
||||
|
||||
return params.urlencode()
|
||||
|
||||
|
@ -62,6 +63,10 @@ def remove_tag_from_query(context, tag_name: str):
|
|||
query_string = " ".join(query_parts)
|
||||
params.__setitem__("q", query_string)
|
||||
|
||||
# Remove details ID and page number
|
||||
params.pop("details", None)
|
||||
params.pop("page", None)
|
||||
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import random
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from unittest import TestCase
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import User
|
||||
|
@ -220,6 +221,75 @@ class HtmlTestMixin:
|
|||
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):
|
||||
def get(self, url, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.get(url)
|
||||
|
|
|
@ -1,13 +1,24 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
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 bookmarks.models import Bookmark
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.models import Bookmark, BookmarkAsset
|
||||
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:
|
||||
user = self.get_or_create_test_user()
|
||||
|
@ -156,6 +167,129 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||
self.assertEqual(response.status_code, 404)
|
||||
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):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
|
@ -791,58 +925,119 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
def test_should_redirect_to_return_url(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
def test_index_action_redirects_to_index_with_query_params(self):
|
||||
url = reverse("bookmarks:index.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("bookmarks:index") + "?q=foo&page=2"
|
||||
response = self.client.post(url)
|
||||
|
||||
url = (
|
||||
reverse("bookmarks:index.action")
|
||||
+ "?return_url="
|
||||
+ reverse("bookmarks:settings.index")
|
||||
self.assertRedirects(response, redirect_url)
|
||||
|
||||
def test_archived_action_redirects_to_archived_with_query_params(self):
|
||||
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(
|
||||
url,
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
reverse("bookmarks:index.action"),
|
||||
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||
)
|
||||
|
||||
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):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
self.assertBookmarkUpdateResponse(response)
|
||||
self.assertVisibleBookmarks(response, fixture["active"] + fixture["shared"])
|
||||
self.assertInvisibleBookmarks(response, fixture["archived"])
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
url = reverse("bookmarks:index.action") + f"?return_url={return_url}"
|
||||
return self.client.post(
|
||||
url,
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
follow=follow,
|
||||
def test_archived_action_with_turbo_returns_bookmark_update(self):
|
||||
fixture = self.bookmark_update_fixture()
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||
)
|
||||
|
||||
response = post_with("https://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
response = post_with("//example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
response = post_with("://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
visible_tags = self.get_tags_from_bookmarks(fixture["archived"])
|
||||
invisible_tags = self.get_tags_from_bookmarks(
|
||||
fixture["active"] + fixture["shared"]
|
||||
)
|
||||
|
||||
response = post_with("/foo//example.com", follow=True)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertBookmarkUpdateResponse(response)
|
||||
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
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
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,
|
||||
BookmarkListTestMixin,
|
||||
TagCloudTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
class BookmarkArchivedViewTestCase(
|
||||
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_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):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(
|
||||
|
@ -307,24 +244,21 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
base_url = reverse("bookmarks:archived")
|
||||
|
||||
# without params
|
||||
return_url = urllib.parse.quote_plus(base_url)
|
||||
url = f"{action_url}?return_url={return_url}"
|
||||
url = f"{action_url}"
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query
|
||||
url_params = "?q=foo"
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f"{action_url}?q=foo&return_url={return_url}"
|
||||
url = f"{action_url}?q=foo"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query and sort
|
||||
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&return_url={return_url}"
|
||||
url = f"{action_url}?q=foo&sort=title_asc"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
@ -527,7 +461,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
self.assertEqual(
|
||||
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):
|
||||
|
@ -557,3 +491,15 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
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 re
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import formats, timezone
|
||||
|
||||
from bookmarks.models import BookmarkAsset, UserProfile
|
||||
from bookmarks.services import bookmarks, tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
|
@ -17,23 +14,23 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def get_view_name(self):
|
||||
return "bookmarks:details_modal"
|
||||
|
||||
def get_base_url(self, bookmark):
|
||||
return reverse(self.get_view_name(), args=[bookmark.id])
|
||||
|
||||
def get_details_form(self, soup, bookmark):
|
||||
expected_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||
return soup.find("form", {"action": expected_url})
|
||||
form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}"
|
||||
return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"})
|
||||
|
||||
def get_details(self, bookmark, return_url=""):
|
||||
url = self.get_base_url(bookmark)
|
||||
if return_url:
|
||||
url += f"?return_url={return_url}"
|
||||
def get_index_details_modal(self, bookmark):
|
||||
url = reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
response = self.client.get(url)
|
||||
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):
|
||||
dt = soup.find("dt", string=section_name)
|
||||
|
@ -54,35 +51,68 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def find_asset(self, soup, asset):
|
||||
return soup.find("div", {"data-asset-id": asset.id})
|
||||
|
||||
def details_route_access_test(self, view_name: str, shareable: bool):
|
||||
def details_route_access_test(self):
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||
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(view_name, args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# non-existent bookmark
|
||||
response = self.client.get(reverse(view_name, args=[9999]))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
# 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(view_name, args=[bookmark.id]))
|
||||
self.assertEqual(response.status_code, 404 if shareable else 302)
|
||||
response = self.client.get(
|
||||
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
|
||||
other_user = self.setup_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)
|
||||
|
||||
# shared bookmark, sharing enabled
|
||||
|
@ -90,37 +120,31 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||
self.assertEqual(response.status_code, 200 if shareable else 404)
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# shared bookmark, guest user, no public sharing
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||
self.assertEqual(response.status_code, 404 if shareable else 302)
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# shared bookmark, guest user, public sharing
|
||||
profile.enable_public_sharing = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||
self.assertEqual(response.status_code, 200 if shareable else 302)
|
||||
|
||||
def test_access(self):
|
||||
self.details_route_access_test(self.get_view_name(), True)
|
||||
|
||||
def test_access_with_sharing(self):
|
||||
self.details_route_sharing_access_test(self.get_view_name(), True)
|
||||
|
||||
def test_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)
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_displays_title(self):
|
||||
# with title
|
||||
bookmark = self.setup_bookmark(title="Test title")
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
title = soup.find("h2")
|
||||
self.assertIsNotNone(title)
|
||||
|
@ -128,7 +152,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# with 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")
|
||||
self.assertIsNotNone(title)
|
||||
|
@ -136,7 +160,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# with URL only
|
||||
bookmark = self.setup_bookmark(title="", website_title="")
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
title = soup.find("h2")
|
||||
self.assertIsNotNone(title)
|
||||
|
@ -145,7 +169,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_website_link(self):
|
||||
# basics
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
link = self.find_weblink(soup, bookmark.url)
|
||||
self.assertIsNotNone(link)
|
||||
self.assertEqual(link["href"], bookmark.url)
|
||||
|
@ -153,7 +177,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# favicons disabled
|
||||
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)
|
||||
image = link.select_one("img")
|
||||
self.assertIsNone(image)
|
||||
|
@ -164,14 +188,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
profile.save()
|
||||
|
||||
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)
|
||||
image = link.select_one("img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# favicons enabled, favicon present
|
||||
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
link = self.find_weblink(soup, bookmark.url)
|
||||
image = link.select_one("img")
|
||||
self.assertIsNotNone(image)
|
||||
|
@ -180,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_reader_mode_link(self):
|
||||
# no latest snapshot
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# snapshot is not complete
|
||||
|
@ -194,7 +218,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_FAILURE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# not a snapshot
|
||||
|
@ -203,7 +227,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
asset_type="upload",
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# snapshot is complete
|
||||
|
@ -212,7 +236,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 3)
|
||||
|
||||
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):
|
||||
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)
|
||||
self.assertIsNotNone(link)
|
||||
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
|
||||
|
@ -231,7 +255,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
bookmark = self.setup_bookmark(
|
||||
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)
|
||||
image = link.select_one("svg")
|
||||
self.assertIsNone(image)
|
||||
|
@ -244,7 +268,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
bookmark = self.setup_bookmark(
|
||||
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)
|
||||
image = link.select_one("svg")
|
||||
self.assertIsNone(image)
|
||||
|
@ -253,7 +277,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
bookmark = self.setup_bookmark(
|
||||
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)
|
||||
image = link.select_one("svg")
|
||||
self.assertIsNotNone(image)
|
||||
|
@ -267,7 +291,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
"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)
|
||||
self.assertIsNotNone(link)
|
||||
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.save()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
website_link = self.find_weblink(soup, bookmark.url)
|
||||
self.assertIsNotNone(website_link)
|
||||
|
@ -297,7 +321,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
profile.save()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
website_link = self.find_weblink(soup, bookmark.url)
|
||||
self.assertIsNotNone(website_link)
|
||||
|
@ -312,13 +336,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_preview_image(self):
|
||||
# without image
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# with image
|
||||
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")
|
||||
self.assertIsNone(image)
|
||||
|
||||
|
@ -328,13 +352,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, image present
|
||||
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")
|
||||
self.assertIsNotNone(image)
|
||||
self.assertEqual(image["src"], "/static/example.png")
|
||||
|
@ -342,18 +366,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_status(self):
|
||||
# renders form
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
form = self.get_details_form(soup, bookmark)
|
||||
self.assertIsNotNone(form)
|
||||
self.assertEqual(
|
||||
form["action"], reverse("bookmarks:details", args=[bookmark.id])
|
||||
)
|
||||
self.assertEqual(form["method"], "post")
|
||||
|
||||
# sharing disabled
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
|
@ -369,7 +390,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
|
@ -381,7 +402,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# unchecked
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
|
@ -393,7 +414,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# checked
|
||||
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")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
|
@ -406,106 +427,29 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_status_visibility(self):
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
self.assertIsNotNone(section)
|
||||
|
||||
# other user's bookmark
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# guest user
|
||||
self.client.logout()
|
||||
other_user.profile.enable_public_sharing = True
|
||||
other_user.profile.save()
|
||||
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")
|
||||
self.assertIsNone(section)
|
||||
|
||||
def test_status_update(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# update status
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.is_archived)
|
||||
self.assertTrue(bookmark.unread)
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
# update individual status
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "", "unread": "on", "shared": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
self.assertTrue(bookmark.unread)
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_status_update_access(self):
|
||||
# no sharing
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# shared, sharing disabled
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# shared, sharing enabled
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
profile = other_user.profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# shared, public sharing enabled
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
profile = other_user.profile
|
||||
profile.enable_public_sharing = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# guest user
|
||||
self.client.logout()
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_date_added(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Date added")
|
||||
|
||||
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||
|
@ -515,7 +459,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_tags(self):
|
||||
# without tags
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Tags")
|
||||
self.assertIsNone(section)
|
||||
|
@ -523,7 +467,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
# with tags
|
||||
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")
|
||||
|
||||
for tag in bookmark.tags.all():
|
||||
|
@ -535,14 +479,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_description(self):
|
||||
# without 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")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with 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")
|
||||
self.assertEqual(section.text.strip(), bookmark.description)
|
||||
|
@ -551,7 +495,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
bookmark = self.setup_bookmark(
|
||||
description="", website_description="Website description"
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.get_section(soup, "Description")
|
||||
self.assertEqual(section.text.strip(), bookmark.website_description)
|
||||
|
@ -559,14 +503,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_notes(self):
|
||||
# without notes
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Notes")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with 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")
|
||||
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||
|
@ -575,52 +519,42 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
bookmark = self.setup_bookmark()
|
||||
|
||||
# with default return URL
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
edit_link = soup.find("a", string="Edit")
|
||||
self.assertIsNotNone(edit_link)
|
||||
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||
expected_url = (
|
||||
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url
|
||||
)
|
||||
self.assertEqual(edit_link["href"], expected_url)
|
||||
|
||||
# with custom return URL
|
||||
soup = self.get_details(bookmark, return_url="/custom")
|
||||
edit_link = soup.find("a", string="Edit")
|
||||
self.assertIsNotNone(edit_link)
|
||||
expected_url = (
|
||||
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
|
||||
)
|
||||
self.assertEqual(edit_link["href"], expected_url)
|
||||
details_url = reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
expected_url = "/bookmarks/1/edit?return_url=/bookmarks%3Fdetails%3D1"
|
||||
self.assertEqual(expected_url, edit_link["href"])
|
||||
|
||||
def test_delete_button(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# basics
|
||||
soup = self.get_details(bookmark)
|
||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||
modal = self.get_index_details_modal(bookmark)
|
||||
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
|
||||
self.assertIsNotNone(delete_button)
|
||||
self.assertEqual(delete_button.text.strip(), "Delete...")
|
||||
self.assertEqual(delete_button["value"], str(bookmark.id))
|
||||
self.assertEqual("Delete...", delete_button.text.strip())
|
||||
self.assertEqual(str(bookmark.id), delete_button["value"])
|
||||
|
||||
form = delete_button.find_parent("form")
|
||||
self.assertIsNotNone(form)
|
||||
expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks"
|
||||
self.assertEqual(form["action"], expected_url)
|
||||
|
||||
# with custom return URL
|
||||
soup = self.get_details(bookmark, return_url="/custom")
|
||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||
form = delete_button.find_parent("form")
|
||||
expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
|
||||
self.assertEqual(form["action"], expected_url)
|
||||
expected_url = reverse("bookmarks:index.action")
|
||||
self.assertEqual(expected_url, form["action"])
|
||||
|
||||
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
|
||||
other_user = self.setup_user(enable_sharing=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")
|
||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||
self.assertIsNone(edit_link)
|
||||
|
@ -632,7 +566,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
profile.save()
|
||||
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")
|
||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||
self.assertIsNone(edit_link)
|
||||
|
@ -642,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
self.client.logout()
|
||||
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")
|
||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||
self.assertIsNone(edit_link)
|
||||
|
@ -651,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_assets_visibility_no_snapshot_support(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Files")
|
||||
self.assertIsNone(section)
|
||||
|
||||
|
@ -659,7 +593,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
def test_assets_visibility_with_snapshot_support(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Files")
|
||||
self.assertIsNotNone(section)
|
||||
|
||||
|
@ -668,7 +602,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
# no assets
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
self.assertIsNone(asset_list)
|
||||
|
@ -677,7 +611,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
bookmark = self.setup_bookmark()
|
||||
self.setup_asset(bookmark)
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
self.assertIsNotNone(asset_list)
|
||||
|
@ -691,7 +625,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
self.setup_asset(bookmark),
|
||||
]
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
|
||||
|
@ -717,7 +651,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
asset.file = ""
|
||||
asset.save()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
asset_item = self.find_asset(soup, asset)
|
||||
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
||||
view_link = asset_item.find("a", {"href": view_url})
|
||||
|
@ -729,7 +663,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
|
||||
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, pending_asset)
|
||||
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)
|
||||
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_text = asset_item.select_one(".asset-text")
|
||||
|
@ -766,7 +700,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# with file
|
||||
asset = self.setup_asset(bookmark)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, asset)
|
||||
view_link = asset_item.find("a", string="View")
|
||||
|
@ -779,7 +713,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
# without file
|
||||
asset.file = ""
|
||||
asset.save()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, asset)
|
||||
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)
|
||||
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||
asset = self.setup_asset(bookmark)
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, asset)
|
||||
view_link = asset_item.find("a", string="View")
|
||||
|
@ -805,7 +739,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
|
||||
# shared bookmark, guest user
|
||||
self.client.logout()
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
|
||||
asset_item = self.find_asset(soup, asset)
|
||||
view_link = asset_item.find("a", string="View")
|
||||
|
@ -815,77 +749,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
self.assertIsNotNone(view_link)
|
||||
self.assertIsNone(delete_button)
|
||||
|
||||
def test_remove_asset(self):
|
||||
# remove asset
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark)
|
||||
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark), {"remove_asset": asset.id}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
|
||||
# non-existent asset
|
||||
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# post without asset ID does not remove
|
||||
asset = self.setup_asset(bookmark)
|
||||
response = self.client.post(self.get_base_url(bookmark))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
|
||||
# guest user
|
||||
asset = self.setup_asset(bookmark)
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark), {"remove_asset": asset.id}
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_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)
|
||||
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
|
||||
|
||||
# no pending asset
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
|
@ -896,40 +766,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||
asset.status = BookmarkAsset.STATUS_PENDING
|
||||
asset.save()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
self.assertTrue(create_button.has_attr("disabled"))
|
||||
|
||||
def test_upload_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile("test.txt", file_content)
|
||||
|
||||
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"upload_asset": "", "upload_asset_file": upload_file},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
mock_upload_asset.assert_called_once()
|
||||
|
||||
args, kwargs = mock_upload_asset.call_args
|
||||
self.assertEqual(args[0], bookmark)
|
||||
|
||||
upload_file = args[1]
|
||||
self.assertEqual(upload_file.name, "test.txt")
|
||||
|
||||
def test_upload_file_without_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"upload_asset": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
mock_upload_asset.assert_not_called()
|
||||
|
|
|
@ -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
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.models import BookmarkSearch, UserProfile
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
BookmarkListTestMixin,
|
||||
TagCloudTestMixin,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
class BookmarkIndexViewTestCase(
|
||||
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
|
||||
):
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_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):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(
|
||||
|
@ -285,24 +224,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
base_url = reverse("bookmarks:index")
|
||||
|
||||
# without params
|
||||
return_url = urllib.parse.quote_plus(base_url)
|
||||
url = f"{action_url}?return_url={return_url}"
|
||||
url = f"{action_url}"
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query
|
||||
url_params = "?q=foo"
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f"{action_url}?q=foo&return_url={return_url}"
|
||||
url = f"{action_url}?q=foo"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query and sort
|
||||
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&return_url={return_url}"
|
||||
url = f"{action_url}?q=foo&sort=title_asc"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
@ -503,7 +439,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
|
||||
self.assertEqual(
|
||||
actions_form.attrs["action"],
|
||||
"/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo",
|
||||
"/bookmarks/action?q=%23foo",
|
||||
)
|
||||
|
||||
def test_encode_search_params(self):
|
||||
|
@ -533,3 +469,15 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
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 django.db.models import QuerySet
|
||||
from django.template import Template, RequestContext
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from bookmarks.models import BookmarkSearch, Tag
|
||||
from bookmarks.models import BookmarkSearch
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def render_template(
|
||||
self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ""
|
||||
):
|
||||
def render_template(self, url: str, mode: str = ""):
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
|
@ -21,32 +18,31 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
{
|
||||
"request": request,
|
||||
"search": search,
|
||||
"tags": tags,
|
||||
"mode": mode,
|
||||
},
|
||||
)
|
||||
template_to_render = Template(
|
||||
"{% load bookmarks %}" "{% bookmark_search search tags mode %}"
|
||||
"{% load bookmarks %} {% bookmark_search search mode %}"
|
||||
)
|
||||
return template_to_render.render(context)
|
||||
|
||||
def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):
|
||||
input = form.select_one(f'input[name="{name}"][type="hidden"]')
|
||||
self.assertIsNotNone(input)
|
||||
element = form.select_one(f'input[name="{name}"][type="hidden"]')
|
||||
self.assertIsNotNone(element)
|
||||
|
||||
if value is not None:
|
||||
self.assertEqual(input["value"], value)
|
||||
self.assertEqual(element["value"], value)
|
||||
|
||||
def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
|
||||
input = form.select_one(f'input[name="{name}"][type="hidden"]')
|
||||
self.assertIsNone(input)
|
||||
element = form.select_one(f'input[name="{name}"][type="hidden"]')
|
||||
self.assertIsNone(element)
|
||||
|
||||
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
|
||||
input = form.select_one(f'input[name="{name}"][type="search"]')
|
||||
self.assertIsNotNone(input)
|
||||
element = form.select_one(f'input[name="{name}"][type="search"]')
|
||||
self.assertIsNotNone(element)
|
||||
|
||||
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):
|
||||
select = form.select_one(f'select[name="{name}"]')
|
||||
|
|
|
@ -6,11 +6,16 @@ from django.test import TestCase
|
|||
from django.urls import reverse
|
||||
|
||||
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:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
@ -24,57 +29,6 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
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]):
|
||||
html = response.content.decode()
|
||||
|
||||
|
@ -84,7 +38,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
f'<option value="{user.username}">{user.username}</option>'
|
||||
)
|
||||
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)}
|
||||
</select>
|
||||
"""
|
||||
|
@ -593,7 +547,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
|
||||
self.assertEqual(
|
||||
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):
|
||||
|
@ -627,3 +581,15 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
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.models import Bookmark, UserProfile, User
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.views.partials import contexts
|
||||
from bookmarks.views import contexts
|
||||
|
||||
|
||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
@ -51,31 +51,25 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
html,
|
||||
)
|
||||
|
||||
def assertViewLink(
|
||||
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
||||
):
|
||||
self.assertViewLinkCount(html, bookmark, return_url=return_url)
|
||||
def assertViewLink(self, html: str, bookmark: Bookmark, base_url=None):
|
||||
self.assertViewLinkCount(html, bookmark, base_url)
|
||||
|
||||
def assertNoViewLink(
|
||||
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
||||
):
|
||||
self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
|
||||
def assertNoViewLink(self, html: str, bookmark: Bookmark, base_url=None):
|
||||
self.assertViewLinkCount(html, bookmark, base_url, count=0)
|
||||
|
||||
def assertViewLinkCount(
|
||||
self,
|
||||
html: str,
|
||||
bookmark: Bookmark,
|
||||
base_url: str = None,
|
||||
count=1,
|
||||
return_url=reverse("bookmarks:index"),
|
||||
):
|
||||
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||
if base_url is None:
|
||||
base_url = reverse("bookmarks:index")
|
||||
details_url = base_url + f"?details={bookmark.id}"
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a ld-fetch="{details_modal_url}?return_url={return_url}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
data-turbo-prefetch="false"
|
||||
href="{details_url}">View</a>
|
||||
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
|
@ -652,7 +646,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
||||
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertShareInfo(html, bookmark)
|
||||
|
||||
|
@ -944,7 +938,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
self.assertWebArchiveLink(
|
||||
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.assertShareInfo(html, bookmark)
|
||||
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"
|
||||
)
|
||||
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.models import UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.views.partials import contexts
|
||||
from bookmarks.views import contexts
|
||||
|
||||
|
||||
class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
@ -203,13 +203,28 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
tag = self.setup_tag(name="tag1")
|
||||
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(
|
||||
url="/test?q=term1&sort=title_asc&page=2"
|
||||
url="/test?q=term1&sort=title_asc&page=2&details=5"
|
||||
)
|
||||
|
||||
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>
|
||||
</a>
|
||||
""",
|
||||
|
@ -347,12 +362,30 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||
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"
|
||||
url="/test?q=term1 %23tag1 term2&sort=title_asc"
|
||||
)
|
||||
|
||||
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">
|
||||
<span>-tag1</span>
|
||||
</a>
|
||||
|
|
|
@ -9,7 +9,6 @@ from bookmarks.feeds import (
|
|||
SharedBookmarksFeed,
|
||||
PublicSharedBookmarksFeed,
|
||||
)
|
||||
from bookmarks.views import partials
|
||||
|
||||
app_name = "bookmarks"
|
||||
urlpatterns = [
|
||||
|
@ -31,21 +30,6 @@ urlpatterns = [
|
|||
path("bookmarks/new", views.bookmarks.new, name="new"),
|
||||
path("bookmarks/close", views.bookmarks.close, name="close"),
|
||||
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
|
||||
path(
|
||||
"bookmarks/<int:bookmark_id>/details",
|
||||
views.bookmarks.details,
|
||||
name="details",
|
||||
),
|
||||
path(
|
||||
"bookmarks/<int:bookmark_id>/details_modal",
|
||||
views.bookmarks.details_modal,
|
||||
name="details_modal",
|
||||
),
|
||||
path(
|
||||
"bookmarks/<int:bookmark_id>/details_assets",
|
||||
views.bookmarks.details_assets,
|
||||
name="details_assets",
|
||||
),
|
||||
# Assets
|
||||
path(
|
||||
"assets/<int:asset_id>",
|
||||
|
@ -57,52 +41,6 @@ urlpatterns = [
|
|||
views.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
|
||||
path("settings", views.settings.general, name="settings.index"),
|
||||
path("settings/general", views.settings.general, name="settings.general"),
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.template.defaultfilters import pluralize
|
||||
from django.utils import timezone, formats
|
||||
|
||||
|
@ -114,6 +116,14 @@ def get_safe_return_url(return_url: str, fallback_url: str):
|
|||
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):
|
||||
# taken from mozilla-django-oidc docs :)
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ from django.http import (
|
|||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks import queries, utils
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
|
@ -19,6 +19,7 @@ from bookmarks.models import (
|
|||
BookmarkSearch,
|
||||
build_tag_string,
|
||||
)
|
||||
from bookmarks.services import bookmarks as bookmark_actions, tasks
|
||||
from bookmarks.services.bookmarks import (
|
||||
create_bookmark,
|
||||
update_bookmark,
|
||||
|
@ -34,9 +35,8 @@ from bookmarks.services.bookmarks import (
|
|||
share_bookmarks,
|
||||
unshare_bookmarks,
|
||||
)
|
||||
from bookmarks.services import bookmarks as bookmark_actions, tasks
|
||||
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
|
||||
|
||||
|
@ -48,12 +48,17 @@ def index(request):
|
|||
|
||||
bookmark_list = contexts.ActiveBookmarkListContext(request)
|
||||
tag_cloud = contexts.ActiveTagCloudContext(request)
|
||||
return render(
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.ActiveBookmarkDetailsContext
|
||||
)
|
||||
|
||||
return render_bookmarks_view(
|
||||
request,
|
||||
"bookmarks/index.html",
|
||||
{
|
||||
"bookmark_list": bookmark_list,
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -65,12 +70,17 @@ def archived(request):
|
|||
|
||||
bookmark_list = contexts.ArchivedBookmarkListContext(request)
|
||||
tag_cloud = contexts.ArchivedTagCloudContext(request)
|
||||
return render(
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.ArchivedBookmarkDetailsContext
|
||||
)
|
||||
|
||||
return render_bookmarks_view(
|
||||
request,
|
||||
"bookmarks/archive.html",
|
||||
{
|
||||
"bookmark_list": bookmark_list,
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -81,14 +91,37 @@ def shared(request):
|
|||
|
||||
bookmark_list = contexts.SharedBookmarkListContext(request)
|
||||
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||
bookmark_details = contexts.get_details_context(
|
||||
request, contexts.SharedBookmarkDetailsContext
|
||||
)
|
||||
public_only = not request.user.is_authenticated
|
||||
users = queries.query_shared_bookmark_users(
|
||||
request.user_profile, bookmark_list.search, public_only
|
||||
)
|
||||
return render(
|
||||
return render_bookmarks_view(
|
||||
request,
|
||||
"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)
|
||||
|
||||
|
||||
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):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
|
@ -307,26 +270,87 @@ def mark_as_read(request, bookmark_id: int):
|
|||
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
|
||||
def index_action(request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
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
|
||||
def archived_action(request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
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
|
||||
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
|
||||
if "archive" in request.POST:
|
||||
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"])
|
||||
if "unshare" in request.POST:
|
||||
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
|
||||
if "bulk_execute" in request.POST:
|
||||
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"]
|
||||
|
||||
|
@ -375,11 +409,6 @@ def action(request, query: QuerySet[Bookmark] = None):
|
|||
if "bulk_unshare" == bulk_action:
|
||||
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
|
||||
def close(request):
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.conf import settings
|
|||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import models
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
|
@ -27,17 +28,11 @@ CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
|||
class RequestContext:
|
||||
index_view = "bookmarks:index"
|
||||
action_view = "bookmarks:index.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
|
||||
|
||||
def __init__(self, request: WSGIRequest):
|
||||
self.request = request
|
||||
self.index_url = reverse(self.index_view)
|
||||
self.action_url = reverse(self.action_view)
|
||||
self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
|
||||
self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
|
||||
self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
|
||||
self.query_params = request.GET.copy()
|
||||
self.query_params.pop("details", None)
|
||||
|
||||
|
@ -51,34 +46,25 @@ class RequestContext:
|
|||
encoded_params = query_params.urlencode()
|
||||
return view_url + "?" + encoded_params if encoded_params else view_url
|
||||
|
||||
def index(self) -> str:
|
||||
return self.get_url(self.index_url)
|
||||
def index(self, add: dict = None, remove: dict = None) -> str:
|
||||
return self.get_url(self.index_url, add=add, remove=remove)
|
||||
|
||||
def action(self, return_url: str) -> str:
|
||||
return self.get_url(self.action_url, add={"return_url": return_url})
|
||||
def action(self, add: dict = None, remove: dict = None) -> str:
|
||||
return self.get_url(self.action_url, add=add, remove=remove)
|
||||
|
||||
def bookmark_list_partial(self) -> str:
|
||||
return self.get_url(self.bookmark_list_partial_url)
|
||||
|
||||
def tag_cloud_partial(self) -> str:
|
||||
return self.get_url(self.tag_cloud_partial_url)
|
||||
|
||||
def tag_modal_partial(self) -> str:
|
||||
return self.get_url(self.tag_modal_partial_url)
|
||||
def details(self, bookmark_id: int) -> str:
|
||||
return self.get_url(self.index_url, add={"details": bookmark_id})
|
||||
|
||||
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):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
raise NotImplementedError("Must be implemented by subclass")
|
||||
|
||||
|
||||
class ActiveBookmarksContext(RequestContext):
|
||||
index_view = "bookmarks:index"
|
||||
action_view = "bookmarks:index.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
return queries.query_bookmarks(
|
||||
|
@ -94,9 +80,6 @@ class ActiveBookmarksContext(RequestContext):
|
|||
class ArchivedBookmarksContext(RequestContext):
|
||||
index_view = "bookmarks:archived"
|
||||
action_view = "bookmarks:archived.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
return queries.query_archived_bookmarks(
|
||||
|
@ -112,9 +95,6 @@ class ArchivedBookmarksContext(RequestContext):
|
|||
class SharedBookmarksContext(RequestContext):
|
||||
index_view = "bookmarks:shared"
|
||||
action_view = "bookmarks:shared.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
|
@ -132,7 +112,13 @@ class SharedBookmarksContext(RequestContext):
|
|||
|
||||
|
||||
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
|
||||
|
||||
is_editable = bookmark.owner == user
|
||||
|
@ -154,6 +140,7 @@ class BookmarkItem:
|
|||
self.is_archived = bookmark.is_archived
|
||||
self.unread = bookmark.unread
|
||||
self.owner = bookmark.owner
|
||||
self.details_url = context.details(bookmark.id)
|
||||
|
||||
css_classes = []
|
||||
if bookmark.unread:
|
||||
|
@ -200,16 +187,15 @@ class BookmarkListContext:
|
|||
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
|
||||
|
||||
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.bookmarks_page = bookmarks_page
|
||||
self.bookmarks_total = paginator.count
|
||||
|
||||
self.return_url = request_context.index()
|
||||
self.action_url = request_context.action(return_url=self.return_url)
|
||||
self.refresh_url = request_context.bookmark_list_partial()
|
||||
self.tag_modal_url = request_context.tag_modal_partial()
|
||||
self.action_url = request_context.action()
|
||||
|
||||
self.link_target = user_profile.bookmark_link_target
|
||||
self.date_display = user_profile.bookmark_date_display
|
||||
|
@ -344,8 +330,6 @@ class TagCloudContext:
|
|||
self.selected_tags = unique_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]):
|
||||
parsed_query = queries.parse_query_string(self.search.q)
|
||||
tag_names = parsed_query["tag_names"]
|
||||
|
@ -396,17 +380,18 @@ class BookmarkAssetItem:
|
|||
|
||||
|
||||
class BookmarkDetailsContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: WSGIRequest, bookmark: Bookmark):
|
||||
request_context = self.request_context(request)
|
||||
|
||||
user = request.user
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.edit_return_url = utils.get_safe_return_url(
|
||||
request.GET.get("return_url"),
|
||||
reverse("bookmarks:details", args=[bookmark.id]),
|
||||
)
|
||||
self.delete_return_url = utils.get_safe_return_url(
|
||||
request.GET.get("return_url"), reverse("bookmarks:index")
|
||||
)
|
||||
self.edit_return_url = request_context.details(bookmark.id)
|
||||
self.action_url = request_context.action(add={"details": bookmark.id})
|
||||
self.delete_url = request_context.action()
|
||||
self.close_url = request_context.index()
|
||||
|
||||
self.bookmark = bookmark
|
||||
self.profile = request.user_profile
|
||||
|
@ -438,3 +423,44 @@ class BookmarkDetailsContext:
|
|||
),
|
||||
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
|
||||
|
||||
# Enable debug toolbar
|
||||
INSTALLED_APPS.append("debug_toolbar")
|
||||
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||
# INSTALLED_APPS.append("debug_toolbar")
|
||||
# MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
|
|
Loading…
Reference in a new issue