diff --git a/.dockerignore b/.dockerignore index c5f38eda..c04ec2f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,7 @@ !/manage.py !/package.json !/package-lock.json +!/postcss.config.js !/requirements.dev.txt !/requirements.txt !/rollup.config.mjs diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 4e6e5205..883c4dd5 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -53,7 +53,6 @@ jobs: - name: Run build run: | npm run build - python manage.py compilescss - python manage.py collectstatic --ignore=*.scss + python manage.py collectstatic - name: Run tests run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py" diff --git a/.gitignore b/.gitignore index 0c21272b..2f6d9ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -183,7 +183,7 @@ typings/ ### Custom # Rollup compilation output /bookmarks/static/bundle.js* -# SASS compilation output +# CSS compilation output /bookmarks/static/theme-*.css* # Collected static files for deployment /static diff --git a/CHANGELOG.md b/CHANGELOG.md index 110f8295..b5ce55c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## v1.34.0 (16/09/2024) + +### What's Changed +* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825 +* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829 +* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826 + +### New Contributors +* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826 + +**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0 + +--- + +## v1.33.0 (14/09/2024) + +### What's Changed +* Theme improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/822 +* Speed up navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/824 +* Rename "SingeFileError" to "SingleFileError" by @curiousleo in https://github.com/sissbruecker/linkding/pull/823 +* Bump svelte from 4.2.12 to 4.2.19 by @dependabot in https://github.com/sissbruecker/linkding/pull/806 + +### New Contributors +* @curiousleo made their first contribution in https://github.com/sissbruecker/linkding/pull/823 + +**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.32.0...v1.33.0 + +--- + +## v1.32.0 (10/09/2024) + +### What's Changed +* Allow configuring landing page for unauthenticated users by @sissbruecker in https://github.com/sissbruecker/linkding/pull/808 +* Allow configuring guest user profile by @sissbruecker in https://github.com/sissbruecker/linkding/pull/809 +* Return bookmark tags in RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/810 +* Additional filter parameters for RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/811 +* Allow pre-filling notes in new bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/812 +* Fix inconsistent tag order in bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/819 +* Fix auto-tagging when URL includes port by @sissbruecker in https://github.com/sissbruecker/linkding/pull/820 + + +**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.1...v1.32.0 + +--- + ## v1.31.1 (30/08/2024) ### What's Changed diff --git a/assets/logo-inset.afdesign b/assets/logo-inset.afdesign new file mode 100644 index 00000000..10f0015b Binary files /dev/null and b/assets/logo-inset.afdesign differ diff --git a/bookmarks/e2e/e2e_test_bookmark_details_modal.py b/bookmarks/e2e/e2e_test_bookmark_details_modal.py index 73872e5e..947a48f6 100644 --- a/bookmarks/e2e/e2e_test_bookmark_details_modal.py +++ b/bookmarks/e2e/e2e_test_bookmark_details_modal.py @@ -121,8 +121,9 @@ def test_edit_return_url(self): with self.page.expect_navigation(): details_modal.get_by_text("Edit").click() - # Cancel edit, verify return url - with self.page.expect_navigation(url=self.live_server_url + url): + # Cancel edit, verify return to details url + details_url = url + f"&details={bookmark.id}" + with self.page.expect_navigation(url=self.live_server_url + details_url): self.page.get_by_text("Nevermind").click() def test_delete(self): @@ -167,7 +168,7 @@ def test_create_snapshot_remove_snapshot(self): # Has new snapshots expect(snapshot).to_be_visible() - # Create snapshot + # Remove snapshot asset_list.get_by_text("Remove", exact=False).click() asset_list.get_by_text("Confirm", exact=False).click() diff --git a/bookmarks/e2e/e2e_test_bookmark_details_view.py b/bookmarks/e2e/e2e_test_bookmark_details_view.py deleted file mode 100644 index 8b792ffa..00000000 --- a/bookmarks/e2e/e2e_test_bookmark_details_view.py +++ /dev/null @@ -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() diff --git a/bookmarks/e2e/e2e_test_tag_cloud_modal.py b/bookmarks/e2e/e2e_test_tag_cloud_modal.py index 19793ebe..7d60dcc2 100644 --- a/bookmarks/e2e/e2e_test_tag_cloud_modal.py +++ b/bookmarks/e2e/e2e_test_tag_cloud_modal.py @@ -1,9 +1,7 @@ -from django.test import override_settings from django.urls import reverse -from playwright.sync_api import sync_playwright, expect, Locator +from playwright.sync_api import sync_playwright, expect from bookmarks.e2e.helpers import LinkdingE2ETestCase -from bookmarks.models import Bookmark class TagCloudModalE2ETestCase(LinkdingE2ETestCase): @@ -26,7 +24,7 @@ def test_show_modal_close_modal(self): # verify modal is visible modal = page.locator(".modal") expect(modal).to_be_visible() - expect(modal.locator(".modal-title")).to_have_text("Tags") + expect(modal.locator("h2")).to_have_text("Tags") # close with close button modal.locator("button.close").click() diff --git a/bookmarks/frontend/api.js b/bookmarks/frontend/api.js index dfe682f8..2a47d882 100644 --- a/bookmarks/frontend/api.js +++ b/bookmarks/frontend/api.js @@ -1,4 +1,4 @@ -export class ApiClient { +export class Api { constructor(baseUrl) { this.baseUrl = baseUrl; } @@ -27,3 +27,6 @@ export class ApiClient { .then((data) => data.results); } } + +const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; +export const api = new Api(apiBaseUrl); diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js index 6f77973e..681f5f62 100644 --- a/bookmarks/frontend/behaviors/bookmark-page.js +++ b/bookmarks/frontend/behaviors/bookmark-page.js @@ -5,9 +5,10 @@ class BookmarkItem extends Behavior { super(element); // Toggle notes - const notesToggle = element.querySelector(".toggle-notes"); - if (notesToggle) { - notesToggle.addEventListener("click", this.onToggleNotes.bind(this)); + this.onToggleNotes = this.onToggleNotes.bind(this); + this.notesToggle = element.querySelector(".toggle-notes"); + if (this.notesToggle) { + this.notesToggle.addEventListener("click", this.onToggleNotes); } // Add tooltip to title if it is truncated @@ -20,6 +21,12 @@ class BookmarkItem extends Behavior { }); } + destroy() { + if (this.notesToggle) { + this.notesToggle.removeEventListener("click", this.onToggleNotes); + } + } + onToggleNotes(event) { event.preventDefault(); event.stopPropagation(); diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js index 6d9d0bcf..e5b5578e 100644 --- a/bookmarks/frontend/behaviors/bulk-edit.js +++ b/bookmarks/frontend/behaviors/bulk-edit.js @@ -4,16 +4,22 @@ class BulkEdit extends Behavior { constructor(element) { super(element); - this.active = false; + this.active = element.classList.contains("active"); + this.init = this.init.bind(this); this.onToggleActive = this.onToggleActive.bind(this); this.onToggleAll = this.onToggleAll.bind(this); this.onToggleBookmark = this.onToggleBookmark.bind(this); this.onActionSelected = this.onActionSelected.bind(this); this.init(); - // Reset when bookmarks are refreshed - document.addEventListener("refresh-bookmark-list-done", () => this.init()); + // Reset when bookmarks are updated + document.addEventListener("bookmark-list-updated", this.init); + } + + destroy() { + this.removeListeners(); + document.removeEventListener("bookmark-list-updated", this.init); } init() { @@ -31,13 +37,9 @@ class BulkEdit extends Behavior { this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"), ); - // Remove previous listeners if elements are the same - this.activeToggle.removeEventListener("click", this.onToggleActive); - this.actionSelect.removeEventListener("change", this.onActionSelected); - this.allCheckbox.removeEventListener("change", this.onToggleAll); - this.bookmarkCheckboxes.forEach((checkbox) => { - checkbox.removeEventListener("change", this.onToggleBookmark); - }); + // Add listeners, ensure there are no dupes by possibly removing existing listeners + this.removeListeners(); + this.addListeners(); // Reset checkbox states this.reset(); @@ -47,8 +49,9 @@ class BulkEdit extends Behavior { const total = totalHolder?.dataset.bookmarksTotal || 0; const totalSpan = this.selectAcross.querySelector("span.total"); totalSpan.textContent = total; + } - // Add new listeners + addListeners() { this.activeToggle.addEventListener("click", this.onToggleActive); this.actionSelect.addEventListener("change", this.onActionSelected); this.allCheckbox.addEventListener("change", this.onToggleAll); @@ -57,6 +60,15 @@ class BulkEdit extends Behavior { }); } + removeListeners() { + this.activeToggle.removeEventListener("click", this.onToggleActive); + this.actionSelect.removeEventListener("change", this.onActionSelected); + this.allCheckbox.removeEventListener("change", this.onToggleAll); + this.bookmarkCheckboxes.forEach((checkbox) => { + checkbox.removeEventListener("change", this.onToggleBookmark); + }); + } + onToggleActive() { this.active = !this.active; if (this.active) { diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js index 059e9382..fb213eea 100644 --- a/bookmarks/frontend/behaviors/confirm-button.js +++ b/bookmarks/frontend/behaviors/confirm-button.js @@ -3,17 +3,14 @@ import { Behavior, registerBehavior } from "./index"; class ConfirmButtonBehavior extends Behavior { constructor(element) { super(element); - element.dataset.type = element.type; - element.dataset.name = element.name; - element.dataset.value = element.value; - element.removeAttribute("type"); - element.removeAttribute("name"); - element.removeAttribute("value"); - element.addEventListener("click", this.onClick.bind(this)); + + this.onClick = this.onClick.bind(this); + element.addEventListener("click", this.onClick); } destroy() { - Behavior.interacting = false; + this.reset(); + this.element.removeEventListener("click", this.onClick); } onClick(event) { @@ -53,9 +50,9 @@ class ConfirmButtonBehavior extends Behavior { cancelButton.addEventListener("click", this.reset.bind(this)); const confirmButton = document.createElement(this.element.nodeName); - confirmButton.type = this.element.dataset.type; - confirmButton.name = this.element.dataset.name; - confirmButton.value = this.element.dataset.value; + confirmButton.type = this.element.type; + confirmButton.name = this.element.name; + confirmButton.value = this.element.value; confirmButton.innerText = question ? "Yes" : "Confirm"; confirmButton.className = buttonClasses; confirmButton.addEventListener("click", this.reset.bind(this)); @@ -70,7 +67,10 @@ class ConfirmButtonBehavior extends Behavior { reset() { setTimeout(() => { Behavior.interacting = false; - this.container.remove(); + if (this.container) { + this.container.remove(); + this.container = null; + } this.element.classList.remove("d-none"); }); } diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js new file mode 100644 index 00000000..5646969d --- /dev/null +++ b/bookmarks/frontend/behaviors/details-modal.js @@ -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); diff --git a/bookmarks/frontend/behaviors/dropdown.js b/bookmarks/frontend/behaviors/dropdown.js index 60a47876..954ced85 100644 --- a/bookmarks/frontend/behaviors/dropdown.js +++ b/bookmarks/frontend/behaviors/dropdown.js @@ -4,16 +4,16 @@ class DropdownBehavior extends Behavior { constructor(element) { super(element); this.opened = false; + this.onClick = this.onClick.bind(this); this.onOutsideClick = this.onOutsideClick.bind(this); - const toggle = element.querySelector(".dropdown-toggle"); - toggle.addEventListener("click", () => { - if (this.opened) { - this.close(); - } else { - this.open(); - } - }); + this.toggle = element.querySelector(".dropdown-toggle"); + this.toggle.addEventListener("click", this.onClick); + } + + destroy() { + this.close(); + this.toggle.removeEventListener("click", this.onClick); } open() { @@ -26,6 +26,14 @@ class DropdownBehavior extends Behavior { document.removeEventListener("click", this.onOutsideClick); } + onClick() { + if (this.opened) { + this.close(); + } else { + this.open(); + } + } + onOutsideClick(event) { if (!this.element.contains(event.target)) { this.close(); diff --git a/bookmarks/frontend/behaviors/fetch.js b/bookmarks/frontend/behaviors/fetch.js deleted file mode 100644 index 32b71847..00000000 --- a/bookmarks/frontend/behaviors/fetch.js +++ /dev/null @@ -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); diff --git a/bookmarks/frontend/behaviors/form.js b/bookmarks/frontend/behaviors/form.js index 3b6dec23..ca475552 100644 --- a/bookmarks/frontend/behaviors/form.js +++ b/bookmarks/frontend/behaviors/form.js @@ -1,64 +1,55 @@ -import { Behavior, fireEvents, registerBehavior } from "./index"; +import { Behavior, registerBehavior } from "./index"; -class FormBehavior extends Behavior { +class AutoSubmitBehavior extends Behavior { constructor(element) { super(element); - element.addEventListener("submit", this.onSubmit.bind(this)); + this.submit = this.submit.bind(this); + element.addEventListener("change", this.submit); } - 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); - } + destroy() { + this.element.removeEventListener("change", this.submit); } -} -class AutoSubmitBehavior extends Behavior { - constructor(element) { - super(element); - - element.addEventListener("change", () => { - const form = element.closest("form"); - form.dispatchEvent(new Event("submit", { cancelable: true })); - }); + submit() { + this.element.closest("form").requestSubmit(); } } class UploadButton extends Behavior { constructor(element) { super(element); + this.fileInput = element.nextElementSibling; - const fileInput = element.nextElementSibling; + this.onClick = this.onClick.bind(this); + this.onChange = this.onChange.bind(this); - element.addEventListener("click", () => { - fileInput.click(); - }); + element.addEventListener("click", this.onClick); + this.fileInput.addEventListener("change", this.onChange); + } + + destroy() { + this.element.removeEventListener("click", this.onClick); + this.fileInput.removeEventListener("change", this.onChange); + } - fileInput.addEventListener("change", () => { - const form = fileInput.closest("form"); - const event = new Event("submit", { cancelable: true }); - 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-upload-button", UploadButton); diff --git a/bookmarks/frontend/behaviors/global-shortcuts.js b/bookmarks/frontend/behaviors/global-shortcuts.js index fba6ab16..68e16f71 100644 --- a/bookmarks/frontend/behaviors/global-shortcuts.js +++ b/bookmarks/frontend/behaviors/global-shortcuts.js @@ -4,7 +4,12 @@ class GlobalShortcuts extends Behavior { constructor(element) { super(element); - document.addEventListener("keydown", this.onKeyDown.bind(this)); + this.onKeyDown = this.onKeyDown.bind(this); + document.addEventListener("keydown", this.onKeyDown); + } + + destroy() { + document.removeEventListener("keydown", this.onKeyDown); } onKeyDown(event) { diff --git a/bookmarks/frontend/behaviors/index.js b/bookmarks/frontend/behaviors/index.js index 0ddbde38..c973ce2d 100644 --- a/bookmarks/frontend/behaviors/index.js +++ b/bookmarks/frontend/behaviors/index.js @@ -16,9 +16,17 @@ const mutationObserver = new MutationObserver((mutations) => { }); }); -mutationObserver.observe(document.body, { - childList: true, - subtree: true, +document.addEventListener("turbo:load", () => { + mutationObserver.observe(document.body, { + childList: true, + subtree: true, + }); + + applyBehaviors(document.body); +}); + +document.addEventListener("turbo:before-cache", () => { + destroyBehaviors(document.body); }); export class Behavior { @@ -95,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)); - }); - }); -} diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js deleted file mode 100644 index f22ed247..00000000 --- a/bookmarks/frontend/behaviors/modal.js +++ /dev/null @@ -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); diff --git a/bookmarks/frontend/behaviors/search-autocomplete.js b/bookmarks/frontend/behaviors/search-autocomplete.js new file mode 100644 index 00000000..d7a686f5 --- /dev/null +++ b/bookmarks/frontend/behaviors/search-autocomplete.js @@ -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); diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js index 58e8e97a..7221582d 100644 --- a/bookmarks/frontend/behaviors/tag-autocomplete.js +++ b/bookmarks/frontend/behaviors/tag-autocomplete.js @@ -1,27 +1,35 @@ import { Behavior, registerBehavior } from "./index"; import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; -import { ApiClient } from "../api"; class TagAutocomplete extends Behavior { constructor(element) { super(element); - const wrapper = document.createElement("div"); - const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; - const apiClient = new ApiClient(apiBaseUrl); + const input = element.querySelector("input"); + if (!input) { + console.warn("TagAutocomplete: input element not found"); + return; + } + + const container = document.createElement("div"); new TagAutoCompleteComponent({ - target: wrapper, + target: container, props: { - id: element.id, - name: element.name, - value: element.value, - placeholder: element.getAttribute("placeholder") || "", - apiClient: apiClient, - variant: element.getAttribute("variant"), + id: input.id, + name: input.name, + value: input.value, + placeholder: input.getAttribute("placeholder") || "", + variant: input.getAttribute("variant"), }, }); - element.replaceWith(wrapper.firstElementChild); + this.input = input; + this.autocomplete = container.firstElementChild; + input.replaceWith(this.autocomplete); + } + + destroy() { + this.autocomplete.replaceWith(this.input); } } diff --git a/bookmarks/frontend/behaviors/tag-modal.js b/bookmarks/frontend/behaviors/tag-modal.js new file mode 100644 index 00000000..9963bff7 --- /dev/null +++ b/bookmarks/frontend/behaviors/tag-modal.js @@ -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 = ` + + + `; + + 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); diff --git a/bookmarks/frontend/cache.js b/bookmarks/frontend/cache.js new file mode 100644 index 00000000..0bcf9e58 --- /dev/null +++ b/bookmarks/frontend/cache.js @@ -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); diff --git a/bookmarks/frontend/components/SearchAutoComplete.svelte b/bookmarks/frontend/components/SearchAutoComplete.svelte index 6041cc94..aca77e98 100644 --- a/bookmarks/frontend/components/SearchAutoComplete.svelte +++ b/bookmarks/frontend/components/SearchAutoComplete.svelte @@ -1,5 +1,7 @@ + diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html index 279fa5e9..b73d8e03 100644 --- a/bookmarks/templates/bookmarks/index.html +++ b/bookmarks/templates/bookmarks/index.html @@ -11,24 +11,19 @@

Bookmarks

- {% bookmark_search bookmark_list.search tag_cloud.tags %} + {% bookmark_search bookmark_list.search %} {% include 'bookmarks/bulk_edit/toggle.html' %} - +
-
{% csrf_token %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} -
+
{% include 'bookmarks/bookmark_list.html' %}
@@ -39,10 +34,16 @@

Bookmarks

Tags

-
+
{% include 'bookmarks/tag_cloud.html' %}
+ + {# Bookmark details #} + + {% if details %} + {% include 'bookmarks/details/modal.html' %} + {% endif %} +
{% endblock %} diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html index ae78c3e0..b69d20c0 100644 --- a/bookmarks/templates/bookmarks/layout.html +++ b/bookmarks/templates/bookmarks/layout.html @@ -1,43 +1,9 @@ {% load static %} -{% load sass_tags %} {# Use data attributes as storage for access in static scripts #} - - - - - - - - - - - - - linkding - {# Include SASS styles, files are resolved from bookmarks/styles #} - {# Include specific theme variant based on user profile setting #} - {% if request.user_profile.theme == 'light' %} - - - {% elif request.user_profile.theme == 'dark' %} - - - {% else %} - {# Use auto theme as fallback #} - - - - - {% endif %} - {% if request.user_profile.custom_css %} - - {% endif %} - +{% include 'bookmarks/head.html' %}
@@ -131,6 +97,5 @@

LINKDING

{% block content %} {% endblock %}
- \ No newline at end of file diff --git a/bookmarks/templates/bookmarks/nav_menu.html b/bookmarks/templates/bookmarks/nav_menu.html index 394bc21f..1e935777 100644 --- a/bookmarks/templates/bookmarks/nav_menu.html +++ b/bookmarks/templates/bookmarks/nav_menu.html @@ -1,88 +1,83 @@ {% load shared %} {% htmlmin %} -{# Basic menu list #} -
- Add bookmark - + Settings +
+ {% csrf_token %} + +
- Settings -
- {% csrf_token %} - -
-
-{# Menu drop-down for smaller devices #} -
- - - - - - -
{% endhtmlmin %} \ No newline at end of file diff --git a/bookmarks/templates/bookmarks/new.html b/bookmarks/templates/bookmarks/new.html index 4e44c142..a60a374a 100644 --- a/bookmarks/templates/bookmarks/new.html +++ b/bookmarks/templates/bookmarks/new.html @@ -2,12 +2,14 @@ {% load bookmarks %} {% block content %} -
-
-

New bookmark

-
-
- {% bookmark_form form return_url auto_close=auto_close %} -
-
+
+
+
+

New bookmark

+
+
+ {% bookmark_form form return_url auto_close=auto_close %} +
+
+
{% endblock %} diff --git a/bookmarks/templates/bookmarks/pagination.html b/bookmarks/templates/bookmarks/pagination.html index 59f853ef..62a63949 100644 --- a/bookmarks/templates/bookmarks/pagination.html +++ b/bookmarks/templates/bookmarks/pagination.html @@ -1,9 +1,9 @@ {% load shared %}

Drag the following bookmarklet to your browser's toolbar:

- 📎 Add bookmark @@ -35,17 +35,15 @@

Bookmarklet

REST API

The following token can be used to authenticate 3rd-party applications against the REST API:

-
-
- -
+
+

Please treat this token as you would any other credential. 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 admin panel. + target="_blank" href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel. After deleting the token, a new one will be generated when you reload this settings page.

@@ -54,10 +52,10 @@

REST API

RSS Feeds

The following URLs provide RSS feeds for your bookmarks:

@@ -82,7 +80,7 @@

RSS Feeds

credential. Any party with access to these URLs can read all your bookmarks. If you think that a URL was compromised you can delete the feed token for your user in the admin panel. + target="_blank" href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel. After deleting the feed token, new URLs will be generated when you reload this settings page.

diff --git a/bookmarks/templates/settings/nav.html b/bookmarks/templates/settings/nav.html index 28371ad4..44ca1f00 100644 --- a/bookmarks/templates/settings/nav.html +++ b/bookmarks/templates/settings/nav.html @@ -3,21 +3,21 @@ {% url 'bookmarks:settings.integrations' as integrations_url %} diff --git a/bookmarks/templatetags/bookmarks.py b/bookmarks/templatetags/bookmarks.py index af7f929b..d7706ceb 100644 --- a/bookmarks/templatetags/bookmarks.py +++ b/bookmarks/templatetags/bookmarks.py @@ -6,8 +6,6 @@ BookmarkForm, BookmarkSearch, BookmarkSearchForm, - Tag, - build_tag_string, User, ) @@ -34,9 +32,7 @@ def bookmark_form( @register.inclusion_tag( "bookmarks/search.html", name="bookmark_search", takes_context=True ) -def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""): - tag_names = [tag.name for tag in tags] - tags_string = build_tag_string(tag_names, " ") +def bookmark_search(context, search: BookmarkSearch, mode: str = ""): search_form = BookmarkSearchForm(search, editable_fields=["q"]) if mode == "shared": @@ -50,7 +46,6 @@ def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = "" "search": search, "search_form": search_form, "preferences_form": preferences_form, - "tags_string": tags_string, "mode": mode, } diff --git a/bookmarks/templatetags/pagination.py b/bookmarks/templatetags/pagination.py index eff59002..bb46e1e2 100644 --- a/bookmarks/templatetags/pagination.py +++ b/bookmarks/templatetags/pagination.py @@ -2,6 +2,7 @@ from django import template from django.core.paginator import Page +from django.http import QueryDict NUM_ADJACENT_PAGES = 2 @@ -12,11 +13,44 @@ "bookmarks/pagination.html", name="pagination", takes_context=True ) def pagination(context, page: Page): + # remove page number and details from query parameters + query_params = context["request"].GET.copy() + query_params.pop("page", None) + query_params.pop("details", None) + + prev_link = ( + _generate_link(query_params, page.previous_page_number()) + if page.has_previous() + else None + ) + next_link = ( + _generate_link(query_params, page.next_page_number()) + if page.has_next() + else None + ) + visible_page_numbers = get_visible_page_numbers( page.number, page.paginator.num_pages ) + page_links = [] + for page_number in visible_page_numbers: + if page_number == -1: + page_links.append(None) + else: + link = _generate_link(query_params, page_number) + page_links.append( + { + "active": page_number == page.number, + "number": page_number, + "link": link, + } + ) - return {"page": page, "visible_page_numbers": visible_page_numbers} + return { + "prev_link": prev_link, + "next_link": next_link, + "page_links": page_links, + } def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]: @@ -56,3 +90,8 @@ def append_page(result: [int], page_number: int): return result return reduce(append_page, visible_pages, []) + + +def _generate_link(query_params: QueryDict, page_number: int) -> str: + query_params["page"] = page_number + return query_params.urlencode() diff --git a/bookmarks/templatetags/shared.py b/bookmarks/templatetags/shared.py index 6524c112..5da3c1c3 100644 --- a/bookmarks/templatetags/shared.py +++ b/bookmarks/templatetags/shared.py @@ -28,12 +28,13 @@ def add_tag_to_query(context, tag_name: str): params = context.request.GET.copy() # Append to or create query string - if params.__contains__("q"): - query_string = params.__getitem__("q") + " " - else: - query_string = "" - query_string = query_string + "#" + tag_name - params.__setitem__("q", query_string) + query_string = params.get("q", "") + query_string = (query_string + " #" + tag_name).strip() + params.setlist("q", [query_string]) + + # Remove details ID and page number + params.pop("details", None) + params.pop("page", None) return params.urlencode() @@ -62,6 +63,10 @@ def remove_tag_from_query(context, tag_name: str): query_string = " ".join(query_parts) params.__setitem__("q", query_string) + # Remove details ID and page number + params.pop("details", None) + params.pop("page", None) + return params.urlencode() diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index dd97f2d9..4a5f016d 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from typing import List +from unittest import TestCase from bs4 import BeautifulSoup from django.contrib.auth.models import User @@ -220,6 +221,75 @@ def make_soup(self, html: str): return BeautifulSoup(html, features="html.parser") +class BookmarkListTestMixin(TestCase, HtmlTestMixin): + def assertVisibleBookmarks( + self, response, bookmarks: List[Bookmark], link_target: str = "_blank" + ): + soup = self.make_soup(response.content.decode()) + bookmark_list = soup.select_one( + f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]' + ) + self.assertIsNotNone(bookmark_list) + + bookmark_items = bookmark_list.select("li[ld-bookmark-item]") + self.assertEqual(len(bookmark_items), len(bookmarks)) + + for bookmark in bookmarks: + bookmark_item = bookmark_list.select_one( + f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' + ) + self.assertIsNotNone(bookmark_item) + + def assertInvisibleBookmarks( + self, response, bookmarks: List[Bookmark], link_target: str = "_blank" + ): + soup = self.make_soup(response.content.decode()) + + for bookmark in bookmarks: + bookmark_item = soup.select_one( + f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' + ) + self.assertIsNone(bookmark_item) + + +class TagCloudTestMixin(TestCase, HtmlTestMixin): + def assertVisibleTags(self, response, tags: List[Tag]): + soup = self.make_soup(response.content.decode()) + tag_cloud = soup.select_one("div.tag-cloud") + self.assertIsNotNone(tag_cloud) + + tag_items = tag_cloud.select("a[data-is-tag-item]") + self.assertEqual(len(tag_items), len(tags)) + + tag_item_names = [tag_item.text.strip() for tag_item in tag_items] + + for tag in tags: + self.assertTrue(tag.name in tag_item_names) + + def assertInvisibleTags(self, response, tags: List[Tag]): + soup = self.make_soup(response.content.decode()) + tag_items = soup.select("a[data-is-tag-item]") + + tag_item_names = [tag_item.text.strip() for tag_item in tag_items] + + for tag in tags: + self.assertFalse(tag.name in tag_item_names) + + def assertSelectedTags(self, response, tags: List[Tag]): + soup = self.make_soup(response.content.decode()) + selected_tags = soup.select_one("p.selected-tags") + self.assertIsNotNone(selected_tags) + + tag_list = selected_tags.select("a") + self.assertEqual(len(tag_list), len(tags)) + + for tag in tags: + self.assertTrue( + tag.name in selected_tags.text, + msg=f"Selected tags do not contain: {tag.name}", + ) + + class LinkdingApiTestCase(APITestCase): def get(self, url, expected_status_code=status.HTTP_200_OK): response = self.client.get(url) diff --git a/bookmarks/tests/test_bookmark_action_view.py b/bookmarks/tests/test_bookmark_action_view.py index 658cb5ac..998a825f 100644 --- a/bookmarks/tests/test_bookmark_action_view.py +++ b/bookmarks/tests/test_bookmark_action_view.py @@ -1,13 +1,24 @@ +from unittest.mock import patch + from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile from django.forms import model_to_dict -from django.test import TestCase +from django.http import HttpResponse +from django.test import TestCase, override_settings from django.urls import reverse -from bookmarks.models import Bookmark -from bookmarks.tests.helpers import BookmarkFactoryMixin +from bookmarks.models import Bookmark, BookmarkAsset +from bookmarks.services import tasks, bookmarks +from bookmarks.tests.helpers import ( + BookmarkFactoryMixin, + BookmarkListTestMixin, + TagCloudTestMixin, +) -class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin): +class BookmarkActionViewTestCase( + TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin +): def setUp(self) -> None: user = self.get_or_create_test_user() @@ -156,6 +167,129 @@ def test_can_only_unshare_own_bookmarks(self): self.assertEqual(response.status_code, 404) self.assertTrue(bookmark.shared) + @override_settings(LD_ENABLE_SNAPSHOTS=True) + def test_create_html_snapshot(self): + bookmark = self.setup_bookmark() + with patch.object(tasks, "_create_html_snapshot_task"): + self.client.post( + reverse("bookmarks:index.action"), + { + "create_html_snapshot": [bookmark.id], + }, + ) + self.assertEqual(bookmark.bookmarkasset_set.count(), 1) + asset = bookmark.bookmarkasset_set.first() + self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT) + + @override_settings(LD_ENABLE_SNAPSHOTS=True) + def test_can_only_create_html_snapshot_for_own_bookmarks(self): + other_user = self.setup_user() + bookmark = self.setup_bookmark(user=other_user) + with patch.object(tasks, "_create_html_snapshot_task"): + response = self.client.post( + reverse("bookmarks:index.action"), + { + "create_html_snapshot": [bookmark.id], + }, + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(bookmark.bookmarkasset_set.count(), 0) + + def test_upload_asset(self): + bookmark = self.setup_bookmark() + file_content = b"file content" + upload_file = SimpleUploadedFile("test.txt", file_content) + + with patch.object(bookmarks, "upload_asset") as mock_upload_asset: + response = self.client.post( + reverse("bookmarks:index.action"), + {"upload_asset": bookmark.id, "upload_asset_file": upload_file}, + ) + self.assertEqual(response.status_code, 302) + + mock_upload_asset.assert_called_once() + + args, _ = mock_upload_asset.call_args + self.assertEqual(args[0], bookmark) + + upload_file = args[1] + self.assertEqual(upload_file.name, "test.txt") + + def test_can_only_upload_asset_for_own_bookmarks(self): + other_user = self.setup_user() + bookmark = self.setup_bookmark(user=other_user) + file_content = b"file content" + upload_file = SimpleUploadedFile("test.txt", file_content) + + with patch.object(bookmarks, "upload_asset") as mock_upload_asset: + response = self.client.post( + reverse("bookmarks:index.action"), + {"upload_asset": bookmark.id, "upload_asset_file": upload_file}, + ) + self.assertEqual(response.status_code, 404) + + mock_upload_asset.assert_not_called() + + def test_remove_asset(self): + bookmark = self.setup_bookmark() + asset = self.setup_asset(bookmark) + + response = self.client.post( + reverse("bookmarks:index.action"), {"remove_asset": asset.id} + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists()) + + def test_can_only_remove_own_asset(self): + other_user = self.setup_user() + bookmark = self.setup_bookmark(user=other_user) + asset = self.setup_asset(bookmark) + + response = self.client.post( + reverse("bookmarks:index.action"), {"remove_asset": asset.id} + ) + self.assertEqual(response.status_code, 404) + self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) + + def test_update_state(self): + bookmark = self.setup_bookmark() + + response = self.client.post( + reverse("bookmarks:index.action"), + { + "update_state": bookmark.id, + "is_archived": "on", + "unread": "on", + "shared": "on", + }, + ) + self.assertEqual(response.status_code, 302) + + bookmark.refresh_from_db() + self.assertTrue(bookmark.unread) + self.assertTrue(bookmark.is_archived) + self.assertTrue(bookmark.shared) + + def test_can_only_update_own_bookmark_state(self): + other_user = self.setup_user() + bookmark = self.setup_bookmark(user=other_user) + + response = self.client.post( + reverse("bookmarks:index.action"), + { + "update_state": bookmark.id, + "is_archived": "on", + "unread": "on", + "shared": "on", + }, + ) + self.assertEqual(response.status_code, 404) + + bookmark.refresh_from_db() + self.assertFalse(bookmark.unread) + self.assertFalse(bookmark.is_archived) + self.assertFalse(bookmark.shared) + def test_bulk_archive(self): bookmark1 = self.setup_bookmark() bookmark2 = self.setup_bookmark() @@ -791,58 +925,119 @@ def test_empty_action_does_not_modify_bookmarks(self): self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3]) - def test_should_redirect_to_return_url(self): - bookmark1 = self.setup_bookmark() - bookmark2 = self.setup_bookmark() - bookmark3 = self.setup_bookmark() + def test_index_action_redirects_to_index_with_query_params(self): + url = reverse("bookmarks:index.action") + "?q=foo&page=2" + redirect_url = reverse("bookmarks:index") + "?q=foo&page=2" + response = self.client.post(url) + + self.assertRedirects(response, redirect_url) + + def test_archived_action_redirects_to_archived_with_query_params(self): + url = reverse("bookmarks:archived.action") + "?q=foo&page=2" + redirect_url = reverse("bookmarks:archived") + "?q=foo&page=2" + response = self.client.post(url) + + self.assertRedirects(response, redirect_url) + + def test_shared_action_redirects_to_shared_with_query_params(self): + url = reverse("bookmarks:shared.action") + "?q=foo&page=2" + redirect_url = reverse("bookmarks:shared") + "?q=foo&page=2" + response = self.client.post(url) + + self.assertRedirects(response, redirect_url) + + def bookmark_update_fixture(self): + user = self.get_or_create_test_user() + profile = user.profile + profile.enable_sharing = True + profile.save() + + return { + "active": self.setup_numbered_bookmarks(3), + "archived": self.setup_numbered_bookmarks(3, archived=True), + "shared": self.setup_numbered_bookmarks(3, shared=True), + } + + def assertBookmarkUpdateResponse(self, response: HttpResponse): + self.assertEqual(response.status_code, 200) + + html = response.content.decode("utf-8") + soup = self.make_soup(html) + + # bookmark list update + self.assertIsNotNone( + soup.select_one( + "turbo-stream[action='update'][target='bookmark-list-container']" + ) + ) - url = ( - reverse("bookmarks:index.action") - + "?return_url=" - + reverse("bookmarks:settings.index") + # tag cloud update + self.assertIsNotNone( + soup.select_one( + "turbo-stream[action='update'][target='tag-cloud-container']" + ) ) + + # update event + self.assertInHTML( + """ + + """, + html, + ) + + def test_index_action_with_turbo_returns_bookmark_update(self): + fixture = self.bookmark_update_fixture() response = self.client.post( - url, - { - "bulk_action": ["bulk_archive"], - "bulk_execute": [""], - "bookmark_id": [ - str(bookmark1.id), - str(bookmark2.id), - str(bookmark3.id), - ], - }, + reverse("bookmarks:index.action"), + HTTP_ACCEPT="text/vnd.turbo-stream.html", ) - self.assertRedirects(response, reverse("bookmarks:settings.index")) + visible_tags = self.get_tags_from_bookmarks( + fixture["active"] + fixture["shared"] + ) + invisible_tags = self.get_tags_from_bookmarks(fixture["archived"]) - def test_should_not_redirect_to_external_url(self): - bookmark1 = self.setup_bookmark() - bookmark2 = self.setup_bookmark() - bookmark3 = self.setup_bookmark() + self.assertBookmarkUpdateResponse(response) + self.assertVisibleBookmarks(response, fixture["active"] + fixture["shared"]) + self.assertInvisibleBookmarks(response, fixture["archived"]) + self.assertVisibleTags(response, visible_tags) + self.assertInvisibleTags(response, invisible_tags) - def post_with(return_url, follow=None): - url = reverse("bookmarks:index.action") + f"?return_url={return_url}" - return self.client.post( - url, - { - "bulk_action": ["bulk_archive"], - "bulk_execute": [""], - "bookmark_id": [ - str(bookmark1.id), - str(bookmark2.id), - str(bookmark3.id), - ], - }, - follow=follow, - ) + def test_archived_action_with_turbo_returns_bookmark_update(self): + fixture = self.bookmark_update_fixture() + response = self.client.post( + reverse("bookmarks:archived.action"), + HTTP_ACCEPT="text/vnd.turbo-stream.html", + ) - response = post_with("https://example.com") - self.assertRedirects(response, reverse("bookmarks:index")) - response = post_with("//example.com") - self.assertRedirects(response, reverse("bookmarks:index")) - response = post_with("://example.com") - self.assertRedirects(response, reverse("bookmarks:index")) + visible_tags = self.get_tags_from_bookmarks(fixture["archived"]) + invisible_tags = self.get_tags_from_bookmarks( + fixture["active"] + fixture["shared"] + ) - response = post_with("/foo//example.com", follow=True) - self.assertEqual(response.status_code, 404) + self.assertBookmarkUpdateResponse(response) + self.assertVisibleBookmarks(response, fixture["archived"]) + self.assertInvisibleBookmarks(response, fixture["active"] + fixture["shared"]) + self.assertVisibleTags(response, visible_tags) + self.assertInvisibleTags(response, invisible_tags) + + def test_shared_action_with_turbo_returns_bookmark_update(self): + fixture = self.bookmark_update_fixture() + response = self.client.post( + reverse("bookmarks:shared.action"), + HTTP_ACCEPT="text/vnd.turbo-stream.html", + ) + + visible_tags = self.get_tags_from_bookmarks(fixture["shared"]) + invisible_tags = self.get_tags_from_bookmarks( + fixture["active"] + fixture["archived"] + ) + + self.assertBookmarkUpdateResponse(response) + self.assertVisibleBookmarks(response, fixture["shared"]) + self.assertInvisibleBookmarks(response, fixture["active"] + fixture["archived"]) + self.assertVisibleTags(response, visible_tags) + self.assertInvisibleTags(response, invisible_tags) diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py index 4b357b6e..040a5f8c 100644 --- a/bookmarks/tests/test_bookmark_archived_view.py +++ b/bookmarks/tests/test_bookmark_archived_view.py @@ -1,89 +1,26 @@ import urllib.parse -from typing import List from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse -from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile +from bookmarks.models import BookmarkSearch, UserProfile from bookmarks.tests.helpers import ( BookmarkFactoryMixin, - HtmlTestMixin, + BookmarkListTestMixin, + TagCloudTestMixin, collapse_whitespace, ) -class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): +class BookmarkArchivedViewTestCase( + TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin +): def setUp(self) -> None: user = self.get_or_create_test_user() self.client.force_login(user) - def assertVisibleBookmarks( - self, response, bookmarks: List[Bookmark], link_target: str = "_blank" - ): - soup = self.make_soup(response.content.decode()) - bookmark_list = soup.select_one( - f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]' - ) - self.assertIsNotNone(bookmark_list) - - bookmark_items = bookmark_list.select("li[ld-bookmark-item]") - self.assertEqual(len(bookmark_items), len(bookmarks)) - - for bookmark in bookmarks: - bookmark_item = bookmark_list.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' - ) - self.assertIsNotNone(bookmark_item) - - def assertInvisibleBookmarks( - self, response, bookmarks: List[Bookmark], link_target: str = "_blank" - ): - soup = self.make_soup(response.content.decode()) - - for bookmark in bookmarks: - bookmark_item = soup.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' - ) - self.assertIsNone(bookmark_item) - - def assertVisibleTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - tag_cloud = soup.select_one("div.tag-cloud") - self.assertIsNotNone(tag_cloud) - - tag_items = tag_cloud.select("a[data-is-tag-item]") - self.assertEqual(len(tag_items), len(tags)) - - tag_item_names = [tag_item.text.strip() for tag_item in tag_items] - - for tag in tags: - self.assertTrue(tag.name in tag_item_names) - - def assertInvisibleTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - tag_items = soup.select("a[data-is-tag-item]") - - tag_item_names = [tag_item.text.strip() for tag_item in tag_items] - - for tag in tags: - self.assertFalse(tag.name in tag_item_names) - - def assertSelectedTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - selected_tags = soup.select_one("p.selected-tags") - self.assertIsNotNone(selected_tags) - - tag_list = selected_tags.select("a") - self.assertEqual(len(tag_list), len(tags)) - - for tag in tags: - self.assertTrue( - tag.name in selected_tags.text, - msg=f"Selected tags do not contain: {tag.name}", - ) - def assertEditLink(self, response, url): html = response.content.decode() self.assertInHTML( @@ -307,24 +244,21 @@ def test_bulk_edit_respects_search_options(self): base_url = reverse("bookmarks:archived") # without params - return_url = urllib.parse.quote_plus(base_url) - url = f"{action_url}?return_url={return_url}" + url = f"{action_url}" response = self.client.get(base_url) self.assertBulkActionForm(response, url) # with query url_params = "?q=foo" - return_url = urllib.parse.quote_plus(base_url + url_params) - url = f"{action_url}?q=foo&return_url={return_url}" + url = f"{action_url}?q=foo" response = self.client.get(base_url + url_params) self.assertBulkActionForm(response, url) # with query and sort url_params = "?q=foo&sort=title_asc" - return_url = urllib.parse.quote_plus(base_url + url_params) - url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}" + url = f"{action_url}?q=foo&sort=title_asc" response = self.client.get(base_url + url_params) self.assertBulkActionForm(response, url) @@ -527,7 +461,7 @@ def test_url_encode_bookmark_actions_url(self): self.assertEqual( actions_form.attrs["action"], - "/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo", + "/bookmarks/archived/action?q=%23foo", ) def test_encode_search_params(self): @@ -557,3 +491,15 @@ def test_encode_search_params(self): url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)" response = self.client.get(url) self.assertNotContains(response, "alert('xss')") + + def test_turbo_frame_details_modal_renders_details_modal_update(self): + bookmark = self.setup_bookmark() + url = reverse("bookmarks:archived") + f"?bookmark_id={bookmark.id}" + response = self.client.get(url, headers={"Turbo-Frame": "details-modal"}) + + self.assertEqual(200, response.status_code) + + soup = self.make_soup(response.content.decode()) + self.assertIsNotNone(soup.select_one("turbo-frame#details-modal")) + self.assertIsNone(soup.select_one("#bookmark-list-container")) + self.assertIsNone(soup.select_one("#tag-cloud-container")) diff --git a/bookmarks/tests/test_bookmark_archived_view_performance.py b/bookmarks/tests/test_bookmark_archived_view_performance.py index 81042b6e..ea1f0af3 100644 --- a/bookmarks/tests/test_bookmark_archived_view_performance.py +++ b/bookmarks/tests/test_bookmark_archived_view_performance.py @@ -1,10 +1,10 @@ -from django.contrib.auth.models import User +from django.db import connections +from django.db.utils import DEFAULT_DB_ALIAS from django.test import TransactionTestCase from django.test.utils import CaptureQueriesContext from django.urls import reverse -from django.db import connections -from django.db.utils import DEFAULT_DB_ALIAS +from bookmarks.models import GlobalSettings from bookmarks.tests.helpers import BookmarkFactoryMixin @@ -20,9 +20,12 @@ def get_connection(self): return connections[DEFAULT_DB_ALIAS] def test_should_not_increase_number_of_queries_per_bookmark(self): + # create global settings + GlobalSettings.get() + # create initial bookmarks num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): self.setup_bookmark(user=self.user, is_archived=True) # capture number of queries @@ -37,7 +40,7 @@ def test_should_not_increase_number_of_queries_per_bookmark(self): # add more bookmarks num_additional_bookmarks = 10 - for index in range(num_additional_bookmarks): + for _ in range(num_additional_bookmarks): self.setup_bookmark(user=self.user, is_archived=True) # assert num queries doesn't increase diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py index a2ef4ad2..291d149c 100644 --- a/bookmarks/tests/test_bookmark_details_modal.py +++ b/bookmarks/tests/test_bookmark_details_modal.py @@ -1,14 +1,11 @@ import datetime import re -from unittest.mock import patch -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from django.urls import reverse from django.utils import formats, timezone from bookmarks.models import BookmarkAsset, UserProfile -from bookmarks.services import bookmarks, tasks from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin @@ -17,23 +14,23 @@ def setUp(self): user = self.get_or_create_test_user() self.client.force_login(user) - def get_view_name(self): - return "bookmarks:details_modal" - - def get_base_url(self, bookmark): - return reverse(self.get_view_name(), args=[bookmark.id]) - def get_details_form(self, soup, bookmark): - expected_url = reverse("bookmarks:details", args=[bookmark.id]) - return soup.find("form", {"action": expected_url}) + form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}" + return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"}) + + def get_index_details_modal(self, bookmark): + url = reverse("bookmarks:index") + 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 get_details(self, bookmark, return_url=""): - url = self.get_base_url(bookmark) - if return_url: - url += f"?return_url={return_url}" + 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) - return soup + modal = soup.find("turbo-frame", {"id": "details-modal"}) + return modal def find_section(self, soup, section_name): dt = soup.find("dt", string=section_name) @@ -54,35 +51,68 @@ def count_weblinks(self, soup): def find_asset(self, soup, asset): return soup.find("div", {"data-asset-id": asset.id}) - def details_route_access_test(self, view_name: str, shareable: bool): + def details_route_access_test(self): # own bookmark bookmark = self.setup_bookmark() - - response = self.client.get(reverse(view_name, args=[bookmark.id])) + response = self.client.get( + reverse("bookmarks:index") + f"?details={bookmark.id}" + ) self.assertEqual(response.status_code, 200) # other user's bookmark other_user = self.setup_user() bookmark = self.setup_bookmark(user=other_user) + response = self.client.get( + reverse("bookmarks:index") + f"?details={bookmark.id}" + ) + self.assertEqual(response.status_code, 404) - response = self.client.get(reverse(view_name, args=[bookmark.id])) + # 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) - # non-existent bookmark - response = self.client.get(reverse(view_name, args=[9999])) + 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(view_name, args=[bookmark.id])) - self.assertEqual(response.status_code, 404 if shareable else 302) + response = self.client.get( + reverse("bookmarks:shared") + f"?details={bookmark.id}" + ) + self.assertEqual(response.status_code, 404) - def details_route_sharing_access_test(self, view_name: str, shareable: bool): + def test_access_with_sharing(self): # shared bookmark, sharing disabled other_user = self.setup_user() bookmark = self.setup_bookmark(shared=True, user=other_user) - response = self.client.get(reverse(view_name, args=[bookmark.id])) + response = self.client.get( + reverse("bookmarks:shared") + f"?details={bookmark.id}" + ) self.assertEqual(response.status_code, 404) # shared bookmark, sharing enabled @@ -90,37 +120,31 @@ def details_route_sharing_access_test(self, view_name: str, shareable: bool): profile.enable_sharing = True profile.save() - response = self.client.get(reverse(view_name, args=[bookmark.id])) - self.assertEqual(response.status_code, 200 if shareable else 404) + response = self.client.get( + reverse("bookmarks:shared") + f"?details={bookmark.id}" + ) + self.assertEqual(response.status_code, 200) # shared bookmark, guest user, no public sharing self.client.logout() - response = self.client.get(reverse(view_name, args=[bookmark.id])) - self.assertEqual(response.status_code, 404 if shareable else 302) + response = self.client.get( + reverse("bookmarks:shared") + f"?details={bookmark.id}" + ) + self.assertEqual(response.status_code, 404) # shared bookmark, guest user, public sharing profile.enable_public_sharing = True profile.save() - response = self.client.get(reverse(view_name, args=[bookmark.id])) - self.assertEqual(response.status_code, 200 if shareable else 302) - - def test_access(self): - self.details_route_access_test(self.get_view_name(), True) - - def test_access_with_sharing(self): - self.details_route_sharing_access_test(self.get_view_name(), True) - - def test_assets_access(self): - self.details_route_access_test("bookmarks:details_assets", True) - - def test_assets_access_with_sharing(self): - self.details_route_sharing_access_test("bookmarks:details_assets", True) + response = self.client.get( + reverse("bookmarks:shared") + f"?details={bookmark.id}" + ) + self.assertEqual(response.status_code, 200) def test_displays_title(self): # with title bookmark = self.setup_bookmark(title="Test title") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) title = soup.find("h2") self.assertIsNotNone(title) @@ -128,7 +152,7 @@ def test_displays_title(self): # with website title bookmark = self.setup_bookmark(title="", website_title="Website title") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) title = soup.find("h2") self.assertIsNotNone(title) @@ -136,7 +160,7 @@ def test_displays_title(self): # with URL only bookmark = self.setup_bookmark(title="", website_title="") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) title = soup.find("h2") self.assertIsNotNone(title) @@ -145,7 +169,7 @@ def test_displays_title(self): def test_website_link(self): # basics bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.url) self.assertIsNotNone(link) self.assertEqual(link["href"], bookmark.url) @@ -153,7 +177,7 @@ def test_website_link(self): # favicons disabled bookmark = self.setup_bookmark(favicon_file="example.png") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.url) image = link.select_one("img") self.assertIsNone(image) @@ -164,14 +188,14 @@ def test_website_link(self): profile.save() bookmark = self.setup_bookmark(favicon_file="") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.url) image = link.select_one("img") self.assertIsNone(image) # favicons enabled, favicon present bookmark = self.setup_bookmark(favicon_file="example.png") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.url) image = link.select_one("img") self.assertIsNotNone(image) @@ -180,7 +204,7 @@ def test_website_link(self): def test_reader_mode_link(self): # no latest snapshot bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) self.assertEqual(self.count_weblinks(soup), 2) # snapshot is not complete @@ -194,7 +218,7 @@ def test_reader_mode_link(self): asset_type=BookmarkAsset.TYPE_SNAPSHOT, status=BookmarkAsset.STATUS_FAILURE, ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) self.assertEqual(self.count_weblinks(soup), 2) # not a snapshot @@ -203,7 +227,7 @@ def test_reader_mode_link(self): asset_type="upload", status=BookmarkAsset.STATUS_COMPLETE, ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) self.assertEqual(self.count_weblinks(soup), 2) # snapshot is complete @@ -212,7 +236,7 @@ def test_reader_mode_link(self): asset_type=BookmarkAsset.TYPE_SNAPSHOT, status=BookmarkAsset.STATUS_COMPLETE, ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) self.assertEqual(self.count_weblinks(soup), 3) reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id]) @@ -221,7 +245,7 @@ def test_reader_mode_link(self): def test_internet_archive_link_with_snapshot_url(self): bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) self.assertIsNotNone(link) self.assertEqual(link["href"], bookmark.web_archive_snapshot_url) @@ -231,7 +255,7 @@ def test_internet_archive_link_with_snapshot_url(self): bookmark = self.setup_bookmark( web_archive_snapshot_url="https://example.com/", favicon_file="example.png" ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) image = link.select_one("svg") self.assertIsNone(image) @@ -244,7 +268,7 @@ def test_internet_archive_link_with_snapshot_url(self): bookmark = self.setup_bookmark( web_archive_snapshot_url="https://example.com/", favicon_file="" ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) image = link.select_one("svg") self.assertIsNone(image) @@ -253,7 +277,7 @@ def test_internet_archive_link_with_snapshot_url(self): bookmark = self.setup_bookmark( web_archive_snapshot_url="https://example.com/", favicon_file="example.png" ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) image = link.select_one("svg") self.assertIsNotNone(image) @@ -267,7 +291,7 @@ def test_internet_archive_link_with_fallback_url(self): "https://web.archive.org/web/20230811214511/https://example.com/" ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) link = self.find_weblink(soup, fallback_web_archive_url) self.assertIsNotNone(link) self.assertEqual(link["href"], fallback_web_archive_url) @@ -281,7 +305,7 @@ def test_weblinks_respect_target_setting(self): profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK profile.save() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) website_link = self.find_weblink(soup, bookmark.url) self.assertIsNotNone(website_link) @@ -297,7 +321,7 @@ def test_weblinks_respect_target_setting(self): profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF profile.save() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) website_link = self.find_weblink(soup, bookmark.url) self.assertIsNotNone(website_link) @@ -312,13 +336,13 @@ def test_weblinks_respect_target_setting(self): def test_preview_image(self): # without image bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) image = soup.select_one("div.preview-image img") self.assertIsNone(image) # with image bookmark = self.setup_bookmark(preview_image_file="example.png") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) image = soup.select_one("div.preview-image img") self.assertIsNone(image) @@ -328,13 +352,13 @@ def test_preview_image(self): profile.save() bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) image = soup.select_one("div.preview-image img") self.assertIsNone(image) # preview images enabled, image present bookmark = self.setup_bookmark(preview_image_file="example.png") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) image = soup.select_one("div.preview-image img") self.assertIsNotNone(image) self.assertEqual(image["src"], "/static/example.png") @@ -342,18 +366,15 @@ def test_preview_image(self): def test_status(self): # renders form bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) form = self.get_details_form(soup, bookmark) self.assertIsNotNone(form) - self.assertEqual( - form["action"], reverse("bookmarks:details", args=[bookmark.id]) - ) self.assertEqual(form["method"], "post") # sharing disabled bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Status") archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) @@ -369,7 +390,7 @@ def test_status(self): profile.save() bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Status") archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) @@ -381,7 +402,7 @@ def test_status(self): # unchecked bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Status") archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) @@ -393,7 +414,7 @@ def test_status(self): # checked bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Status") archived = section.find("input", {"type": "checkbox", "name": "is_archived"}) @@ -406,106 +427,29 @@ def test_status(self): def test_status_visibility(self): # own bookmark bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.find_section(soup, "Status") self.assertIsNotNone(section) # other user's bookmark other_user = self.setup_user(enable_sharing=True) bookmark = self.setup_bookmark(user=other_user, shared=True) - soup = self.get_details(bookmark) + soup = self.get_shared_details_modal(bookmark) section = self.find_section(soup, "Status") self.assertIsNone(section) # guest user self.client.logout() + other_user.profile.enable_public_sharing = True + other_user.profile.save() bookmark = self.setup_bookmark(user=other_user, shared=True) - soup = self.get_details(bookmark) + soup = self.get_shared_details_modal(bookmark) section = self.find_section(soup, "Status") self.assertIsNone(section) - def test_status_update(self): - bookmark = self.setup_bookmark() - - # update status - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "on", "unread": "on", "shared": "on"}, - ) - self.assertEqual(response.status_code, 302) - - bookmark.refresh_from_db() - self.assertTrue(bookmark.is_archived) - self.assertTrue(bookmark.unread) - self.assertTrue(bookmark.shared) - - # update individual status - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "", "unread": "on", "shared": ""}, - ) - self.assertEqual(response.status_code, 302) - - bookmark.refresh_from_db() - self.assertFalse(bookmark.is_archived) - self.assertTrue(bookmark.unread) - self.assertFalse(bookmark.shared) - - def test_status_update_access(self): - # no sharing - other_user = self.setup_user() - bookmark = self.setup_bookmark(user=other_user) - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "on", "unread": "on", "shared": "on"}, - ) - self.assertEqual(response.status_code, 404) - - # shared, sharing disabled - bookmark = self.setup_bookmark(user=other_user, shared=True) - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "on", "unread": "on", "shared": "on"}, - ) - self.assertEqual(response.status_code, 404) - - # shared, sharing enabled - bookmark = self.setup_bookmark(user=other_user, shared=True) - profile = other_user.profile - profile.enable_sharing = True - profile.save() - - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "on", "unread": "on", "shared": "on"}, - ) - self.assertEqual(response.status_code, 404) - - # shared, public sharing enabled - bookmark = self.setup_bookmark(user=other_user, shared=True) - profile = other_user.profile - profile.enable_public_sharing = True - profile.save() - - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "on", "unread": "on", "shared": "on"}, - ) - self.assertEqual(response.status_code, 404) - - # guest user - self.client.logout() - bookmark = self.setup_bookmark(user=other_user, shared=True) - - response = self.client.post( - self.get_base_url(bookmark), - {"is_archived": "on", "unread": "on", "shared": "on"}, - ) - self.assertEqual(response.status_code, 404) - def test_date_added(self): bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Date added") expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT") @@ -515,7 +459,7 @@ def test_date_added(self): def test_tags(self): # without tags bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.find_section(soup, "Tags") self.assertIsNone(section) @@ -523,7 +467,7 @@ def test_tags(self): # with tags bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()]) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Tags") for tag in bookmark.tags.all(): @@ -535,14 +479,14 @@ def test_tags(self): def test_description(self): # without description bookmark = self.setup_bookmark(description="", website_description="") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.find_section(soup, "Description") self.assertIsNone(section) # with description bookmark = self.setup_bookmark(description="Test description") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Description") self.assertEqual(section.text.strip(), bookmark.description) @@ -551,7 +495,7 @@ def test_description(self): bookmark = self.setup_bookmark( description="", website_description="Website description" ) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Description") self.assertEqual(section.text.strip(), bookmark.website_description) @@ -559,14 +503,14 @@ def test_description(self): def test_notes(self): # without notes bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.find_section(soup, "Notes") self.assertIsNone(section) # with notes bookmark = self.setup_bookmark(notes="Test notes") - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Notes") self.assertEqual(section.decode_contents(), "

Test notes

") @@ -575,52 +519,42 @@ def test_edit_link(self): bookmark = self.setup_bookmark() # with default return URL - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) edit_link = soup.find("a", string="Edit") self.assertIsNotNone(edit_link) - details_url = reverse("bookmarks:details", args=[bookmark.id]) - expected_url = ( - reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url - ) - self.assertEqual(edit_link["href"], expected_url) - - # with custom return URL - soup = self.get_details(bookmark, return_url="/custom") - edit_link = soup.find("a", string="Edit") - self.assertIsNotNone(edit_link) - expected_url = ( - reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom" - ) - self.assertEqual(edit_link["href"], expected_url) + details_url = reverse("bookmarks:index") + f"?details={bookmark.id}" + expected_url = "/bookmarks/1/edit?return_url=/bookmarks%3Fdetails%3D1" + self.assertEqual(expected_url, edit_link["href"]) def test_delete_button(self): bookmark = self.setup_bookmark() - # basics - soup = self.get_details(bookmark) - delete_button = soup.find("button", {"type": "submit", "name": "remove"}) + modal = self.get_index_details_modal(bookmark) + delete_button = modal.find("button", {"type": "submit", "name": "remove"}) self.assertIsNotNone(delete_button) - self.assertEqual(delete_button.text.strip(), "Delete...") - self.assertEqual(delete_button["value"], str(bookmark.id)) + self.assertEqual("Delete...", delete_button.text.strip()) + self.assertEqual(str(bookmark.id), delete_button["value"]) form = delete_button.find_parent("form") self.assertIsNotNone(form) - expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks" - self.assertEqual(form["action"], expected_url) + expected_url = reverse("bookmarks:index.action") + self.assertEqual(expected_url, form["action"]) - # with custom return URL - soup = self.get_details(bookmark, return_url="/custom") + 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"}) - form = delete_button.find_parent("form") - expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom" - self.assertEqual(form["action"], expected_url) + self.assertIsNotNone(edit_link) + self.assertIsNotNone(delete_button) - def test_actions_visibility(self): # with sharing other_user = self.setup_user(enable_sharing=True) bookmark = self.setup_bookmark(user=other_user, shared=True) - soup = self.get_details(bookmark) + soup = self.get_shared_details_modal(bookmark) edit_link = soup.find("a", string="Edit") delete_button = soup.find("button", {"type": "submit", "name": "remove"}) self.assertIsNone(edit_link) @@ -632,7 +566,7 @@ def test_actions_visibility(self): profile.save() bookmark = self.setup_bookmark(user=other_user, shared=True) - soup = self.get_details(bookmark) + soup = self.get_shared_details_modal(bookmark) edit_link = soup.find("a", string="Edit") delete_button = soup.find("button", {"type": "submit", "name": "remove"}) self.assertIsNone(edit_link) @@ -642,7 +576,7 @@ def test_actions_visibility(self): self.client.logout() bookmark = self.setup_bookmark(user=other_user, shared=True) - soup = self.get_details(bookmark) + soup = self.get_shared_details_modal(bookmark) edit_link = soup.find("a", string="Edit") delete_button = soup.find("button", {"type": "submit", "name": "remove"}) self.assertIsNone(edit_link) @@ -651,7 +585,7 @@ def test_actions_visibility(self): def test_assets_visibility_no_snapshot_support(self): bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.find_section(soup, "Files") self.assertIsNone(section) @@ -659,7 +593,7 @@ def test_assets_visibility_no_snapshot_support(self): def test_assets_visibility_with_snapshot_support(self): bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.find_section(soup, "Files") self.assertIsNotNone(section) @@ -668,7 +602,7 @@ def test_asset_list_visibility(self): # no assets bookmark = self.setup_bookmark() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Files") asset_list = section.find("div", {"class": "assets"}) self.assertIsNone(asset_list) @@ -677,7 +611,7 @@ def test_asset_list_visibility(self): bookmark = self.setup_bookmark() self.setup_asset(bookmark) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Files") asset_list = section.find("div", {"class": "assets"}) self.assertIsNotNone(asset_list) @@ -691,7 +625,7 @@ def test_asset_list(self): self.setup_asset(bookmark), ] - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) section = self.get_section(soup, "Files") asset_list = section.find("div", {"class": "assets"}) @@ -717,7 +651,7 @@ def test_asset_without_file(self): asset.file = "" asset.save() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) asset_item = self.find_asset(soup, asset) view_url = reverse("bookmarks:assets.view", args=[asset.id]) view_link = asset_item.find("a", {"href": view_url}) @@ -729,7 +663,7 @@ def test_asset_status(self): pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING) failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) asset_item = self.find_asset(soup, pending_asset) asset_text = asset_item.select_one(".asset-text span") @@ -746,7 +680,7 @@ def test_asset_file_size(self): asset2 = self.setup_asset(bookmark, file_size=54639) asset3 = self.setup_asset(bookmark, file_size=11492020) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) asset_item = self.find_asset(soup, asset1) asset_text = asset_item.select_one(".asset-text") @@ -766,7 +700,7 @@ def test_asset_actions_visibility(self): # with file asset = self.setup_asset(bookmark) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) asset_item = self.find_asset(soup, asset) view_link = asset_item.find("a", string="View") @@ -779,7 +713,7 @@ def test_asset_actions_visibility(self): # without file asset.file = "" asset.save() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) asset_item = self.find_asset(soup, asset) view_link = asset_item.find("a", string="View") @@ -793,7 +727,7 @@ def test_asset_actions_visibility(self): other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True) bookmark = self.setup_bookmark(shared=True, user=other_user) asset = self.setup_asset(bookmark) - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) asset_item = self.find_asset(soup, asset) view_link = asset_item.find("a", string="View") @@ -805,7 +739,7 @@ def test_asset_actions_visibility(self): # shared bookmark, guest user self.client.logout() - soup = self.get_details(bookmark) + soup = self.get_shared_details_modal(bookmark) asset_item = self.find_asset(soup, asset) view_link = asset_item.find("a", string="View") @@ -815,77 +749,13 @@ def test_asset_actions_visibility(self): self.assertIsNotNone(view_link) self.assertIsNone(delete_button) - def test_remove_asset(self): - # remove asset - bookmark = self.setup_bookmark() - asset = self.setup_asset(bookmark) - - response = self.client.post( - self.get_base_url(bookmark), {"remove_asset": asset.id} - ) - self.assertEqual(response.status_code, 302) - self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists()) - - # non-existent asset - response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999}) - self.assertEqual(response.status_code, 404) - - # post without asset ID does not remove - asset = self.setup_asset(bookmark) - response = self.client.post(self.get_base_url(bookmark)) - self.assertEqual(response.status_code, 302) - self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) - - # guest user - asset = self.setup_asset(bookmark) - self.client.logout() - response = self.client.post( - self.get_base_url(bookmark), {"remove_asset": asset.id} - ) - self.assertEqual(response.status_code, 404) - self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) - - @override_settings(LD_ENABLE_SNAPSHOTS=True) - def test_assets_refresh_when_having_pending_asset(self): - bookmark = self.setup_bookmark() - asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE) - fetch_url = reverse("bookmarks:details_assets", args=[bookmark.id]) - - # no pending asset - soup = self.get_details(bookmark) - files_section = self.find_section(soup, "Files") - assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url}) - self.assertIsNone(assets_wrapper) - - # with pending asset - asset.status = BookmarkAsset.STATUS_PENDING - asset.save() - - soup = self.get_details(bookmark) - files_section = self.find_section(soup, "Files") - assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url}) - self.assertIsNotNone(assets_wrapper) - - @override_settings(LD_ENABLE_SNAPSHOTS=True) - def test_create_snapshot(self): - with patch.object( - tasks, "_create_html_snapshot_task" - ) as mock_create_html_snapshot_task: - bookmark = self.setup_bookmark() - response = self.client.post( - self.get_base_url(bookmark), {"create_snapshot": ""} - ) - self.assertEqual(response.status_code, 302) - - self.assertEqual(bookmark.bookmarkasset_set.count(), 1) - @override_settings(LD_ENABLE_SNAPSHOTS=True) def test_create_snapshot_is_disabled_when_having_pending_asset(self): bookmark = self.setup_bookmark() asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE) # no pending asset - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) files_section = self.find_section(soup, "Files") create_button = files_section.find( "button", string=re.compile("Create HTML snapshot") @@ -896,40 +766,9 @@ def test_create_snapshot_is_disabled_when_having_pending_asset(self): asset.status = BookmarkAsset.STATUS_PENDING asset.save() - soup = self.get_details(bookmark) + soup = self.get_index_details_modal(bookmark) files_section = self.find_section(soup, "Files") create_button = files_section.find( "button", string=re.compile("Create HTML snapshot") ) self.assertTrue(create_button.has_attr("disabled")) - - def test_upload_file(self): - bookmark = self.setup_bookmark() - file_content = b"file content" - upload_file = SimpleUploadedFile("test.txt", file_content) - - with patch.object(bookmarks, "upload_asset") as mock_upload_asset: - response = self.client.post( - self.get_base_url(bookmark), - {"upload_asset": "", "upload_asset_file": upload_file}, - ) - self.assertEqual(response.status_code, 302) - - mock_upload_asset.assert_called_once() - - args, kwargs = mock_upload_asset.call_args - self.assertEqual(args[0], bookmark) - - upload_file = args[1] - self.assertEqual(upload_file.name, "test.txt") - - def test_upload_file_without_file(self): - bookmark = self.setup_bookmark() - - with patch.object(bookmarks, "upload_asset") as mock_upload_asset: - response = self.client.post( - self.get_base_url(bookmark), - {"upload_asset": ""}, - ) - self.assertEqual(response.status_code, 400) - mock_upload_asset.assert_not_called() diff --git a/bookmarks/tests/test_bookmark_details_view.py b/bookmarks/tests/test_bookmark_details_view.py deleted file mode 100644 index 36824de0..00000000 --- a/bookmarks/tests/test_bookmark_details_view.py +++ /dev/null @@ -1,6 +0,0 @@ -from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase - - -class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase): - def get_view_name(self): - return "bookmarks:details" diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py index ee5de9f1..68fbafbf 100644 --- a/bookmarks/tests/test_bookmark_edit_view.py +++ b/bookmarks/tests/test_bookmark_edit_view.py @@ -98,7 +98,7 @@ def test_should_prefill_bookmark_form_fields(self): tag_string = build_tag_string(bookmark.tag_names, " ") self.assertInHTML( f""" - """, html, diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py index 608f2817..8a0c680c 100644 --- a/bookmarks/tests/test_bookmark_index_view.py +++ b/bookmarks/tests/test_bookmark_index_view.py @@ -1,85 +1,24 @@ import urllib.parse -from typing import List from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse -from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile -from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin +from bookmarks.models import BookmarkSearch, UserProfile +from bookmarks.tests.helpers import ( + BookmarkFactoryMixin, + BookmarkListTestMixin, + TagCloudTestMixin, +) -class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): - +class BookmarkIndexViewTestCase( + TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin +): def setUp(self) -> None: user = self.get_or_create_test_user() self.client.force_login(user) - def assertVisibleBookmarks( - self, response, bookmarks: List[Bookmark], link_target: str = "_blank" - ): - soup = self.make_soup(response.content.decode()) - bookmark_list = soup.select_one( - f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]' - ) - self.assertIsNotNone(bookmark_list) - - bookmark_items = bookmark_list.select("li[ld-bookmark-item]") - self.assertEqual(len(bookmark_items), len(bookmarks)) - - for bookmark in bookmarks: - bookmark_item = bookmark_list.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' - ) - self.assertIsNotNone(bookmark_item) - - def assertInvisibleBookmarks( - self, response, bookmarks: List[Bookmark], link_target: str = "_blank" - ): - soup = self.make_soup(response.content.decode()) - - for bookmark in bookmarks: - bookmark_item = soup.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' - ) - self.assertIsNone(bookmark_item) - - def assertVisibleTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - tag_cloud = soup.select_one("div.tag-cloud") - self.assertIsNotNone(tag_cloud) - - tag_items = tag_cloud.select("a[data-is-tag-item]") - self.assertEqual(len(tag_items), len(tags)) - - tag_item_names = [tag_item.text.strip() for tag_item in tag_items] - - for tag in tags: - self.assertTrue(tag.name in tag_item_names) - - def assertInvisibleTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - tag_items = soup.select("a[data-is-tag-item]") - - tag_item_names = [tag_item.text.strip() for tag_item in tag_items] - - for tag in tags: - self.assertFalse(tag.name in tag_item_names) - - def assertSelectedTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - selected_tags = soup.select_one("p.selected-tags") - self.assertIsNotNone(selected_tags) - - tag_list = selected_tags.select("a") - self.assertEqual(len(tag_list), len(tags)) - - for tag in tags: - self.assertTrue( - tag.name in selected_tags.text, - msg=f"Selected tags do not contain: {tag.name}", - ) - def assertEditLink(self, response, url): html = response.content.decode() self.assertInHTML( @@ -285,24 +224,21 @@ def test_bulk_edit_respects_search_options(self): base_url = reverse("bookmarks:index") # without params - return_url = urllib.parse.quote_plus(base_url) - url = f"{action_url}?return_url={return_url}" + url = f"{action_url}" response = self.client.get(base_url) self.assertBulkActionForm(response, url) # with query url_params = "?q=foo" - return_url = urllib.parse.quote_plus(base_url + url_params) - url = f"{action_url}?q=foo&return_url={return_url}" + url = f"{action_url}?q=foo" response = self.client.get(base_url + url_params) self.assertBulkActionForm(response, url) # with query and sort url_params = "?q=foo&sort=title_asc" - return_url = urllib.parse.quote_plus(base_url + url_params) - url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}" + url = f"{action_url}?q=foo&sort=title_asc" response = self.client.get(base_url + url_params) self.assertBulkActionForm(response, url) @@ -503,7 +439,7 @@ def test_url_encode_bookmark_actions_url(self): self.assertEqual( actions_form.attrs["action"], - "/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo", + "/bookmarks/action?q=%23foo", ) def test_encode_search_params(self): @@ -533,3 +469,15 @@ def test_encode_search_params(self): url = reverse("bookmarks:index") + "?page=alert(%27xss%27)" response = self.client.get(url) self.assertNotContains(response, "alert('xss')") + + def test_turbo_frame_details_modal_renders_details_modal_update(self): + bookmark = self.setup_bookmark() + url = reverse("bookmarks:index") + f"?bookmark_id={bookmark.id}" + response = self.client.get(url, headers={"Turbo-Frame": "details-modal"}) + + self.assertEqual(200, response.status_code) + + soup = self.make_soup(response.content.decode()) + self.assertIsNotNone(soup.select_one("turbo-frame#details-modal")) + self.assertIsNone(soup.select_one("#bookmark-list-container")) + self.assertIsNone(soup.select_one("#tag-cloud-container")) diff --git a/bookmarks/tests/test_bookmark_index_view_performance.py b/bookmarks/tests/test_bookmark_index_view_performance.py index ac508951..8f84469a 100644 --- a/bookmarks/tests/test_bookmark_index_view_performance.py +++ b/bookmarks/tests/test_bookmark_index_view_performance.py @@ -1,10 +1,10 @@ -from django.contrib.auth.models import User +from django.db import connections +from django.db.utils import DEFAULT_DB_ALIAS from django.test import TransactionTestCase from django.test.utils import CaptureQueriesContext from django.urls import reverse -from django.db import connections -from django.db.utils import DEFAULT_DB_ALIAS +from bookmarks.models import GlobalSettings from bookmarks.tests.helpers import BookmarkFactoryMixin @@ -18,9 +18,12 @@ def get_connection(self): return connections[DEFAULT_DB_ALIAS] def test_should_not_increase_number_of_queries_per_bookmark(self): + # create global settings + GlobalSettings.get() + # create initial bookmarks num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): self.setup_bookmark(user=self.user) # capture number of queries @@ -35,7 +38,7 @@ def test_should_not_increase_number_of_queries_per_bookmark(self): # add more bookmarks num_additional_bookmarks = 10 - for index in range(num_additional_bookmarks): + for _ in range(num_additional_bookmarks): self.setup_bookmark(user=self.user) # assert num queries doesn't increase diff --git a/bookmarks/tests/test_bookmark_new_view.py b/bookmarks/tests/test_bookmark_new_view.py index 7980bcaa..02c76ce2 100644 --- a/bookmarks/tests/test_bookmark_new_view.py +++ b/bookmarks/tests/test_bookmark_new_view.py @@ -115,9 +115,6 @@ def test_should_prefill_notes_from_url_parameter(self): -
- Additional notes, supports Markdown. -
""", html, diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py index d40bf479..e8c016f6 100644 --- a/bookmarks/tests/test_bookmark_search_tag.py +++ b/bookmarks/tests/test_bookmark_search_tag.py @@ -1,16 +1,13 @@ from bs4 import BeautifulSoup -from django.db.models import QuerySet from django.template import Template, RequestContext from django.test import TestCase, RequestFactory -from bookmarks.models import BookmarkSearch, Tag +from bookmarks.models import BookmarkSearch from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): - def render_template( - self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = "" - ): + def render_template(self, url: str, mode: str = ""): rf = RequestFactory() request = rf.get(url) request.user = self.get_or_create_test_user() @@ -21,32 +18,31 @@ def render_template( { "request": request, "search": search, - "tags": tags, "mode": mode, }, ) template_to_render = Template( - "{% load bookmarks %}" "{% bookmark_search search tags mode %}" + "{% load bookmarks %} {% bookmark_search search mode %}" ) return template_to_render.render(context) def assertHiddenInput(self, form: BeautifulSoup, name: str, value: str = None): - input = form.select_one(f'input[name="{name}"][type="hidden"]') - self.assertIsNotNone(input) + element = form.select_one(f'input[name="{name}"][type="hidden"]') + self.assertIsNotNone(element) if value is not None: - self.assertEqual(input["value"], value) + self.assertEqual(element["value"], value) def assertNoHiddenInput(self, form: BeautifulSoup, name: str): - input = form.select_one(f'input[name="{name}"][type="hidden"]') - self.assertIsNone(input) + element = form.select_one(f'input[name="{name}"][type="hidden"]') + self.assertIsNone(element) def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None): - input = form.select_one(f'input[name="{name}"][type="search"]') - self.assertIsNotNone(input) + element = form.select_one(f'input[name="{name}"][type="search"]') + self.assertIsNotNone(element) if value is not None: - self.assertEqual(input["value"], value) + self.assertEqual(element["value"], value) def assertSelect(self, form: BeautifulSoup, name: str, value: str = None): select = form.select_one(f'select[name="{name}"]') diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py index a20fd2f2..c54886bd 100644 --- a/bookmarks/tests/test_bookmark_shared_view.py +++ b/bookmarks/tests/test_bookmark_shared_view.py @@ -6,11 +6,16 @@ from django.urls import reverse from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile -from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin +from bookmarks.tests.helpers import ( + BookmarkFactoryMixin, + BookmarkListTestMixin, + TagCloudTestMixin, +) -class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): - +class BookmarkSharedViewTestCase( + TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin +): def authenticate(self) -> None: user = self.get_or_create_test_user() self.client.force_login(user) @@ -24,57 +29,6 @@ def assertBookmarkCount( count=count, ) - def assertVisibleBookmarks( - self, response, bookmarks: List[Bookmark], link_target: str = "_blank" - ): - soup = self.make_soup(response.content.decode()) - bookmark_list = soup.select_one( - f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]' - ) - self.assertIsNotNone(bookmark_list) - - bookmark_items = bookmark_list.select("li[ld-bookmark-item]") - self.assertEqual(len(bookmark_items), len(bookmarks)) - - for bookmark in bookmarks: - bookmark_item = bookmark_list.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' - ) - self.assertIsNotNone(bookmark_item) - - def assertInvisibleBookmarks( - self, response, bookmarks: List[Bookmark], link_target: str = "_blank" - ): - soup = self.make_soup(response.content.decode()) - - for bookmark in bookmarks: - bookmark_item = soup.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' - ) - self.assertIsNone(bookmark_item) - - def assertVisibleTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - tag_cloud = soup.select_one("div.tag-cloud") - self.assertIsNotNone(tag_cloud) - - tag_items = tag_cloud.select("a[data-is-tag-item]") - self.assertEqual(len(tag_items), len(tags)) - - tag_item_names = [tag_item.text.strip() for tag_item in tag_items] - - for tag in tags: - self.assertTrue(tag.name in tag_item_names) - - def assertInvisibleTags(self, response, tags: List[Tag]): - soup = self.make_soup(response.content.decode()) - tag_items = soup.select("a[data-is-tag-item]") - - tag_item_names = [tag_item.text.strip() for tag_item in tag_items] - - for tag in tags: - self.assertFalse(tag.name in tag_item_names) - def assertVisibleUserOptions(self, response, users: List[User]): html = response.content.decode() @@ -84,7 +38,7 @@ def assertVisibleUserOptions(self, response, users: List[User]): f'' ) user_select_html = f""" - {''.join(user_options)} """ @@ -593,7 +547,7 @@ def test_url_encode_bookmark_actions_url(self): self.assertEqual( actions_form.attrs["action"], - "/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo", + "/bookmarks/shared/action?q=%23foo", ) def test_encode_search_params(self): @@ -627,3 +581,15 @@ def test_encode_search_params(self): url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)" response = self.client.get(url) self.assertNotContains(response, "alert('xss')") + + def test_turbo_frame_details_modal_renders_details_modal_update(self): + bookmark = self.setup_bookmark() + url = reverse("bookmarks:shared") + f"?bookmark_id={bookmark.id}" + response = self.client.get(url, headers={"Turbo-Frame": "details-modal"}) + + self.assertEqual(200, response.status_code) + + soup = self.make_soup(response.content.decode()) + self.assertIsNotNone(soup.select_one("turbo-frame#details-modal")) + self.assertIsNone(soup.select_one("#bookmark-list-container")) + self.assertIsNone(soup.select_one("#tag-cloud-container")) diff --git a/bookmarks/tests/test_bookmark_shared_view_performance.py b/bookmarks/tests/test_bookmark_shared_view_performance.py index 05ce95f2..872340d4 100644 --- a/bookmarks/tests/test_bookmark_shared_view_performance.py +++ b/bookmarks/tests/test_bookmark_shared_view_performance.py @@ -1,10 +1,10 @@ -from django.contrib.auth.models import User +from django.db import connections +from django.db.utils import DEFAULT_DB_ALIAS from django.test import TransactionTestCase from django.test.utils import CaptureQueriesContext from django.urls import reverse -from django.db import connections -from django.db.utils import DEFAULT_DB_ALIAS +from bookmarks.models import GlobalSettings from bookmarks.tests.helpers import BookmarkFactoryMixin @@ -18,9 +18,12 @@ def get_connection(self): return connections[DEFAULT_DB_ALIAS] def test_should_not_increase_number_of_queries_per_bookmark(self): + # create global settings + GlobalSettings.get() + # create initial users and bookmarks num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): user = self.setup_user(enable_sharing=True) self.setup_bookmark(user=user, shared=True) @@ -36,7 +39,7 @@ def test_should_not_increase_number_of_queries_per_bookmark(self): # add more users and bookmarks num_additional_bookmarks = 10 - for index in range(num_additional_bookmarks): + for _ in range(num_additional_bookmarks): user = self.setup_user(enable_sharing=True) self.setup_bookmark(user=user, shared=True) diff --git a/bookmarks/tests/test_bookmarks_api_performance.py b/bookmarks/tests/test_bookmarks_api_performance.py index 0c8d26ec..61572d35 100644 --- a/bookmarks/tests/test_bookmarks_api_performance.py +++ b/bookmarks/tests/test_bookmarks_api_performance.py @@ -5,6 +5,7 @@ from rest_framework import status from rest_framework.authtoken.models import Token +from bookmarks.models import GlobalSettings from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin @@ -16,13 +17,16 @@ def setUp(self) -> None: )[0] self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key) + # create global settings + GlobalSettings.get() + def get_connection(self): return connections[DEFAULT_DB_ALIAS] def test_list_bookmarks_max_queries(self): # set up some bookmarks with associated tags num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): self.setup_bookmark(tags=[self.setup_tag()]) # capture number of queries @@ -40,7 +44,7 @@ def test_list_bookmarks_max_queries(self): def test_list_archived_bookmarks_max_queries(self): # set up some bookmarks with associated tags num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]) # capture number of queries @@ -59,7 +63,7 @@ def test_list_shared_bookmarks_max_queries(self): # set up some bookmarks with associated tags share_user = self.setup_user(enable_sharing=True) num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): self.setup_bookmark(user=share_user, shared=True, tags=[self.setup_tag()]) # capture number of queries diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py index c6c4d756..8a6f5d0c 100644 --- a/bookmarks/tests/test_bookmarks_list_template.py +++ b/bookmarks/tests/test_bookmarks_list_template.py @@ -9,10 +9,10 @@ from django.urls import reverse from django.utils import timezone, formats -from bookmarks.middlewares import UserProfileMiddleware +from bookmarks.middlewares import LinkdingMiddleware from bookmarks.models import Bookmark, UserProfile, User from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin -from bookmarks.views.partials import contexts +from bookmarks.views import contexts class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): @@ -44,37 +44,32 @@ def assertWebArchiveLink( f""" - {label_content} ∞ + {label_content} | """, html, ) - def assertViewLink( - self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index") - ): - self.assertViewLinkCount(html, bookmark, return_url=return_url) + def assertViewLink(self, html: str, bookmark: Bookmark, base_url=None): + self.assertViewLinkCount(html, bookmark, base_url) - def assertNoViewLink( - self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index") - ): - self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url) + def assertNoViewLink(self, html: str, bookmark: Bookmark, base_url=None): + self.assertViewLinkCount(html, bookmark, base_url, count=0) def assertViewLinkCount( self, html: str, bookmark: Bookmark, + base_url: str = None, count=1, - return_url=reverse("bookmarks:index"), ): - details_url = reverse("bookmarks:details", args=[bookmark.id]) - details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id]) + if base_url is None: + base_url = reverse("bookmarks:index") + details_url = base_url + f"?details={bookmark.id}" self.assertInHTML( f""" - View + View """, html, count=count, @@ -203,7 +198,7 @@ def assertBookmarkURLHidden( def assertNotes(self, html: str, notes_html: str, count=1): self.assertInHTML( f""" -
+
{notes_html}
@@ -270,7 +265,7 @@ def render_template( rf = RequestFactory() request = rf.get(url) request.user = user or self.get_or_create_test_user() - middleware = UserProfileMiddleware(lambda r: HttpResponse()) + middleware = LinkdingMiddleware(lambda r: HttpResponse()) middleware(request) bookmark_list_context = context_type(request) @@ -651,7 +646,7 @@ def test_show_share_info_for_non_owned_bookmarks(self): bookmark = self.setup_bookmark(user=other_user, shared=True) html = self.render_template(context_type=contexts.SharedBookmarkListContext) - self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared")) + self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared")) self.assertNoBookmarkActions(html, bookmark) self.assertShareInfo(html, bookmark) @@ -943,7 +938,7 @@ def test_with_anonymous_user(self): self.assertWebArchiveLink( html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank" ) - self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared")) + self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared")) self.assertNoBookmarkActions(html, bookmark) self.assertShareInfo(html, bookmark) self.assertMarkAsReadButton(html, bookmark, count=0) diff --git a/bookmarks/tests/test_bookmarks_tasks.py b/bookmarks/tests/test_bookmarks_tasks.py index 25a42c90..828b5141 100644 --- a/bookmarks/tests/test_bookmarks_tasks.py +++ b/bookmarks/tests/test_bookmarks_tasks.py @@ -536,7 +536,7 @@ def test_create_html_snapshot_truncate_filename(self): def test_create_html_snapshot_should_handle_error(self): bookmark = self.setup_bookmark(url="https://example.com") - self.mock_singlefile_create_snapshot.side_effect = singlefile.SingeFileError( + self.mock_singlefile_create_snapshot.side_effect = singlefile.SingleFileError( "Error" ) tasks.create_html_snapshot(bookmark) diff --git a/bookmarks/tests/test_feeds_performance.py b/bookmarks/tests/test_feeds_performance.py index 97941a9d..3aebebc1 100644 --- a/bookmarks/tests/test_feeds_performance.py +++ b/bookmarks/tests/test_feeds_performance.py @@ -4,7 +4,7 @@ from django.test.utils import CaptureQueriesContext from django.urls import reverse -from bookmarks.models import FeedToken +from bookmarks.models import FeedToken, GlobalSettings from bookmarks.tests.helpers import BookmarkFactoryMixin @@ -15,13 +15,16 @@ def setUp(self) -> None: self.client.force_login(user) self.token = FeedToken.objects.get_or_create(user=user)[0] + # create global settings + GlobalSettings.get() + def get_connection(self): return connections[DEFAULT_DB_ALIAS] def test_all_max_queries(self): # set up some bookmarks with associated tags num_initial_bookmarks = 10 - for index in range(num_initial_bookmarks): + for _ in range(num_initial_bookmarks): self.setup_bookmark(tags=[self.setup_tag()]) # capture number of queries diff --git a/bookmarks/tests/test_layout.py b/bookmarks/tests/test_layout.py new file mode 100644 index 00000000..e7fb2636 --- /dev/null +++ b/bookmarks/tests/test_layout.py @@ -0,0 +1,65 @@ +from django.test import TestCase +from django.urls import reverse + +from bookmarks.models import GlobalSettings +from bookmarks.tests.helpers import BookmarkFactoryMixin + + +class LayoutTestCase(TestCase, BookmarkFactoryMixin): + + def setUp(self) -> None: + user = self.get_or_create_test_user() + self.client.force_login(user) + + def test_nav_menu_should_respect_share_profile_setting(self): + self.user.profile.enable_sharing = False + self.user.profile.save() + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + + self.assertInHTML( + f""" + Shared + """, + html, + count=0, + ) + + self.user.profile.enable_sharing = True + self.user.profile.save() + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + + self.assertInHTML( + f""" + Shared + """, + html, + count=2, + ) + + def test_metadata_should_respect_prefetch_links_setting(self): + settings = GlobalSettings.get() + settings.enable_link_prefetch = False + settings.save() + + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + + self.assertInHTML( + '', + html, + count=1, + ) + + settings.enable_link_prefetch = True + settings.save() + + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + + self.assertInHTML( + '', + html, + count=0, + ) diff --git a/bookmarks/tests/test_user_profile_middleware.py b/bookmarks/tests/test_linkding_middleware.py similarity index 96% rename from bookmarks/tests/test_user_profile_middleware.py rename to bookmarks/tests/test_linkding_middleware.py index 1713df00..9280b4ee 100644 --- a/bookmarks/tests/test_user_profile_middleware.py +++ b/bookmarks/tests/test_linkding_middleware.py @@ -6,7 +6,7 @@ from bookmarks.middlewares import standard_profile -class UserProfileMiddlewareTestCase(TestCase, BookmarkFactoryMixin): +class LinkdingMiddlewareTestCase(TestCase, BookmarkFactoryMixin): def test_unauthenticated_user_should_use_standard_profile_by_default(self): response = self.client.get(reverse("login")) diff --git a/bookmarks/tests/test_nav_menu.py b/bookmarks/tests/test_nav_menu.py deleted file mode 100644 index b5813255..00000000 --- a/bookmarks/tests/test_nav_menu.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.test import TestCase -from django.urls import reverse - -from bookmarks.tests.helpers import BookmarkFactoryMixin - - -class NavMenuTestCase(TestCase, BookmarkFactoryMixin): - - def setUp(self) -> None: - user = self.get_or_create_test_user() - self.client.force_login(user) - - def test_should_respect_share_profile_setting(self): - self.user.profile.enable_sharing = False - self.user.profile.save() - response = self.client.get(reverse("bookmarks:index")) - html = response.content.decode() - - self.assertInHTML( - f""" - Shared - """, - html, - count=0, - ) - - self.user.profile.enable_sharing = True - self.user.profile.save() - response = self.client.get(reverse("bookmarks:index")) - html = response.content.decode() - - self.assertInHTML( - f""" - Shared - """, - html, - count=2, - ) diff --git a/bookmarks/tests/test_pagination_tag.py b/bookmarks/tests/test_pagination_tag.py index 1d5ee408..19ec728b 100644 --- a/bookmarks/tests/test_pagination_tag.py +++ b/bookmarks/tests/test_pagination_tag.py @@ -172,3 +172,12 @@ def test_respects_search_parameters(self): rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2" ) self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3") + + def test_removes_details_parameter(self): + rendered_template = self.render_template( + 100, 10, 2, url="/test?details=1&page=2" + ) + self.assertPrevLink(rendered_template, 1, href="?page=1") + self.assertPageLink(rendered_template, 1, False, href="?page=1") + self.assertPageLink(rendered_template, 2, True, href="?page=2") + self.assertNextLink(rendered_template, 3, href="?page=3") diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py index 0f82726a..c9c2bd9a 100644 --- a/bookmarks/tests/test_settings_general_view.py +++ b/bookmarks/tests/test_settings_general_view.py @@ -79,6 +79,13 @@ def test_should_check_authentication(self): reverse("login") + "?next=" + reverse("bookmarks:settings.general"), ) + response = self.client.get(reverse("bookmarks:settings.update"), follow=True) + + self.assertRedirects( + response, + reverse("login") + "?next=" + reverse("bookmarks:settings.update"), + ) + def test_update_profile(self): form_data = { "update_profile": "", @@ -105,7 +112,9 @@ def test_update_profile(self): "custom_css": "body { background-color: #000; }", "auto_tagging_rules": "example.com tag", } - response = self.client.post(reverse("bookmarks:settings.general"), form_data) + response = self.client.post( + reverse("bookmarks:settings.update"), form_data, follow=True + ) html = response.content.decode() self.user.profile.refresh_from_db() @@ -179,7 +188,9 @@ def test_update_profile_should_not_be_called_without_respective_form_action(self form_data = { "theme": UserProfile.THEME_DARK, } - response = self.client.post(reverse("bookmarks:settings.general"), form_data) + response = self.client.post( + reverse("bookmarks:settings.update"), form_data, follow=True + ) html = response.content.decode() self.user.profile.refresh_from_db() @@ -199,14 +210,14 @@ def test_enable_favicons_should_schedule_icon_update(self): "enable_favicons": True, } ) - self.client.post(reverse("bookmarks:settings.general"), form_data) + self.client.post(reverse("bookmarks:settings.update"), form_data) mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user) # No update scheduled if favicons are already enabled mock_schedule_bookmarks_without_favicons.reset_mock() - self.client.post(reverse("bookmarks:settings.general"), form_data) + self.client.post(reverse("bookmarks:settings.update"), form_data) mock_schedule_bookmarks_without_favicons.assert_not_called() @@ -217,7 +228,7 @@ def test_enable_favicons_should_schedule_icon_update(self): } ) - self.client.post(reverse("bookmarks:settings.general"), form_data) + self.client.post(reverse("bookmarks:settings.update"), form_data) mock_schedule_bookmarks_without_favicons.assert_not_called() @@ -229,7 +240,7 @@ def test_refresh_favicons(self): "refresh_favicons": "", } response = self.client.post( - reverse("bookmarks:settings.general"), form_data + reverse("bookmarks:settings.update"), form_data, follow=True ) html = response.content.decode() @@ -243,9 +254,7 @@ def test_refresh_favicons_should_not_be_called_without_respective_form_action(se tasks, "schedule_refresh_favicons" ) as mock_schedule_refresh_favicons: form_data = {} - response = self.client.post( - reverse("bookmarks:settings.general"), form_data - ) + response = self.client.post(reverse("bookmarks:settings.update"), form_data) html = response.content.decode() mock_schedule_refresh_favicons.assert_not_called() @@ -315,14 +324,14 @@ def test_enable_preview_image_should_schedule_preview_update(self): "enable_preview_images": True, } ) - self.client.post(reverse("bookmarks:settings.general"), form_data) + self.client.post(reverse("bookmarks:settings.update"), form_data) mock_schedule_bookmarks_without_previews.assert_called_once_with(self.user) # No update scheduled if favicons are already enabled mock_schedule_bookmarks_without_previews.reset_mock() - self.client.post(reverse("bookmarks:settings.general"), form_data) + self.client.post(reverse("bookmarks:settings.update"), form_data) mock_schedule_bookmarks_without_previews.assert_not_called() @@ -333,7 +342,7 @@ def test_enable_preview_image_should_schedule_preview_update(self): } ) - self.client.post(reverse("bookmarks:settings.general"), form_data) + self.client.post(reverse("bookmarks:settings.update"), form_data) mock_schedule_bookmarks_without_previews.assert_not_called() @@ -422,10 +431,11 @@ def test_create_missing_html_snapshots(self): "create_missing_html_snapshots": "", } response = self.client.post( - reverse("bookmarks:settings.general"), form_data + reverse("bookmarks:settings.update"), form_data, follow=True ) html = response.content.decode() + self.assertEqual(response.status_code, 200) mock_create_missing_html_snapshots.assert_called_once() self.assertSuccessMessage( html, "Queued 5 missing snapshots. This may take a while..." @@ -441,10 +451,11 @@ def test_create_missing_html_snapshots_no_missing_snapshots(self): "create_missing_html_snapshots": "", } response = self.client.post( - reverse("bookmarks:settings.general"), form_data + reverse("bookmarks:settings.update"), form_data, follow=True ) html = response.content.decode() + self.assertEqual(response.status_code, 200) mock_create_missing_html_snapshots.assert_called_once() self.assertSuccessMessage(html, "No missing snapshots found.") @@ -457,10 +468,11 @@ def test_create_missing_html_snapshots_should_not_be_called_without_respective_f mock_create_missing_html_snapshots.return_value = 5 form_data = {} response = self.client.post( - reverse("bookmarks:settings.general"), form_data + reverse("bookmarks:settings.update"), form_data, follow=True ) html = response.content.decode() + self.assertEqual(response.status_code, 200) mock_create_missing_html_snapshots.assert_not_called() self.assertSuccessMessage( html, "Queued 5 missing snapshots. This may take a while...", count=0 @@ -477,7 +489,9 @@ def test_update_global_settings(self): "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS, "guest_profile_user": selectable_user.id, } - response = self.client.post(reverse("bookmarks:settings.general"), form_data) + response = self.client.post( + reverse("bookmarks:settings.update"), form_data, follow=True + ) self.assertEqual(response.status_code, 200) self.assertSuccessMessage(response.content.decode(), "Global settings updated") @@ -491,7 +505,9 @@ def test_update_global_settings(self): "landing_page": GlobalSettings.LANDING_PAGE_LOGIN, "guest_profile_user": "", } - response = self.client.post(reverse("bookmarks:settings.general"), form_data) + response = self.client.post( + reverse("bookmarks:settings.update"), form_data, follow=True + ) self.assertEqual(response.status_code, 200) self.assertSuccessMessage(response.content.decode(), "Global settings updated") @@ -509,7 +525,9 @@ def test_update_global_settings_should_not_be_called_without_respective_form_act form_data = { "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS, } - response = self.client.post(reverse("bookmarks:settings.general"), form_data) + response = self.client.post( + reverse("bookmarks:settings.update"), form_data, follow=True + ) self.assertEqual(response.status_code, 200) self.assertSuccessMessage( response.content.decode(), "Global settings updated", count=0 @@ -520,7 +538,7 @@ def test_update_global_settings_checks_for_superuser(self): "update_global_settings": "", "landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS, } - response = self.client.post(reverse("bookmarks:settings.general"), form_data) + response = self.client.post(reverse("bookmarks:settings.update"), form_data) self.assertEqual(response.status_code, 403) def test_global_settings_only_visible_for_superuser(self): diff --git a/bookmarks/tests/test_settings_integrations_view.py b/bookmarks/tests/test_settings_integrations_view.py index 8098a960..5ada01e9 100644 --- a/bookmarks/tests/test_settings_integrations_view.py +++ b/bookmarks/tests/test_settings_integrations_view.py @@ -68,17 +68,18 @@ def test_should_display_feed_urls(self): token = FeedToken.objects.first() self.assertInHTML( - f'All bookmarks', html + f'All bookmarks', + html, ) self.assertInHTML( - f'Unread bookmarks', + f'Unread bookmarks', html, ) self.assertInHTML( - f'Shared bookmarks', + f'Shared bookmarks', html, ) self.assertInHTML( - f'Public shared bookmarks', + 'Public shared bookmarks', html, ) diff --git a/bookmarks/tests/test_singlefile_service.py b/bookmarks/tests/test_singlefile_service.py index cf026560..c46d730c 100644 --- a/bookmarks/tests/test_singlefile_service.py +++ b/bookmarks/tests/test_singlefile_service.py @@ -43,12 +43,12 @@ def test_create_snapshot_failure(self): with mock.patch("subprocess.Popen") as mock_popen: mock_popen.side_effect = subprocess.CalledProcessError(1, "command") - with self.assertRaises(singlefile.SingeFileError): + with self.assertRaises(singlefile.SingleFileError): singlefile.create_snapshot("http://example.com", self.html_filepath) # so also check that it raises error if output file isn't created with mock.patch("subprocess.Popen"): - with self.assertRaises(singlefile.SingeFileError): + with self.assertRaises(singlefile.SingleFileError): singlefile.create_snapshot("http://example.com", self.html_filepath) def test_create_snapshot_empty_options(self): diff --git a/bookmarks/tests/test_tag_cloud_template.py b/bookmarks/tests/test_tag_cloud_template.py index 4ac807ea..04cad07b 100644 --- a/bookmarks/tests/test_tag_cloud_template.py +++ b/bookmarks/tests/test_tag_cloud_template.py @@ -5,10 +5,10 @@ from django.template import Template, RequestContext from django.test import TestCase, RequestFactory -from bookmarks.middlewares import UserProfileMiddleware +from bookmarks.middlewares import LinkdingMiddleware from bookmarks.models import UserProfile from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin -from bookmarks.views.partials import contexts +from bookmarks.views import contexts class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): @@ -21,7 +21,7 @@ def render_template( rf = RequestFactory() request = rf.get(url) request.user = user or self.get_or_create_test_user() - middleware = UserProfileMiddleware(lambda r: HttpResponse()) + middleware = LinkdingMiddleware(lambda r: HttpResponse()) middleware(request) tag_cloud_context = context_type(request) @@ -203,13 +203,28 @@ def test_tag_url_respects_search_options(self): tag = self.setup_tag(name="tag1") self.setup_bookmark(tags=[tag], title="term1") + rendered_template = self.render_template(url="/test?q=term1&sort=title_asc") + + self.assertInHTML( + """ + + tag1 + + """, + rendered_template, + ) + + def test_tag_url_removes_page_number_and_details_id(self): + tag = self.setup_tag(name="tag1") + self.setup_bookmark(tags=[tag], title="term1") + rendered_template = self.render_template( - url="/test?q=term1&sort=title_asc&page=2" + url="/test?q=term1&sort=title_asc&page=2&details=5" ) self.assertInHTML( """ - + tag1 """, @@ -347,12 +362,30 @@ def test_selected_tag_url_respects_search_options(self): self.setup_bookmark(tags=[tag], title="term1", description="term2") rendered_template = self.render_template( - url="/test?q=term1 %23tag1 term2&sort=title_asc&page=2" + url="/test?q=term1 %23tag1 term2&sort=title_asc" + ) + + self.assertInHTML( + """ + + -tag1 + + """, + 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( """ - -tag1 diff --git a/bookmarks/urls.py b/bookmarks/urls.py index fd855e38..97b8ab1f 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -9,7 +9,6 @@ SharedBookmarksFeed, PublicSharedBookmarksFeed, ) -from bookmarks.views import partials app_name = "bookmarks" urlpatterns = [ @@ -31,21 +30,6 @@ path("bookmarks/new", views.bookmarks.new, name="new"), path("bookmarks/close", views.bookmarks.close, name="close"), path("bookmarks//edit", views.bookmarks.edit, name="edit"), - path( - "bookmarks//details", - views.bookmarks.details, - name="details", - ), - path( - "bookmarks//details_modal", - views.bookmarks.details_modal, - name="details_modal", - ), - path( - "bookmarks//details_assets", - views.bookmarks.details_assets, - name="details_assets", - ), # Assets path( "assets/", @@ -57,55 +41,10 @@ views.assets.read, name="assets.read", ), - # Partials - path( - "bookmarks/partials/bookmark-list/active", - partials.active_bookmark_list, - name="partials.bookmark_list.active", - ), - path( - "bookmarks/partials/tag-cloud/active", - partials.active_tag_cloud, - name="partials.tag_cloud.active", - ), - path( - "bookmarks/partials/tag-modal/active", - partials.active_tag_modal, - name="partials.tag_modal.active", - ), - path( - "bookmarks/partials/bookmark-list/archived", - partials.archived_bookmark_list, - name="partials.bookmark_list.archived", - ), - path( - "bookmarks/partials/tag-cloud/archived", - partials.archived_tag_cloud, - name="partials.tag_cloud.archived", - ), - path( - "bookmarks/partials/tag-modal/archived", - partials.archived_tag_modal, - name="partials.tag_modal.archived", - ), - path( - "bookmarks/partials/bookmark-list/shared", - partials.shared_bookmark_list, - name="partials.bookmark_list.shared", - ), - path( - "bookmarks/partials/tag-cloud/shared", - partials.shared_tag_cloud, - name="partials.tag_cloud.shared", - ), - path( - "bookmarks/partials/tag-modal/shared", - partials.shared_tag_modal, - name="partials.tag_modal.shared", - ), # Settings path("settings", views.settings.general, name="settings.index"), path("settings/general", views.settings.general, name="settings.general"), + path("settings/update", views.settings.update, name="settings.update"), path( "settings/integrations", views.settings.integrations, diff --git a/bookmarks/utils.py b/bookmarks/utils.py index 04bb697b..b1a38ec2 100644 --- a/bookmarks/utils.py +++ b/bookmarks/utils.py @@ -1,10 +1,12 @@ import logging import re import unicodedata +import urllib.parse from datetime import datetime from typing import Optional from dateutil.relativedelta import relativedelta +from django.http import HttpResponseRedirect from django.template.defaultfilters import pluralize from django.utils import timezone, formats @@ -114,6 +116,14 @@ def get_safe_return_url(return_url: str, fallback_url: str): return return_url +def redirect_with_query(request, redirect_url): + query_string = urllib.parse.urlencode(request.GET) + if query_string: + redirect_url += "?" + query_string + + return HttpResponseRedirect(redirect_url) + + def generate_username(email): # taken from mozilla-django-oidc docs :) diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py index 2bcb653f..730741bd 100644 --- a/bookmarks/views/bookmarks.py +++ b/bookmarks/views/bookmarks.py @@ -11,7 +11,7 @@ from django.shortcuts import render from django.urls import reverse -from bookmarks import queries +from bookmarks import queries, utils from bookmarks.models import ( Bookmark, BookmarkAsset, @@ -19,6 +19,7 @@ BookmarkSearch, build_tag_string, ) +from bookmarks.services import bookmarks as bookmark_actions, tasks from bookmarks.services.bookmarks import ( create_bookmark, update_bookmark, @@ -34,9 +35,8 @@ share_bookmarks, unshare_bookmarks, ) -from bookmarks.services import bookmarks as bookmark_actions, tasks from bookmarks.utils import get_safe_return_url -from bookmarks.views.partials import contexts +from bookmarks.views import contexts, partials, turbo _default_page_size = 30 @@ -48,12 +48,17 @@ def index(request): bookmark_list = contexts.ActiveBookmarkListContext(request) tag_cloud = contexts.ActiveTagCloudContext(request) - return render( + bookmark_details = contexts.get_details_context( + request, contexts.ActiveBookmarkDetailsContext + ) + + return render_bookmarks_view( request, "bookmarks/index.html", { "bookmark_list": bookmark_list, "tag_cloud": tag_cloud, + "details": bookmark_details, }, ) @@ -65,12 +70,17 @@ def archived(request): bookmark_list = contexts.ArchivedBookmarkListContext(request) tag_cloud = contexts.ArchivedTagCloudContext(request) - return render( + bookmark_details = contexts.get_details_context( + request, contexts.ArchivedBookmarkDetailsContext + ) + + return render_bookmarks_view( request, "bookmarks/archive.html", { "bookmark_list": bookmark_list, "tag_cloud": tag_cloud, + "details": bookmark_details, }, ) @@ -81,14 +91,37 @@ def shared(request): bookmark_list = contexts.SharedBookmarkListContext(request) tag_cloud = contexts.SharedTagCloudContext(request) + bookmark_details = contexts.get_details_context( + request, contexts.SharedBookmarkDetailsContext + ) public_only = not request.user.is_authenticated users = queries.query_shared_bookmark_users( request.user_profile, bookmark_list.search, public_only ) - return render( + return render_bookmarks_view( request, "bookmarks/shared.html", - {"bookmark_list": bookmark_list, "tag_cloud": tag_cloud, "users": users}, + { + "bookmark_list": bookmark_list, + "tag_cloud": tag_cloud, + "details": bookmark_details, + "users": users, + }, + ) + + +def render_bookmarks_view(request, template_name, context): + if turbo.is_frame(request, "details-modal"): + return render( + request, + "bookmarks/updates/details-modal-frame.html", + context, + ) + + return render( + request, + template_name, + context, ) @@ -111,76 +144,6 @@ def search_action(request): return HttpResponseRedirect(url) -def _details(request, bookmark_id: int, template: str): - try: - bookmark = Bookmark.objects.get(pk=bookmark_id) - except Bookmark.DoesNotExist: - raise Http404("Bookmark does not exist") - - is_owner = bookmark.owner == request.user - is_shared = ( - request.user.is_authenticated - and bookmark.shared - and bookmark.owner.profile.enable_sharing - ) - is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing - if not is_owner and not is_shared and not is_public_shared: - raise Http404("Bookmark does not exist") - - if request.method == "POST": - if not is_owner: - raise Http404("Bookmark does not exist") - - return_url = get_safe_return_url( - request.GET.get("return_url"), - reverse("bookmarks:details", args=[bookmark.id]), - ) - - if "remove_asset" in request.POST: - asset_id = request.POST["remove_asset"] - try: - asset = bookmark.bookmarkasset_set.get(pk=asset_id) - except BookmarkAsset.DoesNotExist: - raise Http404("Asset does not exist") - asset.delete() - if "create_snapshot" in request.POST: - tasks.create_html_snapshot(bookmark) - if "upload_asset" in request.POST: - file = request.FILES.get("upload_asset_file") - if not file: - return HttpResponseBadRequest("No file uploaded") - bookmark_actions.upload_asset(bookmark, file) - else: - bookmark.is_archived = request.POST.get("is_archived") == "on" - bookmark.unread = request.POST.get("unread") == "on" - bookmark.shared = request.POST.get("shared") == "on" - bookmark.save() - - return HttpResponseRedirect(return_url) - - details_context = contexts.BookmarkDetailsContext(request, bookmark) - - return render( - request, - template, - { - "details": details_context, - }, - ) - - -def details(request, bookmark_id: int): - return _details(request, bookmark_id, "bookmarks/details.html") - - -def details_modal(request, bookmark_id: int): - return _details(request, bookmark_id, "bookmarks/details_modal.html") - - -def details_assets(request, bookmark_id: int): - return _details(request, bookmark_id, "bookmarks/details/assets.html") - - def convert_tag_string(tag_string: str): # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # strings @@ -189,6 +152,7 @@ def convert_tag_string(tag_string: str): @login_required def new(request): + status = 200 initial_url = request.GET.get("url") initial_title = request.GET.get("title") initial_description = request.GET.get("description") @@ -207,6 +171,8 @@ def new(request): return HttpResponseRedirect(reverse("bookmarks:close")) else: return HttpResponseRedirect(reverse("bookmarks:index")) + else: + status = 422 else: form = BookmarkForm() if initial_url: @@ -228,7 +194,7 @@ def new(request): "return_url": reverse("bookmarks:index"), } - return render(request, "bookmarks/new.html", context) + return render(request, "bookmarks/new.html", context, status=status) @login_required @@ -304,26 +270,87 @@ def mark_as_read(request, bookmark_id: int): bookmark.save() +def create_html_snapshot(request, bookmark_id: int): + try: + bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user) + except Bookmark.DoesNotExist: + raise Http404("Bookmark does not exist") + + tasks.create_html_snapshot(bookmark) + + +def upload_asset(request, bookmark_id: int): + try: + bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user) + except Bookmark.DoesNotExist: + raise Http404("Bookmark does not exist") + + file = request.FILES.get("upload_asset_file") + if not file: + raise ValueError("No file uploaded") + + bookmark_actions.upload_asset(bookmark, file) + + +def remove_asset(request, asset_id: int): + try: + asset = BookmarkAsset.objects.get(pk=asset_id, bookmark__owner=request.user) + except BookmarkAsset.DoesNotExist: + raise Http404("Asset does not exist") + + asset.delete() + + +def update_state(request, bookmark_id: int): + try: + bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user) + except Bookmark.DoesNotExist: + raise Http404("Bookmark does not exist") + + bookmark.is_archived = request.POST.get("is_archived") == "on" + bookmark.unread = request.POST.get("unread") == "on" + bookmark.shared = request.POST.get("shared") == "on" + bookmark.save() + + @login_required def index_action(request): search = BookmarkSearch.from_request(request.GET) query = queries.query_bookmarks(request.user, request.user_profile, search) - return action(request, query) + handle_action(request, query) + + if turbo.accept(request): + return partials.active_bookmark_update(request) + + return utils.redirect_with_query(request, reverse("bookmarks:index")) @login_required def archived_action(request): search = BookmarkSearch.from_request(request.GET) query = queries.query_archived_bookmarks(request.user, request.user_profile, search) - return action(request, query) + handle_action(request, query) + + if turbo.accept(request): + return partials.archived_bookmark_update(request) + + return utils.redirect_with_query(request, reverse("bookmarks:archived")) @login_required def shared_action(request): - return action(request) + if "bulk_execute" in request.POST: + return HttpResponseBadRequest("View does not support bulk actions") + + handle_action(request) + + if turbo.accept(request): + return partials.shared_bookmark_update(request) + + return utils.redirect_with_query(request, reverse("bookmarks:shared")) -def action(request, query: QuerySet[Bookmark] = None): +def handle_action(request, query: QuerySet[Bookmark] = None): # Single bookmark actions if "archive" in request.POST: archive(request, request.POST["archive"]) @@ -335,11 +362,21 @@ def action(request, query: QuerySet[Bookmark] = None): mark_as_read(request, request.POST["mark_as_read"]) if "unshare" in request.POST: unshare(request, request.POST["unshare"]) + if "create_html_snapshot" in request.POST: + create_html_snapshot(request, request.POST["create_html_snapshot"]) + if "upload_asset" in request.POST: + upload_asset(request, request.POST["upload_asset"]) + if "remove_asset" in request.POST: + remove_asset(request, request.POST["remove_asset"]) + + # State updates + if "update_state" in request.POST: + update_state(request, request.POST["update_state"]) # Bulk actions if "bulk_execute" in request.POST: if query is None: - return HttpResponseBadRequest("View does not support bulk actions") + raise ValueError("Query must be provided for bulk actions") bulk_action = request.POST["bulk_action"] @@ -372,11 +409,6 @@ def action(request, query: QuerySet[Bookmark] = None): if "bulk_unshare" == bulk_action: unshare_bookmarks(bookmark_ids, request.user) - return_url = get_safe_return_url( - request.GET.get("return_url"), reverse("bookmarks:index") - ) - return HttpResponseRedirect(return_url) - @login_required def close(request): diff --git a/bookmarks/views/partials/contexts.py b/bookmarks/views/contexts.py similarity index 83% rename from bookmarks/views/partials/contexts.py rename to bookmarks/views/contexts.py index 6adcaeaf..85934510 100644 --- a/bookmarks/views/partials/contexts.py +++ b/bookmarks/views/contexts.py @@ -6,6 +6,7 @@ from django.core.handlers.wsgi import WSGIRequest from django.core.paginator import Paginator from django.db import models +from django.http import Http404 from django.urls import reverse from bookmarks import queries @@ -27,17 +28,11 @@ class RequestContext: index_view = "bookmarks:index" action_view = "bookmarks:index.action" - bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active" - tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active" - tag_modal_partial_view = "bookmarks:partials.tag_modal.active" def __init__(self, request: WSGIRequest): self.request = request self.index_url = reverse(self.index_view) self.action_url = reverse(self.action_view) - self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view) - self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view) - self.tag_modal_partial_url = reverse(self.tag_modal_partial_view) self.query_params = request.GET.copy() self.query_params.pop("details", None) @@ -51,34 +46,25 @@ def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str: encoded_params = query_params.urlencode() return view_url + "?" + encoded_params if encoded_params else view_url - def index(self) -> str: - return self.get_url(self.index_url) + def index(self, add: dict = None, remove: dict = None) -> str: + return self.get_url(self.index_url, add=add, remove=remove) - def action(self, return_url: str) -> str: - return self.get_url(self.action_url, add={"return_url": return_url}) + def action(self, add: dict = None, remove: dict = None) -> str: + return self.get_url(self.action_url, add=add, remove=remove) - def bookmark_list_partial(self) -> str: - return self.get_url(self.bookmark_list_partial_url) - - def tag_cloud_partial(self) -> str: - return self.get_url(self.tag_cloud_partial_url) - - def tag_modal_partial(self) -> str: - return self.get_url(self.tag_modal_partial_url) + def details(self, bookmark_id: int) -> str: + return self.get_url(self.index_url, add={"details": bookmark_id}) def get_bookmark_query_set(self, search: BookmarkSearch): - raise Exception("Must be implemented by subclass") + raise NotImplementedError("Must be implemented by subclass") def get_tag_query_set(self, search: BookmarkSearch): - raise Exception("Must be implemented by subclass") + raise NotImplementedError("Must be implemented by subclass") class ActiveBookmarksContext(RequestContext): index_view = "bookmarks:index" action_view = "bookmarks:index.action" - bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active" - tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active" - tag_modal_partial_view = "bookmarks:partials.tag_modal.active" def get_bookmark_query_set(self, search: BookmarkSearch): return queries.query_bookmarks( @@ -94,9 +80,6 @@ def get_tag_query_set(self, search: BookmarkSearch): class ArchivedBookmarksContext(RequestContext): index_view = "bookmarks:archived" action_view = "bookmarks:archived.action" - bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived" - tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived" - tag_modal_partial_view = "bookmarks:partials.tag_modal.archived" def get_bookmark_query_set(self, search: BookmarkSearch): return queries.query_archived_bookmarks( @@ -112,9 +95,6 @@ def get_tag_query_set(self, search: BookmarkSearch): class SharedBookmarksContext(RequestContext): index_view = "bookmarks:shared" action_view = "bookmarks:shared.action" - bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared" - tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared" - tag_modal_partial_view = "bookmarks:partials.tag_modal.shared" def get_bookmark_query_set(self, search: BookmarkSearch): user = User.objects.filter(username=search.user).first() @@ -132,7 +112,13 @@ def get_tag_query_set(self, search: BookmarkSearch): class BookmarkItem: - def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None: + def __init__( + self, + context: RequestContext, + bookmark: Bookmark, + user: User, + profile: UserProfile, + ) -> None: self.bookmark = bookmark is_editable = bookmark.owner == user @@ -154,6 +140,7 @@ def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None self.is_archived = bookmark.is_archived self.unread = bookmark.unread self.owner = bookmark.owner + self.details_url = context.details(bookmark.id) css_classes = [] if bookmark.unread: @@ -200,16 +187,15 @@ def __init__(self, request: WSGIRequest) -> None: models.prefetch_related_objects(bookmarks_page.object_list, "owner", "tags") self.items = [ - BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page + BookmarkItem(request_context, bookmark, user, user_profile) + for bookmark in bookmarks_page ] self.is_empty = paginator.count == 0 self.bookmarks_page = bookmarks_page self.bookmarks_total = paginator.count self.return_url = request_context.index() - self.action_url = request_context.action(return_url=self.return_url) - self.refresh_url = request_context.bookmark_list_partial() - self.tag_modal_url = request_context.tag_modal_partial() + self.action_url = request_context.action() self.link_target = user_profile.bookmark_link_target self.date_display = user_profile.bookmark_date_display @@ -344,8 +330,6 @@ def __init__(self, request: WSGIRequest) -> None: self.selected_tags = unique_selected_tags self.has_selected_tags = has_selected_tags - self.refresh_url = request_context.tag_cloud_partial() - def get_selected_tags(self, tags: List[Tag]): parsed_query = queries.parse_query_string(self.search.q) tag_names = parsed_query["tag_names"] @@ -383,30 +367,31 @@ def __init__(self, asset: BookmarkAsset): icon_classes = [] text_classes = [] if asset.status == BookmarkAsset.STATUS_PENDING: - icon_classes.append("text-gray") - text_classes.append("text-gray") + icon_classes.append("text-tertiary") + text_classes.append("text-tertiary") elif asset.status == BookmarkAsset.STATUS_FAILURE: icon_classes.append("text-error") text_classes.append("text-error") else: - icon_classes.append("text-primary") + icon_classes.append("icon-color") self.icon_classes = " ".join(icon_classes) self.text_classes = " ".join(text_classes) class BookmarkDetailsContext: + request_context = RequestContext + def __init__(self, request: WSGIRequest, bookmark: Bookmark): + request_context = self.request_context(request) + user = request.user user_profile = request.user_profile - self.edit_return_url = utils.get_safe_return_url( - request.GET.get("return_url"), - reverse("bookmarks:details", args=[bookmark.id]), - ) - self.delete_return_url = utils.get_safe_return_url( - request.GET.get("return_url"), reverse("bookmarks:index") - ) + self.edit_return_url = request_context.details(bookmark.id) + self.action_url = request_context.action(add={"details": bookmark.id}) + self.delete_url = request_context.action() + self.close_url = request_context.index() self.bookmark = bookmark self.profile = request.user_profile @@ -438,3 +423,44 @@ def __init__(self, request: WSGIRequest, bookmark: Bookmark): ), 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) diff --git a/bookmarks/views/partials.py b/bookmarks/views/partials.py new file mode 100644 index 00000000..8930a66c --- /dev/null +++ b/bookmarks/views/partials.py @@ -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) diff --git a/bookmarks/views/partials/__init__.py b/bookmarks/views/partials/__init__.py deleted file mode 100644 index 22041492..00000000 --- a/bookmarks/views/partials/__init__.py +++ /dev/null @@ -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}) diff --git a/bookmarks/views/root.py b/bookmarks/views/root.py index 8b728466..71b421b4 100644 --- a/bookmarks/views/root.py +++ b/bookmarks/views/root.py @@ -7,7 +7,7 @@ def root(request): # Redirect unauthenticated users to the configured landing page if not request.user.is_authenticated: - settings = GlobalSettings.get() + settings = request.global_settings if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS: return HttpResponseRedirect(reverse("bookmarks:shared")) diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py index 3c8a1f58..3566d85f 100644 --- a/bookmarks/views/settings.py +++ b/bookmarks/views/settings.py @@ -29,41 +29,19 @@ @login_required def general(request): - profile_form = None - global_settings_form = None enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS success_message = _find_message_with_tag( - messages.get_messages(request), "bookmark_import_success" + messages.get_messages(request), "settings_success_message" ) error_message = _find_message_with_tag( - messages.get_messages(request), "bookmark_import_errors" + messages.get_messages(request), "settings_error_message" ) version_info = get_version_info(get_ttl_hash()) - if request.method == "POST": - if "update_profile" in request.POST: - profile_form = update_profile(request) - success_message = "Profile updated" - if "update_global_settings" in request.POST: - global_settings_form = update_global_settings(request) - success_message = "Global settings updated" - if "refresh_favicons" in request.POST: - tasks.schedule_refresh_favicons(request.user) - success_message = "Scheduled favicon update. This may take a while..." - if "create_missing_html_snapshots" in request.POST: - count = tasks.create_missing_html_snapshots(request.user) - if count > 0: - success_message = ( - f"Queued {count} missing snapshots. This may take a while..." - ) - else: - success_message = "No missing snapshots found." - - if not profile_form: - profile_form = UserProfileForm(instance=request.user_profile) - - if request.user.is_superuser and not global_settings_form: + profile_form = UserProfileForm(instance=request.user_profile) + global_settings_form = None + if request.user.is_superuser: global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get()) return render( @@ -81,6 +59,40 @@ def general(request): ) +@login_required +def update(request): + if request.method == "POST": + if "update_profile" in request.POST: + update_profile(request) + messages.success(request, "Profile updated", "settings_success_message") + if "update_global_settings" in request.POST: + update_global_settings(request) + messages.success( + request, "Global settings updated", "settings_success_message" + ) + if "refresh_favicons" in request.POST: + tasks.schedule_refresh_favicons(request.user) + messages.success( + request, + "Scheduled favicon update. This may take a while...", + "settings_success_message", + ) + if "create_missing_html_snapshots" in request.POST: + count = tasks.create_missing_html_snapshots(request.user) + if count > 0: + messages.success( + request, + f"Queued {count} missing snapshots. This may take a while...", + "settings_success_message", + ) + else: + messages.success( + request, "No missing snapshots found.", "settings_success_message" + ) + + return HttpResponseRedirect(reverse("bookmarks:settings.general")) + + def update_profile(request): user = request.user profile = user.profile @@ -178,7 +190,7 @@ def bookmark_import(request): if import_file is None: messages.error( - request, "Please select a file to import.", "bookmark_import_errors" + request, "Please select a file to import.", "settings_error_message" ) return HttpResponseRedirect(reverse("bookmarks:settings.general")) @@ -186,21 +198,20 @@ def bookmark_import(request): content = import_file.read().decode() result = importer.import_netscape_html(content, request.user, import_options) success_msg = str(result.success) + " bookmarks were successfully imported." - messages.success(request, success_msg, "bookmark_import_success") + messages.success(request, success_msg, "settings_success_message") if result.failed > 0: err_msg = ( str(result.failed) + " bookmarks could not be imported. Please check the logs for more details." ) - messages.error(request, err_msg, "bookmark_import_errors") + messages.error(request, err_msg, "settings_error_message") except: logging.exception("Unexpected error during bookmark import") messages.error( request, "An error occurred during bookmark import.", - "bookmark_import_errors", + "settings_error_message", ) - pass return HttpResponseRedirect(reverse("bookmarks:settings.general")) diff --git a/bookmarks/views/turbo.py b/bookmarks/views/turbo.py new file mode 100644 index 00000000..3ac7dbcf --- /dev/null +++ b/bookmarks/views/turbo.py @@ -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 diff --git a/bootstrap.sh b/bootstrap.sh index a3d728fb..24e00633 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Bootstrap script that gets executed in new Docker containers +LD_SERVER_HOST="${LD_SERVER_HOST:-[::]}" LD_SERVER_PORT="${LD_SERVER_PORT:-9090}" # Create data folder if it does not exist @@ -32,4 +33,4 @@ if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then fi # Start uwsgi server -exec uwsgi --http :$LD_SERVER_PORT uwsgi.ini +exec uwsgi --http $LD_SERVER_HOST:$LD_SERVER_PORT uwsgi.ini diff --git a/docker/alpine.Dockerfile b/docker/alpine.Dockerfile index c782c7c0..eeb214bd 100644 --- a/docker/alpine.Dockerfile +++ b/docker/alpine.Dockerfile @@ -1,10 +1,11 @@ FROM node:18-alpine AS node-build WORKDIR /etc/linkding # install build dependencies -COPY rollup.config.mjs package.json package-lock.json ./ +COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./ RUN npm ci # copy files needed for JS build COPY bookmarks/frontend ./bookmarks/frontend +COPY bookmarks/styles ./bookmarks/styles # run build RUN npm run build @@ -23,18 +24,15 @@ WORKDIR /etc/linkding FROM python-base AS python-build # install build dependencies COPY requirements.txt requirements.txt -COPY requirements.dev.txt requirements.dev.txt -# remove playwright from requirements as there is not always a distro and it's not needed for the build -RUN sed -i '/playwright/d' requirements.dev.txt -RUN pip install -U pip && pip install -r requirements.txt -r requirements.dev.txt +RUN pip install -U pip && pip install -r requirements.txt # copy files needed for Django build COPY . . COPY --from=node-build /etc/linkding . +# remove style sources +RUN rm -rf bookmarks/styles # run Django part of the build RUN mkdir data && \ - python manage.py compilescss && \ - python manage.py collectstatic --ignore=*.scss && \ - python manage.py compilescss --delete-files + python manage.py collectstatic FROM python-base AS prod-deps diff --git a/docker/default.Dockerfile b/docker/default.Dockerfile index cacdb3b0..409febc5 100644 --- a/docker/default.Dockerfile +++ b/docker/default.Dockerfile @@ -1,10 +1,11 @@ FROM node:18-alpine AS node-build WORKDIR /etc/linkding # install build dependencies -COPY rollup.config.mjs package.json package-lock.json ./ +COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./ RUN npm ci # copy files needed for JS build COPY bookmarks/frontend ./bookmarks/frontend +COPY bookmarks/styles ./bookmarks/styles # run build RUN npm run build @@ -25,18 +26,15 @@ WORKDIR /etc/linkding FROM python-base AS python-build # install build dependencies COPY requirements.txt requirements.txt -COPY requirements.dev.txt requirements.dev.txt -# remove playwright from requirements as there is not always a distro and it's not needed for the build -RUN sed -i '/playwright/d' requirements.dev.txt -RUN pip install -U pip && pip install -r requirements.txt -r requirements.dev.txt +RUN pip install -U pip && pip install -r requirements.txt # copy files needed for Django build COPY . . COPY --from=node-build /etc/linkding . +# remove style sources +RUN rm -rf bookmarks/styles # run Django part of the build RUN mkdir data && \ - python manage.py compilescss && \ - python manage.py collectstatic --ignore=*.scss && \ - python manage.py compilescss --delete-files + python manage.py collectstatic FROM python-base AS prod-deps diff --git a/docs/Options.md b/docs/Options.md index 688e42fb..817081a6 100644 --- a/docs/Options.md +++ b/docs/Options.md @@ -62,6 +62,12 @@ Values: `Integer` as seconds | Default = `60` Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts. +### `LD_SERVER_HOST` + +Values: Valid address for socket to bind to | Default = `[::]` + +Allows to set a custom host for the UWSGI server running in the container. The default creates a dual stack socket, which will respond to IPv4 and IPv6 requests. IPv4 requests are logged as IPv4-mapped IPv6 addresses, such as "::ffff:127.0.0.1". If reverting to an IPv4-only socket is desired, this can be set to "0.0.0.0". + ### `LD_SERVER_PORT` Values: Valid port number | Default = `9090` diff --git a/docs/header.afdesign b/docs/header.afdesign index b3de3609..cd74168a 100644 Binary files a/docs/header.afdesign and b/docs/header.afdesign differ diff --git a/docs/header.png b/docs/header.png index 6e2c97e9..c4efc4e1 100644 Binary files a/docs/header.png and b/docs/header.png differ diff --git a/docs/header.svg b/docs/header.svg index 91a9dc1a..b2eb3212 100644 --- a/docs/header.svg +++ b/docs/header.svg @@ -4,12 +4,12 @@ - + - + - + diff --git a/docs/linkding-screenshot.png b/docs/linkding-screenshot.png index 005f8ce9..99cd0288 100644 Binary files a/docs/linkding-screenshot.png and b/docs/linkding-screenshot.png differ diff --git a/package-lock.json b/package-lock.json index 65da0424..1540514d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,25 @@ { "name": "linkding", - "version": "1.31.0", + "version": "1.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkding", - "version": "1.31.0", + "version": "1.33.0", "license": "MIT", "dependencies": { + "@hotwired/turbo": "^8.0.6", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", - "@rollup/wasm-node": "^4.18.1", - "rollup-plugin-svelte": "^7.2.2", - "spectre.css": "^0.5.9", - "svelte": "^4.2.18" + "@rollup/wasm-node": "^4.13.0", + "cssnano": "^7.0.6", + "postcss": "^8.4.45", + "postcss-cli": "^11.0.0", + "postcss-import": "^16.1.0", + "postcss-nesting": "^13.0.0", + "rollup-plugin-svelte": "^7.2.0", + "svelte": "^4.0.0" }, "devDependencies": { "prettier": "^3.3.2" @@ -31,6 +36,56 @@ "node": ">=6.0.0" } }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-2.0.0.tgz", + "integrity": "sha512-oklSrRvOxNeeOW1yARd4WNCs/D09cQjunGZUgSq6vM8GpzFswN+8rBZyJA29YFZhOTQ6GFzxgLDNtVbt9wPZMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.1.0" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz", + "integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.1.0" + } + }, + "node_modules/@hotwired/turbo": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.6.tgz", + "integrity": "sha512-mwZRfwcJ4yatUnW5tcCY9NDvo0kjuuLQF/y8pXigHhS+c/JY/ccNluVyuERR9Sraqx0qdpenkO3pNeSWz1mE3w==", + "engines": { + "node": ">= 14" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "license": "MIT", @@ -77,6 +132,38 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.2.3", "license": "MIT", @@ -386,6 +473,25 @@ "fsevents": "~2.3.2" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "license": "MIT" @@ -404,6 +510,40 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/aria-query": { "version": "5.3.0", "license": "Apache-2.0", @@ -418,6 +558,64 @@ "dequal": "^2.0.3" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "license": "MIT" @@ -432,6 +630,72 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001660", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", + "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/code-red": { "version": "1.0.4", "license": "MIT", @@ -443,10 +707,65 @@ "periscopic": "^3.1.0" } }, + "node_modules/code-red/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + }, "node_modules/commander": { "version": "2.20.3", "license": "MIT" }, + "node_modules/css-declaration-sorter": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", + "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "2.3.1", "license": "MIT", @@ -458,6 +777,131 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.6.tgz", + "integrity": "sha512-54woqx8SCbp8HwvNZYn68ZFAepuouZW4lTwiMVnBErM3VkO7/Sd4oTOt3Zz3bPx3kxQ36aISppyXj2Md4lg8bw==", + "dependencies": { + "cssnano-preset-default": "^7.0.6", + "lilconfig": "^3.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.6.tgz", + "integrity": "sha512-ZzrgYupYxEvdGGuqL+JKOY70s7+saoNlHSCK/OGn1vB2pQK8KSET8jvenzItcY+kA7NoWvfbb/YhlzuzNKjOhQ==", + "dependencies": { + "browserslist": "^4.23.3", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^5.0.0", + "postcss-calc": "^10.0.2", + "postcss-colormin": "^7.0.2", + "postcss-convert-values": "^7.0.4", + "postcss-discard-comments": "^7.0.3", + "postcss-discard-duplicates": "^7.0.1", + "postcss-discard-empty": "^7.0.0", + "postcss-discard-overridden": "^7.0.0", + "postcss-merge-longhand": "^7.0.4", + "postcss-merge-rules": "^7.0.4", + "postcss-minify-font-values": "^7.0.0", + "postcss-minify-gradients": "^7.0.0", + "postcss-minify-params": "^7.0.2", + "postcss-minify-selectors": "^7.0.4", + "postcss-normalize-charset": "^7.0.0", + "postcss-normalize-display-values": "^7.0.0", + "postcss-normalize-positions": "^7.0.0", + "postcss-normalize-repeat-style": "^7.0.0", + "postcss-normalize-string": "^7.0.0", + "postcss-normalize-timing-functions": "^7.0.0", + "postcss-normalize-unicode": "^7.0.2", + "postcss-normalize-url": "^7.0.0", + "postcss-normalize-whitespace": "^7.0.0", + "postcss-ordered-values": "^7.0.1", + "postcss-reduce-initial": "^7.0.2", + "postcss-reduce-transforms": "^7.0.0", + "postcss-svgo": "^7.0.1", + "postcss-unique-selectors": "^7.0.3" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.0.tgz", + "integrity": "sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" + }, "node_modules/deepmerge": { "version": "4.3.1", "license": "MIT", @@ -465,6 +909,14 @@ "node": ">=0.10.0" } }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "license": "MIT", @@ -472,6 +924,86 @@ "node": ">=6" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.20", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.20.tgz", + "integrity": "sha512-74mdl6Fs1HHzK9SUX4CKFxAtAe3nUns48y79TskHNAG6fGOlLfyKA4j855x+0b5u8rWJIrlaG9tcTPstMlwjIw==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "license": "MIT", @@ -479,6 +1011,53 @@ "@types/estree": "^1.0.0" } }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "license": "MIT", @@ -497,14 +1076,88 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "engines": { - "node": ">= 0.4" + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stdin": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", + "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/is-builtin-module": { @@ -514,71 +1167,804 @@ "builtin-modules": "^3.3.0" }, "engines": { - "node": ">=6" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", + "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + }, + "node_modules/magic-string": { + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/periscopic/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postcss": { + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.0.2.tgz", + "integrity": "sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==", + "dependencies": { + "postcss-selector-parser": "^6.1.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12 || ^20.9 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/postcss-cli": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-11.0.0.tgz", + "integrity": "sha512-xMITAI7M0u1yolVcXJ9XTZiO9aO49mcoKQy6pCDFdMh9kGqhzLVpWxeD/32M/QBmkhcGypZFFOLNLmIW4Pg4RA==", + "dependencies": { + "chokidar": "^3.3.0", + "dependency-graph": "^0.11.0", + "fs-extra": "^11.0.0", + "get-stdin": "^9.0.0", + "globby": "^14.0.0", + "picocolors": "^1.0.0", + "postcss-load-config": "^5.0.0", + "postcss-reporter": "^7.0.0", + "pretty-hrtime": "^1.0.3", + "read-cache": "^1.0.0", + "slash": "^5.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "postcss": "index.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-colormin": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.2.tgz", + "integrity": "sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0", + "colord": "^2.9.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-convert-values": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.4.tgz", + "integrity": "sha512-e2LSXPqEHVW6aoGbjV9RsSSNDO3A0rZLCBxN24zvxF25WknMPpX8Dm9UxxThyEbaytzggRuZxaGXqaOhxQ514Q==", + "dependencies": { + "browserslist": "^4.23.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-comments": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.3.tgz", + "integrity": "sha512-q6fjd4WU4afNhWOA2WltHgCbkRhZPgQe7cXF74fuVB/ge4QbM9HEaOIzGSiMvM+g/cOsNAUGdf2JDzqA2F8iLA==", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.1.tgz", + "integrity": "sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-empty": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.0.tgz", + "integrity": "sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.0.tgz", + "integrity": "sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-import": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.0.tgz", + "integrity": "sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-load-config": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-5.1.0.tgz", + "integrity": "sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1", + "yaml": "^2.4.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + } + } + }, + "node_modules/postcss-merge-longhand": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.4.tgz", + "integrity": "sha512-zer1KoZA54Q8RVHKOY5vMke0cCdNxMP3KBfDerjH/BYHh4nCIh+1Yy0t1pAEQF18ac/4z3OFclO+ZVH8azjR4A==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^7.0.4" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-rules": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.4.tgz", + "integrity": "sha512-ZsaamiMVu7uBYsIdGtKJ64PkcQt6Pcpep/uO90EpLS3dxJi6OXamIobTYcImyXGoW0Wpugh7DSD3XzxZS9JCPg==", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^5.0.0", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.0.tgz", + "integrity": "sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.0.tgz", + "integrity": "sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==", + "dependencies": { + "colord": "^2.9.3", + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-params": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.2.tgz", + "integrity": "sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==", + "dependencies": { + "browserslist": "^4.23.3", + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.4.tgz", + "integrity": "sha512-JG55VADcNb4xFCf75hXkzc1rNeURhlo7ugf6JjiiKRfMsKlDzN9CXHZDyiG6x/zGchpjQS+UAgb1d4nqXqOpmA==", + "dependencies": { + "cssesc": "^3.0.0", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-nesting": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.0.tgz", + "integrity": "sha512-TCGQOizyqvEkdeTPM+t6NYwJ3EJszYE/8t8ILxw/YoeUvz2rz7aM8XTAmBWh9/DJjfaaabL88fWrsVHSPF2zgA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/selector-resolve-nested": "^2.0.0", + "@csstools/selector-specificity": "^4.0.0", + "postcss-selector-parser": "^6.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.0.tgz", + "integrity": "sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.0.tgz", + "integrity": "sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.0.tgz", + "integrity": "sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.0.tgz", + "integrity": "sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-string": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.0.tgz", + "integrity": "sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.0.tgz", + "integrity": "sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.2.tgz", + "integrity": "sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==", + "dependencies": { + "browserslist": "^4.23.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.4.31" } }, - "node_modules/is-core-module": { - "version": "2.13.1", - "license": "MIT", + "node_modules/postcss-normalize-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.0.tgz", + "integrity": "sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==", "dependencies": { - "hasown": "^2.0.0" + "postcss-value-parser": "^4.2.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" } }, - "node_modules/is-module": { - "version": "1.0.0", - "license": "MIT" + "node_modules/postcss-normalize-whitespace": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.0.tgz", + "integrity": "sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } }, - "node_modules/is-reference": { - "version": "3.0.2", - "license": "MIT", + "node_modules/postcss-ordered-values": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.1.tgz", + "integrity": "sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==", "dependencies": { - "@types/estree": "*" + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "license": "MIT" + "node_modules/postcss-reduce-initial": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.2.tgz", + "integrity": "sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } }, - "node_modules/magic-string": { - "version": "0.30.10", - "license": "MIT", + "node_modules/postcss-reduce-transforms": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.0.tgz", + "integrity": "sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" } }, - "node_modules/mdn-data": { - "version": "2.0.30", - "license": "CC0-1.0" + "node_modules/postcss-reporter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-7.1.0.tgz", + "integrity": "sha512-/eoEylGWyy6/DOiMP5lmFRdmDKThqgn7D6hP2dXKJI/0rJSO1ADFNngZfDzxL0YAxFvws+Rtpuji1YIHj4mySA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "picocolors": "^1.0.0", + "thenby": "^1.3.4" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } }, - "node_modules/path-parse": { - "version": "1.0.7", - "license": "MIT" + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } }, - "node_modules/periscopic": { - "version": "3.1.0", - "license": "MIT", + "node_modules/postcss-svgo": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.1.tgz", + "integrity": "sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^3.0.0", - "is-reference": "^3.0.0" + "postcss-value-parser": "^4.2.0", + "svgo": "^3.3.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.4.31" } }, - "node_modules/picomatch": { - "version": "2.3.1", - "license": "MIT", + "node_modules/postcss-unique-selectors": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.3.tgz", + "integrity": "sha512-J+58u5Ic5T1QjP/LDV9g3Cx4CNOgB5vz+kM6+OxHHhFACdcDeKhBXjQmB7fnIZM12YSTvsL0Opwco83DmacW2g==", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, "engines": { - "node": ">=8.6" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "peerDependencies": { + "postcss": "^8.4.31" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/prettier": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", @@ -595,6 +1981,33 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/randombytes": { "version": "2.1.0", "license": "MIT", @@ -602,6 +2015,33 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "license": "MIT", @@ -624,6 +2064,15 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.16.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.4.tgz", @@ -676,6 +2125,40 @@ "svelte": ">=3.5.0" } }, + "node_modules/rollup-plugin-svelte/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -701,6 +2184,17 @@ "randombytes": "^2.1.0" } }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smob": { "version": "1.5.0", "license": "MIT" @@ -713,8 +2207,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "license": "BSD-3-Clause", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -727,9 +2222,44 @@ "source-map": "^0.6.0" } }, - "node_modules/spectre.css": { - "version": "0.5.9", - "license": "MIT" + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylehacks": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz", + "integrity": "sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==", + "dependencies": { + "browserslist": "^4.23.3", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", @@ -742,10 +2272,9 @@ } }, "node_modules/svelte": { - "version": "4.2.18", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", - "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", - "license": "MIT", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", + "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -766,6 +2295,46 @@ "node": ">=16" } }, + "node_modules/svelte/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, "node_modules/terser": { "version": "5.30.4", "license": "BSD-2-Clause", @@ -781,6 +2350,135 @@ "engines": { "node": ">=10" } + }, + "node_modules/thenby": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.3.4.tgz", + "integrity": "sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } } } } diff --git a/package.json b/package.json index 4542272c..ea40e81f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "linkding", - "version": "1.32.0", + "version": "1.34.0", "description": "", "main": "index.js", "scripts": { - "build": "rollup -c", + "build": "npm run build-js && npm run build-theme-light && npm run build-theme-dark", + "build-js": "rollup -c", + "build-theme-light": "postcss -o bookmarks/static/theme-light.css bookmarks/styles/theme-light.css", + "build-theme-dark": "postcss -o bookmarks/static/theme-dark.css bookmarks/styles/theme-dark.css", "dev": "rollup -c -w" }, "repository": { @@ -19,9 +22,15 @@ }, "homepage": "https://github.com/sissbruecker/linkding#readme", "dependencies": { + "@hotwired/turbo": "^8.0.6", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/wasm-node": "^4.18.1", + "cssnano": "^7.0.6", + "postcss": "^8.4.45", + "postcss-cli": "^11.0.0", + "postcss-import": "^16.1.0", + "postcss-nesting": "^13.0.0", "rollup-plugin-svelte": "^7.2.2", "spectre.css": "^0.5.9", "svelte": "^4.2.18" diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..9eb08c96 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,13 @@ +const cssnano = require("cssnano"); +const postcssImport = require("postcss-import"); +const postcssNesting = require("postcss-nesting"); + +module.exports = { + plugins: [ + postcssImport, + postcssNesting, + cssnano({ + preset: "default", + }), + ], +}; diff --git a/requirements.dev.in b/requirements.dev.in index 09776d89..fc2af78a 100644 --- a/requirements.dev.in +++ b/requirements.dev.in @@ -1,8 +1,6 @@ black coverage -django-compressor django-debug-toolbar -libsass playwright pytest pytest-django diff --git a/requirements.dev.txt b/requirements.dev.txt index 4830e31e..c43026c3 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -12,14 +12,7 @@ click==8.1.7 # via black coverage==7.6.0 # via -r requirements.dev.in -django==5.0.8 - # via - # django-appconf - # django-debug-toolbar -django-appconf==1.0.6 - # via django-compressor -django-compressor==4.4 - # via -r requirements.dev.in +django==5.0.9 django-debug-toolbar==4.4.6 # via -r requirements.dev.in execnet==2.1.1 @@ -28,8 +21,6 @@ greenlet==3.0.3 # via playwright iniconfig==2.0.0 # via pytest -libsass==0.23.0 - # via -r requirements.dev.in mypy-extensions==1.0.0 # via black packaging==24.1 @@ -55,10 +46,6 @@ pytest-django==4.8.0 # via -r requirements.dev.in pytest-xdist==3.6.1 # via -r requirements.dev.in -rcssmin==1.1.1 - # via django-compressor -rjsmin==1.2.1 - # via django-compressor sqlparse==0.5.0 # via # django diff --git a/requirements.in b/requirements.in index a5fa1613..de4069fd 100644 --- a/requirements.in +++ b/requirements.in @@ -4,7 +4,6 @@ bleach-allowlist Django django-environ django-registration -django-sass-processor django-widget-tweaks djangorestframework huey diff --git a/requirements.txt b/requirements.txt index e885c898..e0463984 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,8 +37,6 @@ django-environ==0.11.2 # via -r requirements.in django-registration==3.4 # via -r requirements.in -django-sass-processor==1.4.1 - # via -r requirements.in django-widget-tweaks==1.5.0 # via -r requirements.in djangorestframework==3.15.2 diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index 76f43856..80ad4858 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -6,9 +6,7 @@ playwright install chromium # Test server loads assets from static folder, so make sure files there are up-to-date rm -rf static npm run build -python manage.py compilescss -python manage.py collectstatic --ignore=*.scss -python manage.py compilescss --delete-files +python manage.py collectstatic # Run E2E tests python manage.py test bookmarks.e2e --pattern="e2e_test_*.py" diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py index edeabdc8..165a32df 100644 --- a/siteroot/settings/base.py +++ b/siteroot/settings/base.py @@ -47,7 +47,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "sass_processor", "widget_tweaks", "rest_framework", "rest_framework.authtoken", @@ -61,7 +60,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "bookmarks.middlewares.UserProfileMiddleware", + "bookmarks.middlewares.LinkdingMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.locale.LocaleMiddleware", @@ -144,19 +143,10 @@ # Collect static files in static folder STATIC_ROOT = os.path.join(BASE_DIR, "static") -# Turn off SASS compilation by default -SASS_PROCESSOR_ENABLED = False - -# Add SASS preprocessor finder to resolve generated CSS -STATICFILES_FINDERS = [ - "django.contrib.staticfiles.finders.FileSystemFinder", - "django.contrib.staticfiles.finders.AppDirectoriesFinder", - "sass_processor.finders.CssFinder", -] - -# Enable SASS processor to find custom folder for SCSS sources through static file finders STATICFILES_DIRS = [ + # Resolve theme files from style source folder os.path.join(BASE_DIR, "bookmarks", "styles"), + # Resolve downloaded files in dev environment os.path.join(BASE_DIR, "data", "favicons"), os.path.join(BASE_DIR, "data", "previews"), ] diff --git a/siteroot/settings/dev.py b/siteroot/settings/dev.py index 277fd5ec..e40315b3 100644 --- a/siteroot/settings/dev.py +++ b/siteroot/settings/dev.py @@ -8,12 +8,10 @@ # Turn on debug mode DEBUG = True -# Turn on SASS compilation -SASS_PROCESSOR_ENABLED = True # Enable debug toolbar -INSTALLED_APPS.append("debug_toolbar") -MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") +# INSTALLED_APPS.append("debug_toolbar") +# MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") INTERNAL_IPS = [ "127.0.0.1", diff --git a/siteroot/settings/prod.py b/siteroot/settings/prod.py index e864a82a..48f36e98 100644 --- a/siteroot/settings/prod.py +++ b/siteroot/settings/prod.py @@ -11,8 +11,6 @@ # Turn of debug mode DEBUG = False -# Turn off SASS compilation -SASS_PROCESSOR_ENABLED = True # Try read secret key from file try: diff --git a/version.txt b/version.txt index 359c4108..2b17ffd5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.32.0 +1.34.0