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:
Sascha Ißbrücker 2024-09-16 12:48:19 +02:00 committed by GitHub
parent db225d5267
commit ffaaf0521d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1419 additions and 1444 deletions

View file

@ -121,8 +121,9 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
with self.page.expect_navigation(): with self.page.expect_navigation():
details_modal.get_by_text("Edit").click() details_modal.get_by_text("Edit").click()
# Cancel edit, verify return url # Cancel edit, verify return to details url
with self.page.expect_navigation(url=self.live_server_url + url): details_url = url + f"&details={bookmark.id}"
with self.page.expect_navigation(url=self.live_server_url + details_url):
self.page.get_by_text("Nevermind").click() self.page.get_by_text("Nevermind").click()
def test_delete(self): def test_delete(self):
@ -167,7 +168,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Has new snapshots # Has new snapshots
expect(snapshot).to_be_visible() expect(snapshot).to_be_visible()
# Create snapshot # Remove snapshot
asset_list.get_by_text("Remove", exact=False).click() asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click() asset_list.get_by_text("Confirm", exact=False).click()

View file

@ -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()

View file

@ -1,9 +1,7 @@
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright, expect, Locator from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class TagCloudModalE2ETestCase(LinkdingE2ETestCase): class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
@ -26,7 +24,7 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
# verify modal is visible # verify modal is visible
modal = page.locator(".modal") modal = page.locator(".modal")
expect(modal).to_be_visible() expect(modal).to_be_visible()
expect(modal.locator(".modal-title")).to_have_text("Tags") expect(modal.locator("h2")).to_have_text("Tags")
# close with close button # close with close button
modal.locator("button.close").click() modal.locator("button.close").click()

View file

@ -1,4 +1,4 @@
export class ApiClient { export class Api {
constructor(baseUrl) { constructor(baseUrl) {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
@ -27,3 +27,6 @@ export class ApiClient {
.then((data) => data.results); .then((data) => data.results);
} }
} }
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
export const api = new Api(apiBaseUrl);

View file

@ -5,9 +5,10 @@ class BookmarkItem extends Behavior {
super(element); super(element);
// Toggle notes // Toggle notes
const notesToggle = element.querySelector(".toggle-notes"); this.onToggleNotes = this.onToggleNotes.bind(this);
if (notesToggle) { this.notesToggle = element.querySelector(".toggle-notes");
notesToggle.addEventListener("click", this.onToggleNotes.bind(this)); if (this.notesToggle) {
this.notesToggle.addEventListener("click", this.onToggleNotes);
} }
// Add tooltip to title if it is truncated // Add tooltip to title if it is truncated
@ -20,6 +21,12 @@ class BookmarkItem extends Behavior {
}); });
} }
destroy() {
if (this.notesToggle) {
this.notesToggle.removeEventListener("click", this.onToggleNotes);
}
}
onToggleNotes(event) { onToggleNotes(event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();

View file

@ -13,12 +13,13 @@ class BulkEdit extends Behavior {
this.onActionSelected = this.onActionSelected.bind(this); this.onActionSelected = this.onActionSelected.bind(this);
this.init(); this.init();
// Reset when bookmarks are refreshed // Reset when bookmarks are updated
document.addEventListener("refresh-bookmark-list-done", this.init); document.addEventListener("bookmark-list-updated", this.init);
} }
destroy() { destroy() {
document.removeEventListener("refresh-bookmark-list-done", this.init); this.removeListeners();
document.removeEventListener("bookmark-list-updated", this.init);
} }
init() { init() {
@ -36,13 +37,9 @@ class BulkEdit extends Behavior {
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"), this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
); );
// Remove previous listeners if elements are the same // Add listeners, ensure there are no dupes by possibly removing existing listeners
this.activeToggle.removeEventListener("click", this.onToggleActive); this.removeListeners();
this.actionSelect.removeEventListener("change", this.onActionSelected); this.addListeners();
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
// Reset checkbox states // Reset checkbox states
this.reset(); this.reset();
@ -52,8 +49,9 @@ class BulkEdit extends Behavior {
const total = totalHolder?.dataset.bookmarksTotal || 0; const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total"); const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total; totalSpan.textContent = total;
}
// Add new listeners addListeners() {
this.activeToggle.addEventListener("click", this.onToggleActive); this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected); this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll); this.allCheckbox.addEventListener("change", this.onToggleAll);
@ -62,6 +60,15 @@ class BulkEdit extends Behavior {
}); });
} }
removeListeners() {
this.activeToggle.removeEventListener("click", this.onToggleActive);
this.actionSelect.removeEventListener("change", this.onActionSelected);
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
}
onToggleActive() { onToggleActive() {
this.active = !this.active; this.active = !this.active;
if (this.active) { if (this.active) {

View file

@ -3,20 +3,14 @@ import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior extends Behavior { class ConfirmButtonBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
element.dataset.type = element.type;
element.dataset.name = element.name; this.onClick = this.onClick.bind(this);
element.dataset.value = element.value; element.addEventListener("click", this.onClick);
element.removeAttribute("type");
element.removeAttribute("name");
element.removeAttribute("value");
element.addEventListener("click", this.onClick.bind(this));
} }
destroy() { destroy() {
this.reset(); this.reset();
this.element.setAttribute("type", this.element.dataset.type); this.element.removeEventListener("click", this.onClick);
this.element.setAttribute("name", this.element.dataset.name);
this.element.setAttribute("value", this.element.dataset.value);
} }
onClick(event) { onClick(event) {
@ -56,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior {
cancelButton.addEventListener("click", this.reset.bind(this)); cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.element.nodeName); const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.dataset.type; confirmButton.type = this.element.type;
confirmButton.name = this.element.dataset.name; confirmButton.name = this.element.name;
confirmButton.value = this.element.dataset.value; confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm"; confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses; confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this)); confirmButton.addEventListener("click", this.reset.bind(this));

View 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);

View file

@ -4,20 +4,16 @@ class DropdownBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
this.opened = false; this.opened = false;
this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this);
const toggle = element.querySelector(".dropdown-toggle"); this.toggle = element.querySelector(".dropdown-toggle");
toggle.addEventListener("click", () => { this.toggle.addEventListener("click", this.onClick);
if (this.opened) {
this.close();
} else {
this.open();
}
});
} }
destroy() { destroy() {
this.close(); this.close();
this.toggle.removeEventListener("click", this.onClick);
} }
open() { open() {
@ -30,6 +26,14 @@ class DropdownBehavior extends Behavior {
document.removeEventListener("click", this.onOutsideClick); document.removeEventListener("click", this.onOutsideClick);
} }
onClick() {
if (this.opened) {
this.close();
} else {
this.open();
}
}
onOutsideClick(event) { onOutsideClick(event) {
if (!this.element.contains(event.target)) { if (!this.element.contains(event.target)) {
this.close(); this.close();

View file

@ -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);

View file

@ -1,64 +1,55 @@
import { Behavior, fireEvents, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class FormBehavior extends Behavior {
constructor(element) {
super(element);
element.addEventListener("submit", this.onSubmit.bind(this));
}
async onSubmit(event) {
event.preventDefault();
const url = this.element.action;
const formData = new FormData(this.element);
if (event.submitter) {
formData.append(event.submitter.name, event.submitter.value);
}
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
const events = this.element.getAttribute("ld-fire");
if (fireEvents) {
fireEvents(events);
}
}
}
class AutoSubmitBehavior extends Behavior { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
element.addEventListener("change", () => { this.submit = this.submit.bind(this);
const form = element.closest("form"); element.addEventListener("change", this.submit);
form.dispatchEvent(new Event("submit", { cancelable: true })); }
});
destroy() {
this.element.removeEventListener("change", this.submit);
}
submit() {
this.element.closest("form").requestSubmit();
} }
} }
class UploadButton extends Behavior { class UploadButton extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
this.fileInput = element.nextElementSibling;
const fileInput = element.nextElementSibling; this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this);
element.addEventListener("click", () => { element.addEventListener("click", this.onClick);
fileInput.click(); this.fileInput.addEventListener("change", this.onChange);
}); }
fileInput.addEventListener("change", () => { destroy() {
const form = fileInput.closest("form"); this.element.removeEventListener("click", this.onClick);
const event = new Event("submit", { cancelable: true }); this.fileInput.removeEventListener("change", this.onChange);
event.submitter = element; }
form.dispatchEvent(event);
}); onClick(event) {
event.preventDefault();
this.fileInput.click();
}
onChange() {
// Check if the file input has a file selected
if (!this.fileInput.files.length) {
return;
}
const form = this.fileInput.closest("form");
form.requestSubmit(this.element);
// remove selected file so it doesn't get submitted again
this.fileInput.value = "";
} }
} }
registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-auto-submit", AutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton); registerBehavior("ld-upload-button", UploadButton);

View file

@ -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));
});
});
}

View file

@ -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);

View 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);

View file

@ -1,19 +1,16 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api";
class TagAutocomplete extends Behavior { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
const input = element.querySelector("input"); const input = element.querySelector("input");
if (!input) { if (!input) {
console.warning("TagAutocomplete: input element not found"); console.warn("TagAutocomplete: input element not found");
return; return;
} }
const container = document.createElement("div"); const container = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl);
new TagAutoCompleteComponent({ new TagAutoCompleteComponent({
target: container, target: container,
@ -22,7 +19,6 @@ class TagAutocomplete extends Behavior {
name: input.name, name: input.name,
value: input.value, value: input.value,
placeholder: input.getAttribute("placeholder") || "", placeholder: input.getAttribute("placeholder") || "",
apiClient: apiClient,
variant: input.getAttribute("variant"), variant: input.getAttribute("variant"),
}, },
}); });

View 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);

View 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);

View file

@ -1,5 +1,7 @@
<script> <script>
import {SearchHistory} from "./SearchHistory"; import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util"; import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory() const searchHistory = new SearchHistory()
@ -7,9 +9,7 @@
export let name; export let name;
export let placeholder; export let placeholder;
export let value; export let value;
export let tags;
export let mode = ''; export let mode = '';
export let apiClient;
export let search; export let search;
export let linkTarget = '_blank'; export let linkTarget = '_blank';
@ -88,17 +88,18 @@
} }
// Tag suggestions // Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = [] let tagSuggestions = []
const currentWord = getCurrentWord(input) const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') { if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length) const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0) tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5) .slice(0, 5)
.map(tagName => ({ .map(tag => ({
type: 'tag', type: 'tag',
index: nextIndex(), index: nextIndex(),
label: `#${tagName}`, label: `#${tag.name}`,
tagName: tagName tagName: tag.name
})) }))
} }
@ -119,7 +120,7 @@
...search, ...search,
q: value q: value
} }
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path}) const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => { bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60) const label = clampText(fullLabel, 60)

View file

@ -1,14 +1,13 @@
<script> <script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds} from "../util"; import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id; export let id;
export let name; export let name;
export let value; export let value;
export let placeholder; export let placeholder;
export let apiClient;
export let variant = 'default'; export let variant = 'default';
let tags = [];
let isFocus = false; let isFocus = false;
let isOpen = false; let isOpen = false;
let input = null; let input = null;
@ -17,18 +16,6 @@
let suggestions = []; let suggestions = [];
let selectedIndex = 0; let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 5000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() { function handleFocus() {
isFocus = true; isFocus = true;
} }
@ -38,9 +25,10 @@
close(); close();
} }
function handleInput(e) { async function handleInput(e) {
input = e.target; input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input); const word = getCurrentWord(input);
suggestions = word suggestions = word

View file

@ -3,11 +3,14 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit"; import "./behaviors/bulk-edit";
import "./behaviors/confirm-button"; import "./behaviors/confirm-button";
import "./behaviors/dropdown"; import "./behaviors/dropdown";
import "./behaviors/fetch";
import "./behaviors/form"; import "./behaviors/form";
import "./behaviors/modal"; import "./behaviors/details-modal";
import "./behaviors/global-shortcuts"; import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete"; import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte"; export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte"; export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { ApiClient } from "./api"; export { api } from "./api";
export { cache } from "./cache";

View file

@ -282,7 +282,7 @@ class BookmarkSearchForm(forms.Form):
] ]
q = forms.CharField() q = forms.CharField()
user = forms.ChoiceField() user = forms.ChoiceField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES) sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect) shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect) unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)

View file

@ -1,12 +1,5 @@
/* Common styles */ /* Common styles */
.bookmark-details { .bookmark-details {
& h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
& .weblinks { & .weblinks {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -134,17 +127,3 @@
flex-direction: column; flex-direction: column;
gap: var(--unit-6); gap: var(--unit-6);
} }
/* Bookmark details modal specific */
.bookmark-details.modal {
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
}
& .modal-body {
padding-top: 0;
padding-bottom: 0;
}
}

View file

@ -65,8 +65,18 @@
width: 100%; width: 100%;
& .modal-header { & .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
color: var(--text-color); color: var(--text-color);
& h2 {
flex: 1 1 0;
align-items: flex-start;
font-size: 1rem;
margin: 0;
}
& button.close { & button.close {
background: none; background: none;
border: none; border: none;

View file

@ -11,24 +11,20 @@
<div class="content-area-header mb-0"> <div class="content-area-header mb-0">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %} {% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click" <button ld-tag-modal class="btn ml-2 show-md">Tags
class="btn ml-2 show-md">Tags
</button> </button>
</div> </div>
</div> </div>
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud" <form class="bookmark-actions"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list" <div id="bookmark-list-container">
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@ -39,10 +35,16 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud" <div id="tag-cloud-container">
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -78,10 +78,7 @@
{% endif %} {% endif %}
{# View link is visible for both owned and shared bookmarks #} {# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %} {% if bookmark_list.show_view_action %}
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}" <a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
ld-on="click" ld-target="body|append"
data-turbo-prefetch="false"
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
{% endif %} {% endif %}
{% if bookmark_item.is_editable %} {% if bookmark_item.is_editable %}
{# Bookmark owner actions #} {# Bookmark owner actions #}

View file

@ -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 %}

View file

@ -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>

View file

@ -1,7 +1,4 @@
<div {% if details.has_pending_assets %} <div>
ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
ld-interval="5" ld-target="self|outerHTML"
{% endif %}>
{% if details.assets %} {% if details.assets %}
<div class="assets"> <div class="assets">
{% for asset in details.assets %} {% for asset in details.assets %}
@ -36,10 +33,10 @@
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
<button type="submit" name="create_snapshot" class="btn btn-sm" <button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button> </button>
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button" <button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file class="btn btn-sm">Upload file
</button> </button>
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide"> <input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">

View file

@ -1,9 +1,10 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details" <form action="{{ details.action_url }}" method="post" enctype="multipart/form-data">
action="{% url 'bookmarks:details' details.bookmark.id %}" {% csrf_token %}
method="post"> <input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
<div class="weblinks"> <div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener" <a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
@ -47,7 +48,6 @@
<div class="status col-2"> <div class="status col-2">
<dt>Status</dt> <dt>Status</dt>
<dd class="d-flex" style="gap: .8rem"> <dd class="d-flex" style="gap: .8rem">
{% csrf_token %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived" <input ld-auto-submit type="checkbox" name="is_archived"

View 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>

View file

@ -1,3 +0,0 @@
<h2>
{{ details.bookmark.resolved_title }}
</h2>

View file

@ -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>

View 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>

View file

@ -11,24 +11,19 @@
<div class="content-area-header mb-0"> <div class="content-area-header mb-0">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags %} {% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click" <button ld-tag-modal class="btn ml-2 show-md">Tags</button>
class="btn ml-2 show-md">Tags
</button>
</div> </div>
</div> </div>
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud" <form class="bookmark-actions"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list" <div id="bookmark-list-container">
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@ -39,10 +34,16 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud" <div id="tag-cloud-container">
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -3,44 +3,7 @@
<!DOCTYPE html> <!DOCTYPE html>
{# Use data attributes as storage for access in static scripts #} {# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}"> <html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<head> {% include 'bookmarks/head.html' %}
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style>
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>
<body ld-global-shortcuts> <body ld-global-shortcuts>
<div class="d-none"> <div class="d-none">

View file

@ -1,9 +1,9 @@
{% load shared %} {% load shared %}
<ul class="pagination"> <ul class="pagination">
{% if page.has_previous %} {% if prev_link %}
<li class="page-item"> <li class="page-item">
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a> <a href="?{{ prev_link }}" tabindex="-1">Previous</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@ -11,10 +11,10 @@
</li> </li>
{% endif %} {% endif %}
{% for page_number in visible_page_numbers %} {% for page_link in page_links %}
{% if page_number >= 0 %} {% if page_link %}
<li class="page-item {% if page.number == page_number %}active{% endif %}"> <li class="page-item {% if page_link.active %}active{% endif %}">
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a> <a href="?{{ page_link.link }}">{{ page_link.number }}</a>
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
@ -23,9 +23,9 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if page.has_next %} {% if next_link %}
<li class="page-item"> <li class="page-item">
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a> <a href="?{{ next_link }}" tabindex="-1">Next</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View file

@ -1,9 +1,14 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<div class="search-container"> <div ld-search-autocomplete class="search-container">
<form id="search" action="" method="get" role="search"> <form id="search" action="" method="get" role="search">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags" <input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ search.q }}"> value="{{ search.q }}"
data-link-target="{{ request.user_profile.bookmark_link_target }}"
data-mode="{{ mode }}"
data-user="{{ search.user }}"
data-shared="{{ search.shared }}"
data-unread="{{ search.unread }}">
<input type="submit" value="Search" class="d-none"> <input type="submit" value="Search" class="d-none">
{% for hidden_field in search_form.hidden_fields %} {% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }} {{ hidden_field }}
@ -73,42 +78,3 @@
</div> </div>
</div> </div>
</div> </div>
{# Replace search input with auto-complete component #}
<script type="application/javascript">
(function init() {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const search = {
q: '{{ search.q }}',
user: '{{ search.user }}',
shared: '{{ search.shared }}',
unread: '{{ search.unread }}',
}
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const input = document.querySelector('#search input[name="q"]')
const container = document.createElement('div')
new linkding.SearchAutoComplete({
target: container,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: input.value,
tags: uniqueTags,
mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
apiClient,
search,
}
})
const autoComplete = container.firstElementChild;
input.replaceWith(autoComplete);
document.addEventListener("turbo:before-cache", () => {
autoComplete.replaceWith(input);
}, {once: true});
})();
</script>

View file

@ -11,21 +11,17 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %} {% bookmark_search bookmark_list.search mode='shared' %}
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click" <button ld-tag-modal class="btn ml-2 show-md">Tags
class="btn ml-2 show-md">Tags
</button> </button>
</div> </div>
</div> </div>
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud" <form class="bookmark-actions"
class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list" <div id="bookmark-list-container">
ld-fire="refresh-bookmark-list-done"
class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
</div> </div>
</form> </form>
@ -43,10 +39,16 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud" <div id="tag-cloud-container">
class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -6,17 +6,10 @@
{% endfor %} {% endfor %}
<div class="form-group"> <div class="form-group">
<div class="d-flex"> <div class="d-flex">
{{ form.user|add_class:"form-select" }} {% render_field form.user class+="form-select" ld-auto-submit="" %}
<noscript> <noscript>
<button type="submit" class="btn btn-link ml-2">Apply</button> <button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript> </noscript>
</div> </div>
</div> </div>
</form> </form>
<script>
const form = document.getElementById('user-select');
const select = form.querySelector('select');
select.addEventListener('change', () => {
form.submit();
});
</script>

View file

@ -27,7 +27,7 @@
<li>After saving the bookmark the linkding window closes and you are back on your website</li> <li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul> </ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p> <p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" <a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a> class="btn btn-primary">📎 Add bookmark</a>
</section> </section>
@ -43,7 +43,7 @@
<strong>Please treat this token as you would any other credential.</strong> <strong>Please treat this token as you would any other credential.</strong>
Any party with access to this token can access and manage all your bookmarks. Any party with access to this token can access and manage all your bookmarks.
If you think that a token was compromised you can revoke (delete) it in the <a If you think that a token was compromised you can revoke (delete) it in the <a
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. target="_blank" href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page. After deleting the token, a new one will be generated when you reload this settings page.
</p> </p>
</section> </section>

View file

@ -6,8 +6,6 @@ from bookmarks.models import (
BookmarkForm, BookmarkForm,
BookmarkSearch, BookmarkSearch,
BookmarkSearchForm, BookmarkSearchForm,
Tag,
build_tag_string,
User, User,
) )
@ -34,9 +32,7 @@ def bookmark_form(
@register.inclusion_tag( @register.inclusion_tag(
"bookmarks/search.html", name="bookmark_search", takes_context=True "bookmarks/search.html", name="bookmark_search", takes_context=True
) )
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""): def bookmark_search(context, search: BookmarkSearch, mode: str = ""):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, " ")
search_form = BookmarkSearchForm(search, editable_fields=["q"]) search_form = BookmarkSearchForm(search, editable_fields=["q"])
if mode == "shared": if mode == "shared":
@ -50,7 +46,6 @@ def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""
"search": search, "search": search,
"search_form": search_form, "search_form": search_form,
"preferences_form": preferences_form, "preferences_form": preferences_form,
"tags_string": tags_string,
"mode": mode, "mode": mode,
} }

View file

@ -2,6 +2,7 @@ from functools import reduce
from django import template from django import template
from django.core.paginator import Page from django.core.paginator import Page
from django.http import QueryDict
NUM_ADJACENT_PAGES = 2 NUM_ADJACENT_PAGES = 2
@ -12,11 +13,44 @@ register = template.Library()
"bookmarks/pagination.html", name="pagination", takes_context=True "bookmarks/pagination.html", name="pagination", takes_context=True
) )
def pagination(context, page: Page): def pagination(context, page: Page):
# remove page number and details from query parameters
query_params = context["request"].GET.copy()
query_params.pop("page", None)
query_params.pop("details", None)
prev_link = (
_generate_link(query_params, page.previous_page_number())
if page.has_previous()
else None
)
next_link = (
_generate_link(query_params, page.next_page_number())
if page.has_next()
else None
)
visible_page_numbers = get_visible_page_numbers( visible_page_numbers = get_visible_page_numbers(
page.number, page.paginator.num_pages page.number, page.paginator.num_pages
) )
page_links = []
for page_number in visible_page_numbers:
if page_number == -1:
page_links.append(None)
else:
link = _generate_link(query_params, page_number)
page_links.append(
{
"active": page_number == page.number,
"number": page_number,
"link": link,
}
)
return {"page": page, "visible_page_numbers": visible_page_numbers} return {
"prev_link": prev_link,
"next_link": next_link,
"page_links": page_links,
}
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]: def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
@ -56,3 +90,8 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
return result return result
return reduce(append_page, visible_pages, []) return reduce(append_page, visible_pages, [])
def _generate_link(query_params: QueryDict, page_number: int) -> str:
query_params["page"] = page_number
return query_params.urlencode()

View file

@ -28,12 +28,13 @@ def add_tag_to_query(context, tag_name: str):
params = context.request.GET.copy() params = context.request.GET.copy()
# Append to or create query string # Append to or create query string
if params.__contains__("q"): query_string = params.get("q", "")
query_string = params.__getitem__("q") + " " query_string = (query_string + " #" + tag_name).strip()
else: params.setlist("q", [query_string])
query_string = ""
query_string = query_string + "#" + tag_name # Remove details ID and page number
params.__setitem__("q", query_string) params.pop("details", None)
params.pop("page", None)
return params.urlencode() return params.urlencode()
@ -62,6 +63,10 @@ def remove_tag_from_query(context, tag_name: str):
query_string = " ".join(query_parts) query_string = " ".join(query_parts)
params.__setitem__("q", query_string) params.__setitem__("q", query_string)
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode() return params.urlencode()

View file

@ -2,6 +2,7 @@ import random
import logging import logging
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from unittest import TestCase
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -220,6 +221,75 @@ class HtmlTestMixin:
return BeautifulSoup(html, features="html.parser") return BeautifulSoup(html, features="html.parser")
class BookmarkListTestMixin(TestCase, HtmlTestMixin):
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
class TagCloudTestMixin(TestCase, HtmlTestMixin):
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select_one("p.selected-tags")
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select("a")
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(
tag.name in selected_tags.text,
msg=f"Selected tags do not contain: {tag.name}",
)
class LinkdingApiTestCase(APITestCase): class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK): def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url) response = self.client.get(url)

View file

@ -1,13 +1,24 @@
from unittest.mock import patch
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import model_to_dict from django.forms import model_to_dict
from django.test import TestCase from django.http import HttpResponse
from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.services import tasks, bookmarks
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkActionViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@ -156,6 +167,129 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.shared) self.assertTrue(bookmark.shared)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot(self):
bookmark = self.setup_bookmark()
with patch.object(tasks, "_create_html_snapshot_task"):
self.client.post(
reverse("bookmarks:index.action"),
{
"create_html_snapshot": [bookmark.id],
},
)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
asset = bookmark.bookmarkasset_set.first()
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_can_only_create_html_snapshot_for_own_bookmarks(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
with patch.object(tasks, "_create_html_snapshot_task"):
response = self.client.post(
reverse("bookmarks:index.action"),
{
"create_html_snapshot": [bookmark.id],
},
)
self.assertEqual(response.status_code, 404)
self.assertEqual(bookmark.bookmarkasset_set.count(), 0)
def test_upload_asset(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 302)
mock_upload_asset.assert_called_once()
args, _ = mock_upload_asset.call_args
self.assertEqual(args[0], bookmark)
upload_file = args[1]
self.assertEqual(upload_file.name, "test.txt")
def test_can_only_upload_asset_for_own_bookmarks(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 404)
mock_upload_asset.assert_not_called()
def test_remove_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
response = self.client.post(
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
def test_can_only_remove_own_asset(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark)
response = self.client.post(
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
def test_update_state(self):
bookmark = self.setup_bookmark()
response = self.client.post(
reverse("bookmarks:index.action"),
{
"update_state": bookmark.id,
"is_archived": "on",
"unread": "on",
"shared": "on",
},
)
self.assertEqual(response.status_code, 302)
bookmark.refresh_from_db()
self.assertTrue(bookmark.unread)
self.assertTrue(bookmark.is_archived)
self.assertTrue(bookmark.shared)
def test_can_only_update_own_bookmark_state(self):
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(
reverse("bookmarks:index.action"),
{
"update_state": bookmark.id,
"is_archived": "on",
"unread": "on",
"shared": "on",
},
)
self.assertEqual(response.status_code, 404)
bookmark.refresh_from_db()
self.assertFalse(bookmark.unread)
self.assertFalse(bookmark.is_archived)
self.assertFalse(bookmark.shared)
def test_bulk_archive(self): def test_bulk_archive(self):
bookmark1 = self.setup_bookmark() bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
@ -791,58 +925,119 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3]) self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_should_redirect_to_return_url(self): def test_index_action_redirects_to_index_with_query_params(self):
bookmark1 = self.setup_bookmark() url = reverse("bookmarks:index.action") + "?q=foo&page=2"
bookmark2 = self.setup_bookmark() redirect_url = reverse("bookmarks:index") + "?q=foo&page=2"
bookmark3 = self.setup_bookmark() response = self.client.post(url)
url = ( self.assertRedirects(response, redirect_url)
reverse("bookmarks:index.action")
+ "?return_url="
+ reverse("bookmarks:settings.index")
)
response = self.client.post(
url,
{
"bulk_action": ["bulk_archive"],
"bulk_execute": [""],
"bookmark_id": [
str(bookmark1.id),
str(bookmark2.id),
str(bookmark3.id),
],
},
)
self.assertRedirects(response, reverse("bookmarks:settings.index")) 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)
def test_should_not_redirect_to_external_url(self): self.assertRedirects(response, redirect_url)
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
def post_with(return_url, follow=None): def test_shared_action_redirects_to_shared_with_query_params(self):
url = reverse("bookmarks:index.action") + f"?return_url={return_url}" url = reverse("bookmarks:shared.action") + "?q=foo&page=2"
return self.client.post( redirect_url = reverse("bookmarks:shared") + "?q=foo&page=2"
url, response = self.client.post(url)
{
"bulk_action": ["bulk_archive"], self.assertRedirects(response, redirect_url)
"bulk_execute": [""],
"bookmark_id": [ def bookmark_update_fixture(self):
str(bookmark1.id), user = self.get_or_create_test_user()
str(bookmark2.id), profile = user.profile
str(bookmark3.id), profile.enable_sharing = True
], profile.save()
},
follow=follow, 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']"
) )
)
response = post_with("https://example.com") # tag cloud update
self.assertRedirects(response, reverse("bookmarks:index")) self.assertIsNotNone(
response = post_with("//example.com") soup.select_one(
self.assertRedirects(response, reverse("bookmarks:index")) "turbo-stream[action='update'][target='tag-cloud-container']"
response = post_with("://example.com") )
self.assertRedirects(response, reverse("bookmarks:index")) )
response = post_with("/foo//example.com", follow=True) # update event
self.assertEqual(response.status_code, 404) 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(
reverse("bookmarks:index.action"),
HTTP_ACCEPT="text/vnd.turbo-stream.html",
)
visible_tags = self.get_tags_from_bookmarks(
fixture["active"] + fixture["shared"]
)
invisible_tags = self.get_tags_from_bookmarks(fixture["archived"])
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 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",
)
visible_tags = self.get_tags_from_bookmarks(fixture["archived"])
invisible_tags = self.get_tags_from_bookmarks(
fixture["active"] + fixture["shared"]
)
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)

View file

@ -1,89 +1,26 @@
import urllib.parse import urllib.parse
from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import ( from bookmarks.tests.helpers import (
BookmarkFactoryMixin, BookmarkFactoryMixin,
HtmlTestMixin, BookmarkListTestMixin,
TagCloudTestMixin,
collapse_whitespace, collapse_whitespace,
) )
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkArchivedViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select_one("p.selected-tags")
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select("a")
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(
tag.name in selected_tags.text,
msg=f"Selected tags do not contain: {tag.name}",
)
def assertEditLink(self, response, url): def assertEditLink(self, response, url):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@ -307,24 +244,21 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
base_url = reverse("bookmarks:archived") base_url = reverse("bookmarks:archived")
# without params # without params
return_url = urllib.parse.quote_plus(base_url) url = f"{action_url}"
url = f"{action_url}?return_url={return_url}"
response = self.client.get(base_url) response = self.client.get(base_url)
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
# with query # with query
url_params = "?q=foo" url_params = "?q=foo"
return_url = urllib.parse.quote_plus(base_url + url_params) url = f"{action_url}?q=foo"
url = f"{action_url}?q=foo&return_url={return_url}"
response = self.client.get(base_url + url_params) response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
# with query and sort # with query and sort
url_params = "?q=foo&sort=title_asc" url_params = "?q=foo&sort=title_asc"
return_url = urllib.parse.quote_plus(base_url + url_params) url = f"{action_url}?q=foo&sort=title_asc"
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
response = self.client.get(base_url + url_params) response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
@ -527,7 +461,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual( self.assertEqual(
actions_form.attrs["action"], actions_form.attrs["action"],
"/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo", "/bookmarks/archived/action?q=%23foo",
) )
def test_encode_search_params(self): def test_encode_search_params(self):
@ -557,3 +491,15 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)" url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:archived") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))

View file

@ -1,14 +1,11 @@
import datetime import datetime
import re import re
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import formats, timezone from django.utils import formats, timezone
from bookmarks.models import BookmarkAsset, UserProfile from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import bookmarks, tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@ -17,23 +14,23 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def get_view_name(self):
return "bookmarks:details_modal"
def get_base_url(self, bookmark):
return reverse(self.get_view_name(), args=[bookmark.id])
def get_details_form(self, soup, bookmark): def get_details_form(self, soup, bookmark):
expected_url = reverse("bookmarks:details", args=[bookmark.id]) form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}"
return soup.find("form", {"action": expected_url}) return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"})
def get_details(self, bookmark, return_url=""): def get_index_details_modal(self, bookmark):
url = self.get_base_url(bookmark) url = reverse("bookmarks:index") + f"?details={bookmark.id}"
if return_url:
url += f"?return_url={return_url}"
response = self.client.get(url) response = self.client.get(url)
soup = self.make_soup(response.content) soup = self.make_soup(response.content)
return soup modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def get_shared_details_modal(self, bookmark):
url = reverse("bookmarks:shared") + f"?details={bookmark.id}"
response = self.client.get(url)
soup = self.make_soup(response.content)
modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def find_section(self, soup, section_name): def find_section(self, soup, section_name):
dt = soup.find("dt", string=section_name) dt = soup.find("dt", string=section_name)
@ -54,35 +51,68 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_asset(self, soup, asset): def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id}) return soup.find("div", {"data-asset-id": asset.id})
def details_route_access_test(self, view_name: str, shareable: bool): def details_route_access_test(self):
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
response = self.client.get(
response = self.client.get(reverse(view_name, args=[bookmark.id])) reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# other user's bookmark # other user's bookmark
other_user = self.setup_user() other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(
response = self.client.get(reverse(view_name, args=[bookmark.id])) reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# non-existent bookmark # non-existent bookmark - just returns without modal in response
response = self.client.get(reverse(view_name, args=[9999])) response = self.client.get(reverse("bookmarks:index") + "?details=9999")
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 200)
# guest user # guest user
self.client.logout() self.client.logout()
response = self.client.get(reverse(view_name, args=[bookmark.id])) response = self.client.get(
self.assertEqual(response.status_code, 404 if shareable else 302) reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
def details_route_sharing_access_test(self, view_name: str, shareable: bool): def test_access(self):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(
reverse("bookmarks:index") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
# non-existent bookmark - just returns without modal in response
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
self.assertEqual(response.status_code, 200)
# guest user
self.client.logout()
response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
def test_access_with_sharing(self):
# shared bookmark, sharing disabled # shared bookmark, sharing disabled
other_user = self.setup_user() other_user = self.setup_user()
bookmark = self.setup_bookmark(shared=True, user=other_user) bookmark = self.setup_bookmark(shared=True, user=other_user)
response = self.client.get(reverse(view_name, args=[bookmark.id])) response = self.client.get(
reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared bookmark, sharing enabled # shared bookmark, sharing enabled
@ -90,37 +120,31 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.enable_sharing = True profile.enable_sharing = True
profile.save() profile.save()
response = self.client.get(reverse(view_name, args=[bookmark.id])) response = self.client.get(
self.assertEqual(response.status_code, 200 if shareable else 404) reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 200)
# shared bookmark, guest user, no public sharing # shared bookmark, guest user, no public sharing
self.client.logout() self.client.logout()
response = self.client.get(reverse(view_name, args=[bookmark.id])) response = self.client.get(
self.assertEqual(response.status_code, 404 if shareable else 302) reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
self.assertEqual(response.status_code, 404)
# shared bookmark, guest user, public sharing # shared bookmark, guest user, public sharing
profile.enable_public_sharing = True profile.enable_public_sharing = True
profile.save() profile.save()
response = self.client.get(reverse(view_name, args=[bookmark.id])) response = self.client.get(
self.assertEqual(response.status_code, 200 if shareable else 302) reverse("bookmarks:shared") + f"?details={bookmark.id}"
)
def test_access(self): self.assertEqual(response.status_code, 200)
self.details_route_access_test(self.get_view_name(), True)
def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True)
def test_assets_access(self):
self.details_route_access_test("bookmarks:details_assets", True)
def test_assets_access_with_sharing(self):
self.details_route_sharing_access_test("bookmarks:details_assets", True)
def test_displays_title(self): def test_displays_title(self):
# with title # with title
bookmark = self.setup_bookmark(title="Test title") bookmark = self.setup_bookmark(title="Test title")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
title = soup.find("h2") title = soup.find("h2")
self.assertIsNotNone(title) self.assertIsNotNone(title)
@ -128,7 +152,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with website title # with website title
bookmark = self.setup_bookmark(title="", website_title="Website title") bookmark = self.setup_bookmark(title="", website_title="Website title")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
title = soup.find("h2") title = soup.find("h2")
self.assertIsNotNone(title) self.assertIsNotNone(title)
@ -136,7 +160,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with URL only # with URL only
bookmark = self.setup_bookmark(title="", website_title="") bookmark = self.setup_bookmark(title="", website_title="")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
title = soup.find("h2") title = soup.find("h2")
self.assertIsNotNone(title) self.assertIsNotNone(title)
@ -145,7 +169,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_website_link(self): def test_website_link(self):
# basics # basics
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url) link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.url) self.assertEqual(link["href"], bookmark.url)
@ -153,7 +177,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# favicons disabled # favicons disabled
bookmark = self.setup_bookmark(favicon_file="example.png") bookmark = self.setup_bookmark(favicon_file="example.png")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url) link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img") image = link.select_one("img")
self.assertIsNone(image) self.assertIsNone(image)
@ -164,14 +188,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file="") bookmark = self.setup_bookmark(favicon_file="")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url) link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img") image = link.select_one("img")
self.assertIsNone(image) self.assertIsNone(image)
# favicons enabled, favicon present # favicons enabled, favicon present
bookmark = self.setup_bookmark(favicon_file="example.png") bookmark = self.setup_bookmark(favicon_file="example.png")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.url) link = self.find_weblink(soup, bookmark.url)
image = link.select_one("img") image = link.select_one("img")
self.assertIsNotNone(image) self.assertIsNotNone(image)
@ -180,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_reader_mode_link(self): def test_reader_mode_link(self):
# no latest snapshot # no latest snapshot
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 2) self.assertEqual(self.count_weblinks(soup), 2)
# snapshot is not complete # snapshot is not complete
@ -194,7 +218,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_type=BookmarkAsset.TYPE_SNAPSHOT, asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_FAILURE, status=BookmarkAsset.STATUS_FAILURE,
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 2) self.assertEqual(self.count_weblinks(soup), 2)
# not a snapshot # not a snapshot
@ -203,7 +227,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_type="upload", asset_type="upload",
status=BookmarkAsset.STATUS_COMPLETE, status=BookmarkAsset.STATUS_COMPLETE,
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 2) self.assertEqual(self.count_weblinks(soup), 2)
# snapshot is complete # snapshot is complete
@ -212,7 +236,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_type=BookmarkAsset.TYPE_SNAPSHOT, asset_type=BookmarkAsset.TYPE_SNAPSHOT,
status=BookmarkAsset.STATUS_COMPLETE, status=BookmarkAsset.STATUS_COMPLETE,
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
self.assertEqual(self.count_weblinks(soup), 3) self.assertEqual(self.count_weblinks(soup), 3)
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id]) reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
@ -221,7 +245,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_internet_archive_link_with_snapshot_url(self): def test_internet_archive_link_with_snapshot_url(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/") bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url) self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
@ -231,7 +255,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="example.png" web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg") image = link.select_one("svg")
self.assertIsNone(image) self.assertIsNone(image)
@ -244,7 +268,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="" web_archive_snapshot_url="https://example.com/", favicon_file=""
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg") image = link.select_one("svg")
self.assertIsNone(image) self.assertIsNone(image)
@ -253,7 +277,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://example.com/", favicon_file="example.png" web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
image = link.select_one("svg") image = link.select_one("svg")
self.assertIsNotNone(image) self.assertIsNotNone(image)
@ -267,7 +291,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
"https://web.archive.org/web/20230811214511/https://example.com/" "https://web.archive.org/web/20230811214511/https://example.com/"
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
link = self.find_weblink(soup, fallback_web_archive_url) link = self.find_weblink(soup, fallback_web_archive_url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
self.assertEqual(link["href"], fallback_web_archive_url) self.assertEqual(link["href"], fallback_web_archive_url)
@ -281,7 +305,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
profile.save() profile.save()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
website_link = self.find_weblink(soup, bookmark.url) website_link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(website_link) self.assertIsNotNone(website_link)
@ -297,7 +321,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save() profile.save()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
website_link = self.find_weblink(soup, bookmark.url) website_link = self.find_weblink(soup, bookmark.url)
self.assertIsNotNone(website_link) self.assertIsNotNone(website_link)
@ -312,13 +336,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_preview_image(self): def test_preview_image(self):
# without image # without image
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img") image = soup.select_one("div.preview-image img")
self.assertIsNone(image) self.assertIsNone(image)
# with image # with image
bookmark = self.setup_bookmark(preview_image_file="example.png") bookmark = self.setup_bookmark(preview_image_file="example.png")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img") image = soup.select_one("div.preview-image img")
self.assertIsNone(image) self.assertIsNone(image)
@ -328,13 +352,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img") image = soup.select_one("div.preview-image img")
self.assertIsNone(image) self.assertIsNone(image)
# preview images enabled, image present # preview images enabled, image present
bookmark = self.setup_bookmark(preview_image_file="example.png") bookmark = self.setup_bookmark(preview_image_file="example.png")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
image = soup.select_one("div.preview-image img") image = soup.select_one("div.preview-image img")
self.assertIsNotNone(image) self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png") self.assertEqual(image["src"], "/static/example.png")
@ -342,18 +366,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_status(self): def test_status(self):
# renders form # renders form
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
form = self.get_details_form(soup, bookmark) form = self.get_details_form(soup, bookmark)
self.assertIsNotNone(form) self.assertIsNotNone(form)
self.assertEqual(
form["action"], reverse("bookmarks:details", args=[bookmark.id])
)
self.assertEqual(form["method"], "post") self.assertEqual(form["method"], "post")
# sharing disabled # sharing disabled
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -369,7 +390,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -381,7 +402,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked # unchecked
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -393,7 +414,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked # checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True) bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status") section = self.get_section(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
@ -406,106 +427,29 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_status_visibility(self): def test_status_visibility(self):
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section(soup, "Status")
self.assertIsNotNone(section) self.assertIsNotNone(section)
# other user's bookmark # other user's bookmark
other_user = self.setup_user(enable_sharing=True) other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark) soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section(soup, "Status")
self.assertIsNone(section) self.assertIsNone(section)
# guest user # guest user
self.client.logout() self.client.logout()
other_user.profile.enable_public_sharing = True
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark) soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section(soup, "Status")
self.assertIsNone(section) self.assertIsNone(section)
def test_status_update(self):
bookmark = self.setup_bookmark()
# update status
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 302)
bookmark.refresh_from_db()
self.assertTrue(bookmark.is_archived)
self.assertTrue(bookmark.unread)
self.assertTrue(bookmark.shared)
# update individual status
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "", "unread": "on", "shared": ""},
)
self.assertEqual(response.status_code, 302)
bookmark.refresh_from_db()
self.assertFalse(bookmark.is_archived)
self.assertTrue(bookmark.unread)
self.assertFalse(bookmark.shared)
def test_status_update_access(self):
# no sharing
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
profile = other_user.profile
profile.enable_sharing = True
profile.save()
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# shared, public sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
profile = other_user.profile
profile.enable_public_sharing = True
profile.save()
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(
self.get_base_url(bookmark),
{"is_archived": "on", "unread": "on", "shared": "on"},
)
self.assertEqual(response.status_code, 404)
def test_date_added(self): def test_date_added(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Date added") section = self.get_section(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT") expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
@ -515,7 +459,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_tags(self): def test_tags(self):
# without tags # without tags
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags") section = self.find_section(soup, "Tags")
self.assertIsNone(section) self.assertIsNone(section)
@ -523,7 +467,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with tags # with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()]) bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Tags") section = self.get_section(soup, "Tags")
for tag in bookmark.tags.all(): for tag in bookmark.tags.all():
@ -535,14 +479,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_description(self): def test_description(self):
# without description # without description
bookmark = self.setup_bookmark(description="", website_description="") bookmark = self.setup_bookmark(description="", website_description="")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description") section = self.find_section(soup, "Description")
self.assertIsNone(section) self.assertIsNone(section)
# with description # with description
bookmark = self.setup_bookmark(description="Test description") bookmark = self.setup_bookmark(description="Test description")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description") section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description) self.assertEqual(section.text.strip(), bookmark.description)
@ -551,7 +495,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(
description="", website_description="Website description" description="", website_description="Website description"
) )
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description") section = self.get_section(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.website_description) self.assertEqual(section.text.strip(), bookmark.website_description)
@ -559,14 +503,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_notes(self): def test_notes(self):
# without notes # without notes
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes") section = self.find_section(soup, "Notes")
self.assertIsNone(section) self.assertIsNone(section)
# with notes # with notes
bookmark = self.setup_bookmark(notes="Test notes") bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Notes") section = self.get_section(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>") self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
@ -575,52 +519,42 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
# with default return URL # with default return URL
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
edit_link = soup.find("a", string="Edit") edit_link = soup.find("a", string="Edit")
self.assertIsNotNone(edit_link) self.assertIsNotNone(edit_link)
details_url = reverse("bookmarks:details", args=[bookmark.id]) details_url = reverse("bookmarks:index") + f"?details={bookmark.id}"
expected_url = ( expected_url = "/bookmarks/1/edit?return_url=/bookmarks%3Fdetails%3D1"
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url self.assertEqual(expected_url, edit_link["href"])
)
self.assertEqual(edit_link["href"], expected_url)
# with custom return URL
soup = self.get_details(bookmark, return_url="/custom")
edit_link = soup.find("a", string="Edit")
self.assertIsNotNone(edit_link)
expected_url = (
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
)
self.assertEqual(edit_link["href"], expected_url)
def test_delete_button(self): def test_delete_button(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
# basics modal = self.get_index_details_modal(bookmark)
soup = self.get_details(bookmark) delete_button = modal.find("button", {"type": "submit", "name": "remove"})
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(delete_button) self.assertIsNotNone(delete_button)
self.assertEqual(delete_button.text.strip(), "Delete...") self.assertEqual("Delete...", delete_button.text.strip())
self.assertEqual(delete_button["value"], str(bookmark.id)) self.assertEqual(str(bookmark.id), delete_button["value"])
form = delete_button.find_parent("form") form = delete_button.find_parent("form")
self.assertIsNotNone(form) self.assertIsNotNone(form)
expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks" expected_url = reverse("bookmarks:index.action")
self.assertEqual(form["action"], expected_url) self.assertEqual(expected_url, form["action"])
# with custom return URL
soup = self.get_details(bookmark, return_url="/custom")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
form = delete_button.find_parent("form")
expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
self.assertEqual(form["action"], expected_url)
def test_actions_visibility(self): def test_actions_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(edit_link)
self.assertIsNotNone(delete_button)
# with sharing # with sharing
other_user = self.setup_user(enable_sharing=True) other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark) soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit") edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"}) delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link) self.assertIsNone(edit_link)
@ -632,7 +566,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.save() profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark) soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit") edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"}) delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link) self.assertIsNone(edit_link)
@ -642,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.client.logout() self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark) soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit") edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"}) delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link) self.assertIsNone(edit_link)
@ -651,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_assets_visibility_no_snapshot_support(self): def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files") section = self.find_section(soup, "Files")
self.assertIsNone(section) self.assertIsNone(section)
@ -659,7 +593,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def test_assets_visibility_with_snapshot_support(self): def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files") section = self.find_section(soup, "Files")
self.assertIsNotNone(section) self.assertIsNotNone(section)
@ -668,7 +602,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no assets # no assets
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files") section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"}) asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list) self.assertIsNone(asset_list)
@ -677,7 +611,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.setup_asset(bookmark) self.setup_asset(bookmark)
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files") section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"}) asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list) self.assertIsNotNone(asset_list)
@ -691,7 +625,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark), self.setup_asset(bookmark),
] ]
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files") section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"}) asset_list = section.find("div", {"class": "assets"})
@ -717,7 +651,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.file = "" asset.file = ""
asset.save() asset.save()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset) asset_item = self.find_asset(soup, asset)
view_url = reverse("bookmarks:assets.view", args=[asset.id]) view_url = reverse("bookmarks:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url}) view_link = asset_item.find("a", {"href": view_url})
@ -729,7 +663,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING) pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE) failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, pending_asset) asset_item = self.find_asset(soup, pending_asset)
asset_text = asset_item.select_one(".asset-text span") asset_text = asset_item.select_one(".asset-text span")
@ -746,7 +680,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset2 = self.setup_asset(bookmark, file_size=54639) asset2 = self.setup_asset(bookmark, file_size=54639)
asset3 = self.setup_asset(bookmark, file_size=11492020) asset3 = self.setup_asset(bookmark, file_size=11492020)
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset1) asset_item = self.find_asset(soup, asset1)
asset_text = asset_item.select_one(".asset-text") asset_text = asset_item.select_one(".asset-text")
@ -766,7 +700,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# with file # with file
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset) asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View") view_link = asset_item.find("a", string="View")
@ -779,7 +713,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# without file # without file
asset.file = "" asset.file = ""
asset.save() asset.save()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset) asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View") view_link = asset_item.find("a", string="View")
@ -793,7 +727,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True) other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
bookmark = self.setup_bookmark(shared=True, user=other_user) bookmark = self.setup_bookmark(shared=True, user=other_user)
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset) asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View") view_link = asset_item.find("a", string="View")
@ -805,7 +739,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# shared bookmark, guest user # shared bookmark, guest user
self.client.logout() self.client.logout()
soup = self.get_details(bookmark) soup = self.get_shared_details_modal(bookmark)
asset_item = self.find_asset(soup, asset) asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View") view_link = asset_item.find("a", string="View")
@ -815,77 +749,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(view_link) self.assertIsNotNone(view_link)
self.assertIsNone(delete_button) self.assertIsNone(delete_button)
def test_remove_asset(self):
# remove asset
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
# non-existent asset
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
self.assertEqual(response.status_code, 404)
# post without asset ID does not remove
asset = self.setup_asset(bookmark)
response = self.client.post(self.get_base_url(bookmark))
self.assertEqual(response.status_code, 302)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
# guest user
asset = self.setup_asset(bookmark)
self.client.logout()
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_refresh_when_having_pending_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
fetch_url = reverse("bookmarks:details_assets", args=[bookmark.id])
# no pending asset
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNone(assets_wrapper)
# with pending asset
asset.status = BookmarkAsset.STATUS_PENDING
asset.save()
soup = self.get_details(bookmark)
files_section = self.find_section(soup, "Files")
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
self.assertIsNotNone(assets_wrapper)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self):
with patch.object(
tasks, "_create_html_snapshot_task"
) as mock_create_html_snapshot_task:
bookmark = self.setup_bookmark()
response = self.client.post(
self.get_base_url(bookmark), {"create_snapshot": ""}
)
self.assertEqual(response.status_code, 302)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot_is_disabled_when_having_pending_asset(self): def test_create_snapshot_is_disabled_when_having_pending_asset(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE) asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
# no pending asset # no pending asset
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files") files_section = self.find_section(soup, "Files")
create_button = files_section.find( create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
@ -896,40 +766,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.status = BookmarkAsset.STATUS_PENDING asset.status = BookmarkAsset.STATUS_PENDING
asset.save() asset.save()
soup = self.get_details(bookmark) soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files") files_section = self.find_section(soup, "Files")
create_button = files_section.find( create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
) )
self.assertTrue(create_button.has_attr("disabled")) self.assertTrue(create_button.has_attr("disabled"))
def test_upload_file(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": "", "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 302)
mock_upload_asset.assert_called_once()
args, kwargs = mock_upload_asset.call_args
self.assertEqual(args[0], bookmark)
upload_file = args[1]
self.assertEqual(upload_file.name, "test.txt")
def test_upload_file_without_file(self):
bookmark = self.setup_bookmark()
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
response = self.client.post(
self.get_base_url(bookmark),
{"upload_asset": ""},
)
self.assertEqual(response.status_code, 400)
mock_upload_asset.assert_not_called()

View file

@ -1,6 +0,0 @@
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
def get_view_name(self):
return "bookmarks:details"

View file

@ -1,85 +1,24 @@
import urllib.parse import urllib.parse
from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkIndexViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select_one("p.selected-tags")
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select("a")
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(
tag.name in selected_tags.text,
msg=f"Selected tags do not contain: {tag.name}",
)
def assertEditLink(self, response, url): def assertEditLink(self, response, url):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
@ -285,24 +224,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
base_url = reverse("bookmarks:index") base_url = reverse("bookmarks:index")
# without params # without params
return_url = urllib.parse.quote_plus(base_url) url = f"{action_url}"
url = f"{action_url}?return_url={return_url}"
response = self.client.get(base_url) response = self.client.get(base_url)
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
# with query # with query
url_params = "?q=foo" url_params = "?q=foo"
return_url = urllib.parse.quote_plus(base_url + url_params) url = f"{action_url}?q=foo"
url = f"{action_url}?q=foo&return_url={return_url}"
response = self.client.get(base_url + url_params) response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
# with query and sort # with query and sort
url_params = "?q=foo&sort=title_asc" url_params = "?q=foo&sort=title_asc"
return_url = urllib.parse.quote_plus(base_url + url_params) url = f"{action_url}?q=foo&sort=title_asc"
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
response = self.client.get(base_url + url_params) response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url) self.assertBulkActionForm(response, url)
@ -503,7 +439,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual( self.assertEqual(
actions_form.attrs["action"], actions_form.attrs["action"],
"/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo", "/bookmarks/action?q=%23foo",
) )
def test_encode_search_params(self): def test_encode_search_params(self):
@ -533,3 +469,15 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)" url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:index") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))

View file

@ -1,16 +1,13 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.db.models import QuerySet
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkSearch, Tag from bookmarks.models import BookmarkSearch
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template( def render_template(self, url: str, mode: str = ""):
self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ""
):
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = self.get_or_create_test_user() request.user = self.get_or_create_test_user()
@ -21,32 +18,31 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{ {
"request": request, "request": request,
"search": search, "search": search,
"tags": tags,
"mode": mode, "mode": mode,
}, },
) )
template_to_render = Template( template_to_render = Template(
"{% load bookmarks %}" "{% bookmark_search search tags mode %}" "{% load bookmarks %} {% bookmark_search search mode %}"
) )
return template_to_render.render(context) return template_to_render.render(context)
def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None): def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None):
input = form.select_one(f'input[name="{name}"][type="hidden"]') element = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNotNone(input) self.assertIsNotNone(element)
if value is not None: if value is not None:
self.assertEqual(input["value"], value) self.assertEqual(element["value"], value)
def assertNoHiddenInput(self, form: BeautifulSoup, name: str): def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
input = form.select_one(f'input[name="{name}"][type="hidden"]') element = form.select_one(f'input[name="{name}"][type="hidden"]')
self.assertIsNone(input) self.assertIsNone(element)
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None): def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
input = form.select_one(f'input[name="{name}"][type="search"]') element = form.select_one(f'input[name="{name}"][type="search"]')
self.assertIsNotNone(input) self.assertIsNotNone(element)
if value is not None: if value is not None:
self.assertEqual(input["value"], value) self.assertEqual(element["value"], value)
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None): def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
select = form.select_one(f'select[name="{name}"]') select = form.select_one(f'select[name="{name}"]')

View file

@ -6,11 +6,16 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkSharedViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def authenticate(self) -> None: def authenticate(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
@ -24,57 +29,6 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
count=count, count=count,
) )
def assertVisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
bookmark_list = soup.select_one(
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
)
self.assertIsNotNone(bookmark_list)
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
self.assertEqual(len(bookmark_items), len(bookmarks))
for bookmark in bookmarks:
bookmark_item = bookmark_list.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNotNone(bookmark_item)
def assertInvisibleBookmarks(
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
):
soup = self.make_soup(response.content.decode())
for bookmark in bookmarks:
bookmark_item = soup.select_one(
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
)
self.assertIsNone(bookmark_item)
def assertVisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_cloud = soup.select_one("div.tag-cloud")
self.assertIsNotNone(tag_cloud)
tag_items = tag_cloud.select("a[data-is-tag-item]")
self.assertEqual(len(tag_items), len(tags))
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertTrue(tag.name in tag_item_names)
def assertInvisibleTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
tag_items = soup.select("a[data-is-tag-item]")
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
for tag in tags:
self.assertFalse(tag.name in tag_item_names)
def assertVisibleUserOptions(self, response, users: List[User]): def assertVisibleUserOptions(self, response, users: List[User]):
html = response.content.decode() html = response.content.decode()
@ -84,7 +38,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f'<option value="{user.username}">{user.username}</option>' f'<option value="{user.username}">{user.username}</option>'
) )
user_select_html = f""" user_select_html = f"""
<select name="user" class="form-select" required="" id="id_user"> <select name="user" class="form-select" id="id_user" ld-auto-submit>
{''.join(user_options)} {''.join(user_options)}
</select> </select>
""" """
@ -593,7 +547,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual( self.assertEqual(
actions_form.attrs["action"], actions_form.attrs["action"],
"/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo", "/bookmarks/shared/action?q=%23foo",
) )
def test_encode_search_params(self): def test_encode_search_params(self):
@ -627,3 +581,15 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)" url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, "alert('xss')") self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:shared") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))

View file

@ -12,7 +12,7 @@ from django.utils import timezone, formats
from bookmarks.middlewares import LinkdingMiddleware from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import Bookmark, UserProfile, User from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts from bookmarks.views import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@ -51,31 +51,25 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
html, html,
) )
def assertViewLink( def assertViewLink(self, html: str, bookmark: Bookmark, base_url=None):
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index") self.assertViewLinkCount(html, bookmark, base_url)
):
self.assertViewLinkCount(html, bookmark, return_url=return_url)
def assertNoViewLink( def assertNoViewLink(self, html: str, bookmark: Bookmark, base_url=None):
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index") self.assertViewLinkCount(html, bookmark, base_url, count=0)
):
self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
def assertViewLinkCount( def assertViewLinkCount(
self, self,
html: str, html: str,
bookmark: Bookmark, bookmark: Bookmark,
base_url: str = None,
count=1, count=1,
return_url=reverse("bookmarks:index"),
): ):
details_url = reverse("bookmarks:details", args=[bookmark.id]) if base_url is None:
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id]) base_url = reverse("bookmarks:index")
details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML( self.assertInHTML(
f""" f"""
<a ld-fetch="{details_modal_url}?return_url={return_url}" <a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
ld-on="click" ld-target="body|append"
data-turbo-prefetch="false"
href="{details_url}">View</a>
""", """,
html, html,
count=count, count=count,
@ -652,7 +646,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext) html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared")) self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
@ -944,7 +938,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertWebArchiveLink( self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank" html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
) )
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared")) self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0) self.assertMarkAsReadButton(html, bookmark, count=0)

View file

@ -172,3 +172,12 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2" rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
) )
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3") self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
def test_removes_details_parameter(self):
rendered_template = self.render_template(
100, 10, 2, url="/test?details=1&page=2"
)
self.assertPrevLink(rendered_template, 1, href="?page=1")
self.assertPageLink(rendered_template, 1, False, href="?page=1")
self.assertPageLink(rendered_template, 2, True, href="?page=2")
self.assertNextLink(rendered_template, 3, href="?page=3")

View file

@ -8,7 +8,7 @@ from django.test import TestCase, RequestFactory
from bookmarks.middlewares import LinkdingMiddleware from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts from bookmarks.views import contexts
class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@ -203,13 +203,28 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
tag = self.setup_tag(name="tag1") tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1") self.setup_bookmark(tags=[tag], title="term1")
rendered_template = self.render_template(url="/test?q=term1&sort=title_asc")
self.assertInHTML(
"""
<a href="?q=term1+%23tag1&sort=title_asc" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span>
</a>
""",
rendered_template,
)
def test_tag_url_removes_page_number_and_details_id(self):
tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1")
rendered_template = self.render_template( rendered_template = self.render_template(
url="/test?q=term1&sort=title_asc&page=2" url="/test?q=term1&sort=title_asc&page=2&details=5"
) )
self.assertInHTML( self.assertInHTML(
""" """
<a href="?q=term1+%23tag1&sort=title_asc&page=2" class="mr-2" data-is-tag-item> <a href="?q=term1+%23tag1&sort=title_asc" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span> <span class="highlight-char">t</span><span>ag1</span>
</a> </a>
""", """,
@ -347,12 +362,30 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(tags=[tag], title="term1", description="term2") self.setup_bookmark(tags=[tag], title="term1", description="term2")
rendered_template = self.render_template( rendered_template = self.render_template(
url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2" url="/test?q=term1 %23tag1 term2&sort=title_asc"
) )
self.assertInHTML( self.assertInHTML(
""" """
<a href="?q=term1+term2&sort=title_asc&page=2" <a href="?q=term1+term2&sort=title_asc"
class="text-bold mr-2">
<span>-tag1</span>
</a>
""",
rendered_template,
)
def test_selected_tag_url_removes_page_number_and_details_id(self):
tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1", description="term2")
rendered_template = self.render_template(
url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2&details=5"
)
self.assertInHTML(
"""
<a href="?q=term1+term2&sort=title_asc"
class="text-bold mr-2"> class="text-bold mr-2">
<span>-tag1</span> <span>-tag1</span>
</a> </a>

View file

@ -9,7 +9,6 @@ from bookmarks.feeds import (
SharedBookmarksFeed, SharedBookmarksFeed,
PublicSharedBookmarksFeed, PublicSharedBookmarksFeed,
) )
from bookmarks.views import partials
app_name = "bookmarks" app_name = "bookmarks"
urlpatterns = [ urlpatterns = [
@ -31,21 +30,6 @@ urlpatterns = [
path("bookmarks/new", views.bookmarks.new, name="new"), path("bookmarks/new", views.bookmarks.new, name="new"),
path("bookmarks/close", views.bookmarks.close, name="close"), path("bookmarks/close", views.bookmarks.close, name="close"),
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"), path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
path(
"bookmarks/<int:bookmark_id>/details",
views.bookmarks.details,
name="details",
),
path(
"bookmarks/<int:bookmark_id>/details_modal",
views.bookmarks.details_modal,
name="details_modal",
),
path(
"bookmarks/<int:bookmark_id>/details_assets",
views.bookmarks.details_assets,
name="details_assets",
),
# Assets # Assets
path( path(
"assets/<int:asset_id>", "assets/<int:asset_id>",
@ -57,52 +41,6 @@ urlpatterns = [
views.assets.read, views.assets.read,
name="assets.read", name="assets.read",
), ),
# Partials
path(
"bookmarks/partials/bookmark-list/active",
partials.active_bookmark_list,
name="partials.bookmark_list.active",
),
path(
"bookmarks/partials/tag-cloud/active",
partials.active_tag_cloud,
name="partials.tag_cloud.active",
),
path(
"bookmarks/partials/tag-modal/active",
partials.active_tag_modal,
name="partials.tag_modal.active",
),
path(
"bookmarks/partials/bookmark-list/archived",
partials.archived_bookmark_list,
name="partials.bookmark_list.archived",
),
path(
"bookmarks/partials/tag-cloud/archived",
partials.archived_tag_cloud,
name="partials.tag_cloud.archived",
),
path(
"bookmarks/partials/tag-modal/archived",
partials.archived_tag_modal,
name="partials.tag_modal.archived",
),
path(
"bookmarks/partials/bookmark-list/shared",
partials.shared_bookmark_list,
name="partials.bookmark_list.shared",
),
path(
"bookmarks/partials/tag-cloud/shared",
partials.shared_tag_cloud,
name="partials.tag_cloud.shared",
),
path(
"bookmarks/partials/tag-modal/shared",
partials.shared_tag_modal,
name="partials.tag_modal.shared",
),
# Settings # Settings
path("settings", views.settings.general, name="settings.index"), path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"), path("settings/general", views.settings.general, name="settings.general"),

View file

@ -1,10 +1,12 @@
import logging import logging
import re import re
import unicodedata import unicodedata
import urllib.parse
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.http import HttpResponseRedirect
from django.template.defaultfilters import pluralize from django.template.defaultfilters import pluralize
from django.utils import timezone, formats from django.utils import timezone, formats
@ -114,6 +116,14 @@ def get_safe_return_url(return_url: str, fallback_url: str):
return return_url return return_url
def redirect_with_query(request, redirect_url):
query_string = urllib.parse.urlencode(request.GET)
if query_string:
redirect_url += "?" + query_string
return HttpResponseRedirect(redirect_url)
def generate_username(email): def generate_username(email):
# taken from mozilla-django-oidc docs :) # taken from mozilla-django-oidc docs :)

View file

@ -11,7 +11,7 @@ from django.http import (
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries, utils
from bookmarks.models import ( from bookmarks.models import (
Bookmark, Bookmark,
BookmarkAsset, BookmarkAsset,
@ -19,6 +19,7 @@ from bookmarks.models import (
BookmarkSearch, BookmarkSearch,
build_tag_string, build_tag_string,
) )
from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.services.bookmarks import ( from bookmarks.services.bookmarks import (
create_bookmark, create_bookmark,
update_bookmark, update_bookmark,
@ -34,9 +35,8 @@ from bookmarks.services.bookmarks import (
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
) )
from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts from bookmarks.views import contexts, partials, turbo
_default_page_size = 30 _default_page_size = 30
@ -48,12 +48,17 @@ def index(request):
bookmark_list = contexts.ActiveBookmarkListContext(request) bookmark_list = contexts.ActiveBookmarkListContext(request)
tag_cloud = contexts.ActiveTagCloudContext(request) tag_cloud = contexts.ActiveTagCloudContext(request)
return render( bookmark_details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
return render_bookmarks_view(
request, request,
"bookmarks/index.html", "bookmarks/index.html",
{ {
"bookmark_list": bookmark_list, "bookmark_list": bookmark_list,
"tag_cloud": tag_cloud, "tag_cloud": tag_cloud,
"details": bookmark_details,
}, },
) )
@ -65,12 +70,17 @@ def archived(request):
bookmark_list = contexts.ArchivedBookmarkListContext(request) bookmark_list = contexts.ArchivedBookmarkListContext(request)
tag_cloud = contexts.ArchivedTagCloudContext(request) tag_cloud = contexts.ArchivedTagCloudContext(request)
return render( bookmark_details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
return render_bookmarks_view(
request, request,
"bookmarks/archive.html", "bookmarks/archive.html",
{ {
"bookmark_list": bookmark_list, "bookmark_list": bookmark_list,
"tag_cloud": tag_cloud, "tag_cloud": tag_cloud,
"details": bookmark_details,
}, },
) )
@ -81,14 +91,37 @@ def shared(request):
bookmark_list = contexts.SharedBookmarkListContext(request) bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request) tag_cloud = contexts.SharedTagCloudContext(request)
bookmark_details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
public_only = not request.user.is_authenticated public_only = not request.user.is_authenticated
users = queries.query_shared_bookmark_users( users = queries.query_shared_bookmark_users(
request.user_profile, bookmark_list.search, public_only request.user_profile, bookmark_list.search, public_only
) )
return render( return render_bookmarks_view(
request, request,
"bookmarks/shared.html", "bookmarks/shared.html",
{"bookmark_list": bookmark_list, "tag_cloud": tag_cloud, "users": users}, {
"bookmark_list": bookmark_list,
"tag_cloud": tag_cloud,
"details": bookmark_details,
"users": users,
},
)
def render_bookmarks_view(request, template_name, context):
if turbo.is_frame(request, "details-modal"):
return render(
request,
"bookmarks/updates/details-modal-frame.html",
context,
)
return render(
request,
template_name,
context,
) )
@ -111,76 +144,6 @@ def search_action(request):
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
def _details(request, bookmark_id: int, template: str):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
is_owner = bookmark.owner == request.user
is_shared = (
request.user.is_authenticated
and bookmark.shared
and bookmark.owner.profile.enable_sharing
)
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist")
if request.method == "POST":
if not is_owner:
raise Http404("Bookmark does not exist")
return_url = get_safe_return_url(
request.GET.get("return_url"),
reverse("bookmarks:details", args=[bookmark.id]),
)
if "remove_asset" in request.POST:
asset_id = request.POST["remove_asset"]
try:
asset = bookmark.bookmarkasset_set.get(pk=asset_id)
except BookmarkAsset.DoesNotExist:
raise Http404("Asset does not exist")
asset.delete()
if "create_snapshot" in request.POST:
tasks.create_html_snapshot(bookmark)
if "upload_asset" in request.POST:
file = request.FILES.get("upload_asset_file")
if not file:
return HttpResponseBadRequest("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
else:
bookmark.is_archived = request.POST.get("is_archived") == "on"
bookmark.unread = request.POST.get("unread") == "on"
bookmark.shared = request.POST.get("shared") == "on"
bookmark.save()
return HttpResponseRedirect(return_url)
details_context = contexts.BookmarkDetailsContext(request, bookmark)
return render(
request,
template,
{
"details": details_context,
},
)
def details(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details.html")
def details_modal(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details_modal.html")
def details_assets(request, bookmark_id: int):
return _details(request, bookmark_id, "bookmarks/details/assets.html")
def convert_tag_string(tag_string: str): def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings # strings
@ -307,26 +270,87 @@ def mark_as_read(request, bookmark_id: int):
bookmark.save() bookmark.save()
def create_html_snapshot(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
tasks.create_html_snapshot(bookmark)
def upload_asset(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
file = request.FILES.get("upload_asset_file")
if not file:
raise ValueError("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
def remove_asset(request, asset_id: int):
try:
asset = BookmarkAsset.objects.get(pk=asset_id, bookmark__owner=request.user)
except BookmarkAsset.DoesNotExist:
raise Http404("Asset does not exist")
asset.delete()
def update_state(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404("Bookmark does not exist")
bookmark.is_archived = request.POST.get("is_archived") == "on"
bookmark.unread = request.POST.get("unread") == "on"
bookmark.shared = request.POST.get("shared") == "on"
bookmark.save()
@login_required @login_required
def index_action(request): def index_action(request):
search = BookmarkSearch.from_request(request.GET) search = BookmarkSearch.from_request(request.GET)
query = queries.query_bookmarks(request.user, request.user_profile, search) query = queries.query_bookmarks(request.user, request.user_profile, search)
return action(request, query) handle_action(request, query)
if turbo.accept(request):
return partials.active_bookmark_update(request)
return utils.redirect_with_query(request, reverse("bookmarks:index"))
@login_required @login_required
def archived_action(request): def archived_action(request):
search = BookmarkSearch.from_request(request.GET) search = BookmarkSearch.from_request(request.GET)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search) query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
return action(request, query) handle_action(request, query)
if turbo.accept(request):
return partials.archived_bookmark_update(request)
return utils.redirect_with_query(request, reverse("bookmarks:archived"))
@login_required @login_required
def shared_action(request): def shared_action(request):
return action(request) if "bulk_execute" in request.POST:
return HttpResponseBadRequest("View does not support bulk actions")
handle_action(request)
if turbo.accept(request):
return partials.shared_bookmark_update(request)
return utils.redirect_with_query(request, reverse("bookmarks:shared"))
def action(request, query: QuerySet[Bookmark] = None): def handle_action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions # Single bookmark actions
if "archive" in request.POST: if "archive" in request.POST:
archive(request, request.POST["archive"]) archive(request, request.POST["archive"])
@ -338,11 +362,21 @@ def action(request, query: QuerySet[Bookmark] = None):
mark_as_read(request, request.POST["mark_as_read"]) mark_as_read(request, request.POST["mark_as_read"])
if "unshare" in request.POST: if "unshare" in request.POST:
unshare(request, request.POST["unshare"]) unshare(request, request.POST["unshare"])
if "create_html_snapshot" in request.POST:
create_html_snapshot(request, request.POST["create_html_snapshot"])
if "upload_asset" in request.POST:
upload_asset(request, request.POST["upload_asset"])
if "remove_asset" in request.POST:
remove_asset(request, request.POST["remove_asset"])
# State updates
if "update_state" in request.POST:
update_state(request, request.POST["update_state"])
# Bulk actions # Bulk actions
if "bulk_execute" in request.POST: if "bulk_execute" in request.POST:
if query is None: if query is None:
return HttpResponseBadRequest("View does not support bulk actions") raise ValueError("Query must be provided for bulk actions")
bulk_action = request.POST["bulk_action"] bulk_action = request.POST["bulk_action"]
@ -375,11 +409,6 @@ def action(request, query: QuerySet[Bookmark] = None):
if "bulk_unshare" == bulk_action: if "bulk_unshare" == bulk_action:
unshare_bookmarks(bookmark_ids, request.user) unshare_bookmarks(bookmark_ids, request.user)
return_url = get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:index")
)
return HttpResponseRedirect(return_url)
@login_required @login_required
def close(request): def close(request):

View file

@ -6,6 +6,7 @@ from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import models from django.db import models
from django.http import Http404
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
@ -27,17 +28,11 @@ CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
class RequestContext: class RequestContext:
index_view = "bookmarks:index" index_view = "bookmarks:index"
action_view = "bookmarks:index.action" action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def __init__(self, request: WSGIRequest): def __init__(self, request: WSGIRequest):
self.request = request self.request = request
self.index_url = reverse(self.index_view) self.index_url = reverse(self.index_view)
self.action_url = reverse(self.action_view) self.action_url = reverse(self.action_view)
self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
self.query_params = request.GET.copy() self.query_params = request.GET.copy()
self.query_params.pop("details", None) self.query_params.pop("details", None)
@ -51,34 +46,25 @@ class RequestContext:
encoded_params = query_params.urlencode() encoded_params = query_params.urlencode()
return view_url + "?" + encoded_params if encoded_params else view_url return view_url + "?" + encoded_params if encoded_params else view_url
def index(self) -> str: def index(self, add: dict = None, remove: dict = None) -> str:
return self.get_url(self.index_url) return self.get_url(self.index_url, add=add, remove=remove)
def action(self, return_url: str) -> str: def action(self, add: dict = None, remove: dict = None) -> str:
return self.get_url(self.action_url, add={"return_url": return_url}) return self.get_url(self.action_url, add=add, remove=remove)
def bookmark_list_partial(self) -> str: def details(self, bookmark_id: int) -> str:
return self.get_url(self.bookmark_list_partial_url) return self.get_url(self.index_url, add={"details": bookmark_id})
def tag_cloud_partial(self) -> str:
return self.get_url(self.tag_cloud_partial_url)
def tag_modal_partial(self) -> str:
return self.get_url(self.tag_modal_partial_url)
def get_bookmark_query_set(self, search: BookmarkSearch): def get_bookmark_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass") raise NotImplementedError("Must be implemented by subclass")
def get_tag_query_set(self, search: BookmarkSearch): def get_tag_query_set(self, search: BookmarkSearch):
raise Exception("Must be implemented by subclass") raise NotImplementedError("Must be implemented by subclass")
class ActiveBookmarksContext(RequestContext): class ActiveBookmarksContext(RequestContext):
index_view = "bookmarks:index" index_view = "bookmarks:index"
action_view = "bookmarks:index.action" action_view = "bookmarks:index.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
def get_bookmark_query_set(self, search: BookmarkSearch): def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_bookmarks( return queries.query_bookmarks(
@ -94,9 +80,6 @@ class ActiveBookmarksContext(RequestContext):
class ArchivedBookmarksContext(RequestContext): class ArchivedBookmarksContext(RequestContext):
index_view = "bookmarks:archived" index_view = "bookmarks:archived"
action_view = "bookmarks:archived.action" action_view = "bookmarks:archived.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
def get_bookmark_query_set(self, search: BookmarkSearch): def get_bookmark_query_set(self, search: BookmarkSearch):
return queries.query_archived_bookmarks( return queries.query_archived_bookmarks(
@ -112,9 +95,6 @@ class ArchivedBookmarksContext(RequestContext):
class SharedBookmarksContext(RequestContext): class SharedBookmarksContext(RequestContext):
index_view = "bookmarks:shared" index_view = "bookmarks:shared"
action_view = "bookmarks:shared.action" action_view = "bookmarks:shared.action"
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
def get_bookmark_query_set(self, search: BookmarkSearch): def get_bookmark_query_set(self, search: BookmarkSearch):
user = User.objects.filter(username=search.user).first() user = User.objects.filter(username=search.user).first()
@ -132,7 +112,13 @@ class SharedBookmarksContext(RequestContext):
class BookmarkItem: class BookmarkItem:
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None: def __init__(
self,
context: RequestContext,
bookmark: Bookmark,
user: User,
profile: UserProfile,
) -> None:
self.bookmark = bookmark self.bookmark = bookmark
is_editable = bookmark.owner == user is_editable = bookmark.owner == user
@ -154,6 +140,7 @@ class BookmarkItem:
self.is_archived = bookmark.is_archived self.is_archived = bookmark.is_archived
self.unread = bookmark.unread self.unread = bookmark.unread
self.owner = bookmark.owner self.owner = bookmark.owner
self.details_url = context.details(bookmark.id)
css_classes = [] css_classes = []
if bookmark.unread: if bookmark.unread:
@ -200,16 +187,15 @@ class BookmarkListContext:
models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags") models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags")
self.items = [ self.items = [
BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page BookmarkItem(request_context, bookmark, user, user_profile)
for bookmark in bookmarks_page
] ]
self.is_empty = paginator.count == 0 self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count self.bookmarks_total = paginator.count
self.return_url = request_context.index() self.return_url = request_context.index()
self.action_url = request_context.action(return_url=self.return_url) self.action_url = request_context.action()
self.refresh_url = request_context.bookmark_list_partial()
self.tag_modal_url = request_context.tag_modal_partial()
self.link_target = user_profile.bookmark_link_target self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display self.date_display = user_profile.bookmark_date_display
@ -344,8 +330,6 @@ class TagCloudContext:
self.selected_tags = unique_selected_tags self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags self.has_selected_tags = has_selected_tags
self.refresh_url = request_context.tag_cloud_partial()
def get_selected_tags(self, tags: List[Tag]): def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.search.q) parsed_query = queries.parse_query_string(self.search.q)
tag_names = parsed_query["tag_names"] tag_names = parsed_query["tag_names"]
@ -396,17 +380,18 @@ class BookmarkAssetItem:
class BookmarkDetailsContext: class BookmarkDetailsContext:
request_context = RequestContext
def __init__(self, request: WSGIRequest, bookmark: Bookmark): def __init__(self, request: WSGIRequest, bookmark: Bookmark):
request_context = self.request_context(request)
user = request.user user = request.user
user_profile = request.user_profile user_profile = request.user_profile
self.edit_return_url = utils.get_safe_return_url( self.edit_return_url = request_context.details(bookmark.id)
request.GET.get("return_url"), self.action_url = request_context.action(add={"details": bookmark.id})
reverse("bookmarks:details", args=[bookmark.id]), self.delete_url = request_context.action()
) self.close_url = request_context.index()
self.delete_return_url = utils.get_safe_return_url(
request.GET.get("return_url"), reverse("bookmarks:index")
)
self.bookmark = bookmark self.bookmark = bookmark
self.profile = request.user_profile self.profile = request.user_profile
@ -438,3 +423,44 @@ class BookmarkDetailsContext:
), ),
None, None,
) )
class ActiveBookmarkDetailsContext(BookmarkDetailsContext):
request_context = ActiveBookmarksContext
class ArchivedBookmarkDetailsContext(BookmarkDetailsContext):
request_context = ArchivedBookmarksContext
class SharedBookmarkDetailsContext(BookmarkDetailsContext):
request_context = SharedBookmarksContext
def get_details_context(
request: WSGIRequest, context_type
) -> BookmarkDetailsContext | None:
bookmark_id = request.GET.get("details")
if not bookmark_id:
return None
try:
bookmark = Bookmark.objects.get(pk=int(bookmark_id))
except Bookmark.DoesNotExist:
# just ignore, might end up in a situation where the bookmark was deleted
# in between navigating back and forth
return None
is_owner = bookmark.owner == request.user
is_shared = (
request.user.is_authenticated
and bookmark.shared
and bookmark.owner.profile.enable_sharing
)
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist")
if request.method == "POST" and not is_owner:
raise Http404("Bookmark does not exist")
return context_type(request, bookmark)

View 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)

View file

@ -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
View 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

View file

@ -10,8 +10,8 @@ from .base import *
DEBUG = True DEBUG = True
# Enable debug toolbar # Enable debug toolbar
INSTALLED_APPS.append("debug_toolbar") # INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") # MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = [ INTERNAL_IPS = [
"127.0.0.1", "127.0.0.1",