From be789ea9e6df643005a96315e1bd1426b0061eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Mon, 21 Aug 2023 23:12:00 +0200 Subject: [PATCH] Avoid page reload when triggering actions in bookmark list (#506) * Extract bookmark view contexts * Implement basic partial updates for bookmark list and tag cloud * Refactor confirm button JS into web component * Refactor bulk edit JS into web component * Refactor tag autocomplete JS into web component * Refactor bookmark page JS into web component * Refactor global shortcuts JS into web component * Update tests * Add E2E test for partial updates * Add partial updates for archived bookmarks * Add partial updates for shared bookmarks * Cleanup helpers * Improve naming in bulk edit * Refactor shared components into behaviors * Refactor bulk edit components into behaviors * Refactor bookmark list components into behaviors * Update tests * Combine all scripts into bundle * Fix E2E CI --- .github/workflows/main.yaml | 3 + .../components/SearchAutoComplete.svelte | 271 ----------------- bookmarks/components/SearchHistory.js | 48 ---- bookmarks/components/TagAutocomplete.svelte | 168 ----------- bookmarks/components/api.js | 32 --- bookmarks/components/index.js | 10 - bookmarks/components/util.js | 37 --- ...mark_list.py => e2e_test_bookmark_item.py} | 12 +- .../e2e_test_bookmark_page_partial_updates.py | 252 ++++++++++++++++ bookmarks/e2e/helpers.py | 26 +- bookmarks/frontend/api.js | 29 ++ bookmarks/frontend/behaviors/bookmark-page.js | 65 +++++ bookmarks/frontend/behaviors/bulk-edit.js | 100 +++++++ .../frontend/behaviors/confirm-button.js | 50 ++++ .../frontend/behaviors/global-shortcuts.js | 73 +++++ bookmarks/frontend/behaviors/index.js | 36 +++ .../frontend/behaviors/tag-autocomplete.js | 26 ++ .../components/SearchAutoComplete.svelte | 272 ++++++++++++++++++ .../frontend/components/SearchHistory.js | 52 ++++ .../components/TagAutocomplete.svelte | 170 +++++++++++ bookmarks/frontend/index.js | 14 + bookmarks/frontend/util.js | 37 +++ bookmarks/static/bookmark_list.js | 171 ----------- bookmarks/static/shared.js | 83 ------ bookmarks/styles/bookmarks.scss | 120 ++++---- bookmarks/styles/shared.scss | 10 +- bookmarks/templates/bookmarks/archive.html | 29 +- .../templates/bookmarks/bookmark_list.html | 220 +++++++------- .../templates/bookmarks/bulk_edit/bar.html | 56 ++-- .../templates/bookmarks/bulk_edit/state.html | 1 - .../templates/bookmarks/bulk_edit/toggle.html | 16 +- bookmarks/templates/bookmarks/form.html | 19 +- bookmarks/templates/bookmarks/index.html | 29 +- bookmarks/templates/bookmarks/layout.html | 2 +- bookmarks/templates/bookmarks/shared.html | 24 +- bookmarks/templates/bookmarks/tag_cloud.html | 6 +- bookmarks/templatetags/bookmarks.py | 58 +--- bookmarks/tests/helpers.py | 38 +++ .../tests/test_bookmark_archived_view.py | 2 +- ...test_bookmark_archived_view_performance.py | 4 +- bookmarks/tests/test_bookmark_edit_view.py | 2 +- bookmarks/tests/test_bookmark_index_view.py | 2 +- .../test_bookmark_index_view_performance.py | 4 +- bookmarks/tests/test_bookmark_shared_view.py | 2 +- .../test_bookmark_shared_view_performance.py | 4 +- ...tag.py => test_bookmarks_list_template.py} | 183 ++++++------ ...loud_tag.py => test_tag_cloud_template.py} | 87 +++--- bookmarks/urls.py | 15 +- bookmarks/views/bookmarks.py | 101 ++----- bookmarks/views/partials/__init__.py | 58 ++++ bookmarks/views/partials/contexts.py | 168 +++++++++++ package-lock.json | 28 +- package.json | 3 + rollup.config.js | 2 +- 54 files changed, 1942 insertions(+), 1388 deletions(-) delete mode 100644 bookmarks/components/SearchAutoComplete.svelte delete mode 100644 bookmarks/components/SearchHistory.js delete mode 100644 bookmarks/components/TagAutocomplete.svelte delete mode 100644 bookmarks/components/api.js delete mode 100644 bookmarks/components/index.js delete mode 100644 bookmarks/components/util.js rename bookmarks/e2e/{e2e_test_bookmark_list.py => e2e_test_bookmark_item.py} (60%) create mode 100644 bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py create mode 100644 bookmarks/frontend/api.js create mode 100644 bookmarks/frontend/behaviors/bookmark-page.js create mode 100644 bookmarks/frontend/behaviors/bulk-edit.js create mode 100644 bookmarks/frontend/behaviors/confirm-button.js create mode 100644 bookmarks/frontend/behaviors/global-shortcuts.js create mode 100644 bookmarks/frontend/behaviors/index.js create mode 100644 bookmarks/frontend/behaviors/tag-autocomplete.js create mode 100644 bookmarks/frontend/components/SearchAutoComplete.svelte create mode 100644 bookmarks/frontend/components/SearchHistory.js create mode 100644 bookmarks/frontend/components/TagAutocomplete.svelte create mode 100644 bookmarks/frontend/index.js create mode 100644 bookmarks/frontend/util.js delete mode 100644 bookmarks/static/bookmark_list.js delete mode 100644 bookmarks/static/shared.js delete mode 100644 bookmarks/templates/bookmarks/bulk_edit/state.html rename bookmarks/tests/{test_bookmarks_list_tag.py => test_bookmarks_list_template.py} (75%) rename bookmarks/tests/{test_tag_cloud_tag.py => test_tag_cloud_template.py} (69%) create mode 100644 bookmarks/views/partials/__init__.py create mode 100644 bookmarks/views/partials/contexts.py diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d7d48b9..a72bf51 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -41,6 +41,9 @@ jobs: run: | pip install -r requirements.txt playwright install chromium + - name: Run build + run: | + npm run build python manage.py compilescss python manage.py collectstatic --ignore=*.scss - name: Run tests diff --git a/bookmarks/components/SearchAutoComplete.svelte b/bookmarks/components/SearchAutoComplete.svelte deleted file mode 100644 index c04ecc2..0000000 --- a/bookmarks/components/SearchAutoComplete.svelte +++ /dev/null @@ -1,271 +0,0 @@ - - -
-
- -
- - -
- - \ No newline at end of file diff --git a/bookmarks/components/SearchHistory.js b/bookmarks/components/SearchHistory.js deleted file mode 100644 index c1821fd..0000000 --- a/bookmarks/components/SearchHistory.js +++ /dev/null @@ -1,48 +0,0 @@ -const SEARCH_HISTORY_KEY = 'searchHistory' -const MAX_ENTRIES = 30 - -export class SearchHistory { - - getHistory() { - const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY) - return historyJson ? JSON.parse(historyJson) : { - recent: [] - } - } - - pushCurrent() { - // Skip if browser is not compatible - if (!window.URLSearchParams) return - const urlParams = new URLSearchParams(window.location.search); - const searchParam = urlParams.get('q'); - - if (!searchParam) return - - this.push(searchParam) - } - - push(search) { - const history = this.getHistory() - - history.recent.unshift(search) - - // Remove duplicates and clamp to max entries - history.recent = history.recent.reduce((acc, cur) => { - if (acc.length >= MAX_ENTRIES) return acc - if (acc.indexOf(cur) >= 0) return acc - acc.push(cur) - return acc - }, []) - - const newHistoryJson = JSON.stringify(history) - localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson) - } - - getRecentSearches(query, max) { - const history = this.getHistory() - - return history.recent - .filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0) - .slice(0, max) - } -} \ No newline at end of file diff --git a/bookmarks/components/TagAutocomplete.svelte b/bookmarks/components/TagAutocomplete.svelte deleted file mode 100644 index 27a6aa0..0000000 --- a/bookmarks/components/TagAutocomplete.svelte +++ /dev/null @@ -1,168 +0,0 @@ - - -
- -
- - -
- - - -
- - diff --git a/bookmarks/components/api.js b/bookmarks/components/api.js deleted file mode 100644 index 31641a5..0000000 --- a/bookmarks/components/api.js +++ /dev/null @@ -1,32 +0,0 @@ -export class ApiClient { - constructor(baseUrl) { - this.baseUrl = baseUrl - } - - listBookmarks(filters, options = {limit: 100, offset: 0, path: ''}) { - const query = [ - `limit=${options.limit}`, - `offset=${options.offset}`, - ] - Object.keys(filters).forEach(key => { - const value = filters[key] - if (value) { - query.push(`${key}=${encodeURIComponent(value)}`) - } - }) - const queryString = query.join('&') - const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}` - - return fetch(url) - .then(response => response.json()) - .then(data => data.results) - } - - getTags(options = {limit: 100, offset: 0}) { - const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}` - - return fetch(url) - .then(response => response.json()) - .then(data => data.results) - } -} \ No newline at end of file diff --git a/bookmarks/components/index.js b/bookmarks/components/index.js deleted file mode 100644 index 96c5768..0000000 --- a/bookmarks/components/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import TagAutoComplete from './TagAutocomplete.svelte' -import SearchAutoComplete from './SearchAutoComplete.svelte' -import {ApiClient} from './api' - -export default { - ApiClient, - TagAutoComplete, - SearchAutoComplete -} - diff --git a/bookmarks/components/util.js b/bookmarks/components/util.js deleted file mode 100644 index 0bfac66..0000000 --- a/bookmarks/components/util.js +++ /dev/null @@ -1,37 +0,0 @@ -export function debounce(callback, delay = 250) { - let timeoutId - return (...args) => { - clearTimeout(timeoutId) - timeoutId = setTimeout(() => { - timeoutId = null - callback(...args) - }, delay) - } -} - -export function clampText(text, maxChars = 30) { - if(!text || text.length <= 30) return text - - return text.substr(0, maxChars) + '...' -} - -export function getCurrentWordBounds(input) { - const text = input.value; - const end = input.selectionStart; - let start = end; - - let currentChar = text.charAt(start - 1); - - while (currentChar && currentChar !== ' ' && start > 0) { - start--; - currentChar = text.charAt(start - 1); - } - - return {start, end}; -} - -export function getCurrentWord(input) { - const bounds = getCurrentWordBounds(input); - - return input.value.substring(bounds.start, bounds.end); -} \ No newline at end of file diff --git a/bookmarks/e2e/e2e_test_bookmark_list.py b/bookmarks/e2e/e2e_test_bookmark_item.py similarity index 60% rename from bookmarks/e2e/e2e_test_bookmark_list.py rename to bookmarks/e2e/e2e_test_bookmark_item.py index 789ac8f..796535f 100644 --- a/bookmarks/e2e/e2e_test_bookmark_list.py +++ b/bookmarks/e2e/e2e_test_bookmark_item.py @@ -6,17 +6,15 @@ from playwright.sync_api import sync_playwright, expect from bookmarks.e2e.helpers import LinkdingE2ETestCase -@skip("Fails in CI, needs investigation") -class BookmarkListE2ETestCase(LinkdingE2ETestCase): +class BookmarkItemE2ETestCase(LinkdingE2ETestCase): + @skip("Fails in CI, needs investigation") def test_toggle_notes_should_show_hide_notes(self): - self.setup_bookmark(notes='Test notes') + bookmark = self.setup_bookmark(notes='Test notes') with sync_playwright() as p: - browser = self.setup_browser(p) - page = browser.new_page() - page.goto(self.live_server_url + reverse('bookmarks:index')) + page = self.open(reverse('bookmarks:index'), p) - notes = page.locator('li .notes') + notes = self.locate_bookmark(bookmark.title).locator('.notes') expect(notes).to_be_hidden() toggle_notes = page.locator('li button.toggle-notes') diff --git a/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py b/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py new file mode 100644 index 0000000..d1cfe04 --- /dev/null +++ b/bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py @@ -0,0 +1,252 @@ +from typing import List + +from django.urls import reverse +from playwright.sync_api import sync_playwright, expect + +from bookmarks.e2e.helpers import LinkdingE2ETestCase + + +class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): + def setup_fixture(self): + profile = self.get_or_create_test_user().profile + profile.enable_sharing = True + profile.save() + + # create a number of bookmarks with different states / visibility to + # verify correct data is loaded on update + self.setup_numbered_bookmarks(3, with_tags=True) + self.setup_numbered_bookmarks(3, with_tags=True, archived=True) + self.setup_numbered_bookmarks(3, + shared=True, + prefix="Joe's Bookmark", + user=self.setup_user(enable_sharing=True)) + + def assertVisibleBookmarks(self, titles: List[str]): + bookmark_tags = self.page.locator('li[ld-bookmark-item]') + expect(bookmark_tags).to_have_count(len(titles)) + + for title in titles: + matching_tag = bookmark_tags.filter(has_text=title) + expect(matching_tag).to_be_visible() + + def assertVisibleTags(self, titles: List[str]): + tag_tags = self.page.locator('.tag-cloud .unselected-tags a') + expect(tag_tags).to_have_count(len(titles)) + + for title in titles: + matching_tag = tag_tags.filter(has_text=title) + expect(matching_tag).to_be_visible() + + def test_partial_update_respects_query(self): + self.setup_numbered_bookmarks(5, prefix='foo') + self.setup_numbered_bookmarks(5, prefix='bar') + + with sync_playwright() as p: + url = reverse('bookmarks:index') + '?q=foo' + self.open(url, p) + + self.assertVisibleBookmarks(['foo 1', 'foo 2', 'foo 3', 'foo 4', 'foo 5']) + + self.locate_bookmark('foo 2').get_by_text('Archive').click() + self.assertVisibleBookmarks(['foo 1', 'foo 3', 'foo 4', 'foo 5']) + + def test_partial_update_respects_page(self): + # add a suffix, otherwise 'foo 1' also matches 'foo 10' + self.setup_numbered_bookmarks(50, prefix='foo', suffix='-') + + with sync_playwright() as p: + url = reverse('bookmarks:index') + '?q=foo&page=2' + self.open(url, p) + + # with descending sort, page two has 'foo 1' to 'foo 20' + expected_titles = [f'foo {i}-' for i in range(1, 21)] + self.assertVisibleBookmarks(expected_titles) + + self.locate_bookmark('foo 20-').get_by_text('Archive').click() + + expected_titles = [f'foo {i}-' for i in range(1, 20)] + self.assertVisibleBookmarks(expected_titles) + + def test_multiple_partial_updates(self): + self.setup_numbered_bookmarks(5) + + with sync_playwright() as p: + url = reverse('bookmarks:index') + self.open(url, p) + + self.locate_bookmark('Bookmark 1').get_by_text('Archive').click() + self.assertVisibleBookmarks(['Bookmark 2', 'Bookmark 3', 'Bookmark 4', 'Bookmark 5']) + + self.locate_bookmark('Bookmark 2').get_by_text('Archive').click() + self.assertVisibleBookmarks(['Bookmark 3', 'Bookmark 4', 'Bookmark 5']) + + self.locate_bookmark('Bookmark 3').get_by_text('Archive').click() + self.assertVisibleBookmarks(['Bookmark 4', 'Bookmark 5']) + + self.assertReloads(0) + + def test_active_bookmarks_partial_update_on_archive(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:index'), p) + + self.locate_bookmark('Bookmark 2').get_by_text('Archive').click() + + self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) + self.assertVisibleTags(['Tag 1', 'Tag 3']) + self.assertReloads(0) + + def test_active_bookmarks_partial_update_on_delete(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:index'), p) + + self.locate_bookmark('Bookmark 2').get_by_text('Remove').click() + self.locate_bookmark('Bookmark 2').get_by_text('Confirm').click() + + self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) + self.assertVisibleTags(['Tag 1', 'Tag 3']) + self.assertReloads(0) + + def test_active_bookmarks_partial_update_on_mark_as_read(self): + self.setup_fixture() + bookmark2 = self.get_numbered_bookmark('Bookmark 2') + bookmark2.unread = True + bookmark2.save() + + with sync_playwright() as p: + self.open(reverse('bookmarks:index'), p) + + expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic') + self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click() + + expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic') + self.assertReloads(0) + + def test_active_bookmarks_partial_update_on_bulk_archive(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:index'), p) + + self.locate_bulk_edit_toggle().click() + self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() + self.locate_bulk_edit_bar().get_by_text('Archive').click() + self.locate_bulk_edit_bar().get_by_text('Confirm').click() + + self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) + self.assertVisibleTags(['Tag 1', 'Tag 3']) + self.assertReloads(0) + + def test_active_bookmarks_partial_update_on_bulk_delete(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:index'), p) + + self.locate_bulk_edit_toggle().click() + self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() + self.locate_bulk_edit_bar().get_by_text('Delete').click() + self.locate_bulk_edit_bar().get_by_text('Confirm').click() + + self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) + self.assertVisibleTags(['Tag 1', 'Tag 3']) + self.assertReloads(0) + + def test_archived_bookmarks_partial_update_on_unarchive(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:archived'), p) + + self.locate_bookmark('Archived Bookmark 2').get_by_text('Unarchive').click() + + self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) + self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) + self.assertReloads(0) + + def test_archived_bookmarks_partial_update_on_delete(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:archived'), p) + + self.locate_bookmark('Archived Bookmark 2').get_by_text('Remove').click() + self.locate_bookmark('Archived Bookmark 2').get_by_text('Confirm').click() + + self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) + self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) + self.assertReloads(0) + + def test_archived_bookmarks_partial_update_on_bulk_archive(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:archived'), p) + + self.locate_bulk_edit_toggle().click() + self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() + self.locate_bulk_edit_bar().get_by_text('Archive').click() + self.locate_bulk_edit_bar().get_by_text('Confirm').click() + + self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) + self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) + self.assertReloads(0) + + def test_archived_bookmarks_partial_update_on_bulk_delete(self): + self.setup_fixture() + + with sync_playwright() as p: + self.open(reverse('bookmarks:archived'), p) + + self.locate_bulk_edit_toggle().click() + self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() + self.locate_bulk_edit_bar().get_by_text('Delete').click() + self.locate_bulk_edit_bar().get_by_text('Confirm').click() + + self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) + self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) + self.assertReloads(0) + + def test_shared_bookmarks_partial_update_on_unarchive(self): + self.setup_fixture() + self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True) + + with sync_playwright() as p: + self.open(reverse('bookmarks:shared'), p) + + self.locate_bookmark('My Bookmark 2').get_by_text('Archive').click() + + # Shared bookmarks page also shows archived bookmarks, though it probably shouldn't + self.assertVisibleBookmarks([ + 'My Bookmark 1', + 'My Bookmark 2', + 'My Bookmark 3', + "Joe's Bookmark 1", + "Joe's Bookmark 2", + "Joe's Bookmark 3", + ]) + self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 2', 'Shared Tag 3']) + self.assertReloads(0) + + def test_shared_bookmarks_partial_update_on_delete(self): + self.setup_fixture() + self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True) + + with sync_playwright() as p: + self.open(reverse('bookmarks:shared'), p) + + self.locate_bookmark('My Bookmark 2').get_by_text('Remove').click() + self.locate_bookmark('My Bookmark 2').get_by_text('Confirm').click() + + self.assertVisibleBookmarks([ + 'My Bookmark 1', + 'My Bookmark 3', + "Joe's Bookmark 1", + "Joe's Bookmark 2", + "Joe's Bookmark 3", + ]) + self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 3']) + self.assertReloads(0) diff --git a/bookmarks/e2e/helpers.py b/bookmarks/e2e/helpers.py index 7a7116f..ef80625 100644 --- a/bookmarks/e2e/helpers.py +++ b/bookmarks/e2e/helpers.py @@ -1,5 +1,5 @@ from django.contrib.staticfiles.testing import LiveServerTestCase -from playwright.sync_api import BrowserContext +from playwright.sync_api import BrowserContext, Playwright, Page from bookmarks.tests.helpers import BookmarkFactoryMixin @@ -19,3 +19,27 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin): 'path': '/' }]) return context + + def open(self, url: str, playwright: Playwright) -> Page: + browser = self.setup_browser(playwright) + self.page = browser.new_page() + self.page.goto(self.live_server_url + url) + self.page.on('load', self.on_load) + self.num_loads = 0 + return self.page + + def on_load(self): + self.num_loads += 1 + + def assertReloads(self, count: int): + self.assertEqual(self.num_loads, count) + + def locate_bookmark(self, title: str): + bookmark_tags = self.page.locator('li[ld-bookmark-item]') + return bookmark_tags.filter(has_text=title) + + def locate_bulk_edit_bar(self): + return self.page.locator('.bulk-edit-bar') + + def locate_bulk_edit_toggle(self): + return self.page.get_by_title('Bulk edit') diff --git a/bookmarks/frontend/api.js b/bookmarks/frontend/api.js new file mode 100644 index 0000000..9062116 --- /dev/null +++ b/bookmarks/frontend/api.js @@ -0,0 +1,29 @@ +export class ApiClient { + constructor(baseUrl) { + this.baseUrl = baseUrl; + } + + listBookmarks(filters, options = { limit: 100, offset: 0, path: "" }) { + const query = [`limit=${options.limit}`, `offset=${options.offset}`]; + Object.keys(filters).forEach((key) => { + const value = filters[key]; + if (value) { + query.push(`${key}=${encodeURIComponent(value)}`); + } + }); + const queryString = query.join("&"); + const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`; + + return fetch(url) + .then((response) => response.json()) + .then((data) => data.results); + } + + getTags(options = { limit: 100, offset: 0 }) { + const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`; + + return fetch(url) + .then((response) => response.json()) + .then((data) => data.results); + } +} diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js new file mode 100644 index 0000000..f535f90 --- /dev/null +++ b/bookmarks/frontend/behaviors/bookmark-page.js @@ -0,0 +1,65 @@ +import { registerBehavior, swap } from "./index"; + +class BookmarkPage { + constructor(element) { + this.element = element; + this.form = element.querySelector("form.bookmark-actions"); + this.form.addEventListener("submit", this.onFormSubmit.bind(this)); + + this.bookmarkList = element.querySelector(".bookmark-list-container"); + this.tagCloud = element.querySelector(".tag-cloud-container"); + } + + async onFormSubmit(event) { + event.preventDefault(); + + const url = this.form.action; + const formData = new FormData(this.form); + formData.append(event.submitter.name, event.submitter.value); + + await fetch(url, { + method: "POST", + body: formData, + redirect: "manual", // ignore redirect + }); + await this.refresh(); + } + + async refresh() { + const query = window.location.search; + const bookmarksUrl = this.element.getAttribute("bookmarks-url"); + const tagsUrl = this.element.getAttribute("tags-url"); + Promise.all([ + fetch(`${bookmarksUrl}${query}`).then((response) => response.text()), + fetch(`${tagsUrl}${query}`).then((response) => response.text()), + ]).then(([bookmarkListHtml, tagCloudHtml]) => { + swap(this.bookmarkList, bookmarkListHtml); + swap(this.tagCloud, tagCloudHtml); + + this.bookmarkList.dispatchEvent( + new CustomEvent("bookmark-list-updated", { bubbles: true }), + ); + }); + } +} + +registerBehavior("ld-bookmark-page", BookmarkPage); + +class BookmarkItem { + constructor(element) { + this.element = element; + + const notesToggle = element.querySelector(".toggle-notes"); + if (notesToggle) { + notesToggle.addEventListener("click", this.onToggleNotes.bind(this)); + } + } + + onToggleNotes(event) { + event.preventDefault(); + event.stopPropagation(); + this.element.classList.toggle("show-notes"); + } +} + +registerBehavior("ld-bookmark-item", BookmarkItem); diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js new file mode 100644 index 0000000..fd9092d --- /dev/null +++ b/bookmarks/frontend/behaviors/bulk-edit.js @@ -0,0 +1,100 @@ +import { registerBehavior } from "./index"; + +class BulkEdit { + constructor(element) { + this.element = element; + this.active = false; + + element.addEventListener( + "bulk-edit-toggle-active", + this.onToggleActive.bind(this), + ); + element.addEventListener( + "bulk-edit-toggle-all", + this.onToggleAll.bind(this), + ); + element.addEventListener( + "bulk-edit-toggle-bookmark", + this.onToggleBookmark.bind(this), + ); + element.addEventListener( + "bookmark-list-updated", + this.onListUpdated.bind(this), + ); + } + + get allCheckbox() { + return this.element.querySelector("[ld-bulk-edit-checkbox][all] input"); + } + + get bookmarkCheckboxes() { + return [ + ...this.element.querySelectorAll( + "[ld-bulk-edit-checkbox]:not([all]) input", + ), + ]; + } + + onToggleActive() { + this.active = !this.active; + if (this.active) { + this.element.classList.add("active", "activating"); + setTimeout(() => { + this.element.classList.remove("activating"); + }, 500); + } else { + this.element.classList.remove("active"); + } + } + + onToggleBookmark() { + this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => { + return checkbox.checked; + }); + } + + onToggleAll() { + const checked = this.allCheckbox.checked; + this.bookmarkCheckboxes.forEach((checkbox) => { + checkbox.checked = checked; + }); + } + + onListUpdated() { + this.allCheckbox.checked = false; + this.bookmarkCheckboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + } +} + +class BulkEditActiveToggle { + constructor(element) { + this.element = element; + element.addEventListener("click", this.onClick.bind(this)); + } + + onClick() { + this.element.dispatchEvent( + new CustomEvent("bulk-edit-toggle-active", { bubbles: true }), + ); + } +} + +class BulkEditCheckbox { + constructor(element) { + this.element = element; + element.addEventListener("change", this.onChange.bind(this)); + } + + onChange() { + const type = this.element.hasAttribute("all") ? "all" : "bookmark"; + this.element.dispatchEvent( + new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }), + ); + } +} + +registerBehavior("ld-bulk-edit", BulkEdit); +registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle); +registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox); diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js new file mode 100644 index 0000000..742601a --- /dev/null +++ b/bookmarks/frontend/behaviors/confirm-button.js @@ -0,0 +1,50 @@ +import { registerBehavior } from "./index"; + +class ConfirmButtonBehavior { + constructor(element) { + const button = element; + button.dataset.type = button.type; + button.dataset.name = button.name; + button.dataset.value = button.value; + button.removeAttribute("type"); + button.removeAttribute("name"); + button.removeAttribute("value"); + button.addEventListener("click", this.onClick.bind(this)); + this.button = button; + } + + onClick(event) { + event.preventDefault(); + + const cancelButton = document.createElement(this.button.nodeName); + cancelButton.type = "button"; + cancelButton.innerText = "Cancel"; + cancelButton.className = "btn btn-link btn-sm mr-1"; + cancelButton.addEventListener("click", this.reset.bind(this)); + + const confirmButton = document.createElement(this.button.nodeName); + confirmButton.type = this.button.dataset.type; + confirmButton.name = this.button.dataset.name; + confirmButton.value = this.button.dataset.value; + confirmButton.innerText = "Confirm"; + confirmButton.className = "btn btn-link btn-sm"; + confirmButton.addEventListener("click", this.reset.bind(this)); + + const container = document.createElement("span"); + container.className = "confirmation"; + container.append(cancelButton, confirmButton); + this.container = container; + + this.button.before(container); + this.button.classList.add("d-none"); + } + + reset() { + setTimeout(() => { + this.container.remove(); + this.button.classList.remove("d-none"); + }); + } +} + +registerBehavior("ld-confirm-button", ConfirmButtonBehavior); diff --git a/bookmarks/frontend/behaviors/global-shortcuts.js b/bookmarks/frontend/behaviors/global-shortcuts.js new file mode 100644 index 0000000..f1700b4 --- /dev/null +++ b/bookmarks/frontend/behaviors/global-shortcuts.js @@ -0,0 +1,73 @@ +import { registerBehavior } from "./index"; + +class GlobalShortcuts { + constructor() { + document.addEventListener("keydown", this.onKeyDown.bind(this)); + } + + 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; + } + + // Handle shortcuts for navigating bookmarks with arrow keys + const isArrowUp = event.key === "ArrowUp"; + const isArrowDown = event.key === "ArrowDown"; + if (isArrowUp || isArrowDown) { + event.preventDefault(); + + // Detect current bookmark list item + const path = event.composedPath(); + const currentItem = path.find( + (item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"), + ); + + // Find next item + let nextItem; + if (currentItem) { + nextItem = isArrowUp + ? currentItem.previousElementSibling + : currentItem.nextElementSibling; + } else { + // Select first item + nextItem = document.querySelector("[ld-bookmark-item]"); + } + // Focus first link + if (nextItem) { + nextItem.querySelector("a").focus(); + } + } + + // Handle shortcut for toggling all notes + if (event.key === "e") { + const list = document.querySelector(".bookmark-list"); + if (list) { + list.classList.toggle("show-notes"); + } + } + + // Handle shortcut for focusing search input + if (event.key === "s") { + const searchInput = document.querySelector('input[type="search"]'); + + if (searchInput) { + searchInput.focus(); + event.preventDefault(); + } + } + + // Handle shortcut for adding new bookmark + if (event.key === "n") { + window.location.assign("/bookmarks/new"); + } + } +} + +registerBehavior("ld-global-shortcuts", GlobalShortcuts); diff --git a/bookmarks/frontend/behaviors/index.js b/bookmarks/frontend/behaviors/index.js new file mode 100644 index 0000000..548edb6 --- /dev/null +++ b/bookmarks/frontend/behaviors/index.js @@ -0,0 +1,36 @@ +const behaviorRegistry = {}; + +export function registerBehavior(name, behavior) { + behaviorRegistry[name] = behavior; + applyBehaviors(document, [name]); +} + +export function applyBehaviors(container, behaviorNames = null) { + if (!behaviorNames) { + behaviorNames = Object.keys(behaviorRegistry); + } + + behaviorNames.forEach((behaviorName) => { + const behavior = behaviorRegistry[behaviorName]; + const elements = container.querySelectorAll(`[${behaviorName}]`); + + elements.forEach((element) => { + element.__behaviors = element.__behaviors || []; + const hasBehavior = element.__behaviors.some( + (b) => b instanceof behavior, + ); + + if (hasBehavior) { + return; + } + + const behaviorInstance = new behavior(element); + element.__behaviors.push(behaviorInstance); + }); + }); +} + +export function swap(element, html) { + element.innerHTML = html; + applyBehaviors(element); +} diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js new file mode 100644 index 0000000..a7644ca --- /dev/null +++ b/bookmarks/frontend/behaviors/tag-autocomplete.js @@ -0,0 +1,26 @@ +import { registerBehavior } from "./index"; +import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; +import { ApiClient } from "../api"; + +class TagAutocomplete { + constructor(element) { + const wrapper = document.createElement("div"); + const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; + const apiClient = new ApiClient(apiBaseUrl); + + new TagAutoCompleteComponent({ + target: wrapper, + props: { + id: element.id, + name: element.name, + value: element.value, + apiClient: apiClient, + variant: element.getAttribute("variant"), + }, + }); + + element.replaceWith(wrapper); + } +} + +registerBehavior("ld-tag-autocomplete", TagAutocomplete); diff --git a/bookmarks/frontend/components/SearchAutoComplete.svelte b/bookmarks/frontend/components/SearchAutoComplete.svelte new file mode 100644 index 0000000..ee11238 --- /dev/null +++ b/bookmarks/frontend/components/SearchAutoComplete.svelte @@ -0,0 +1,272 @@ + + +
+
+ +
+ + +
+ + \ No newline at end of file diff --git a/bookmarks/frontend/components/SearchHistory.js b/bookmarks/frontend/components/SearchHistory.js new file mode 100644 index 0000000..fd6421a --- /dev/null +++ b/bookmarks/frontend/components/SearchHistory.js @@ -0,0 +1,52 @@ +const SEARCH_HISTORY_KEY = "searchHistory"; +const MAX_ENTRIES = 30; + +export class SearchHistory { + getHistory() { + const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY); + return historyJson + ? JSON.parse(historyJson) + : { + recent: [], + }; + } + + pushCurrent() { + // Skip if browser is not compatible + if (!window.URLSearchParams) return; + const urlParams = new URLSearchParams(window.location.search); + const searchParam = urlParams.get("q"); + + if (!searchParam) return; + + this.push(searchParam); + } + + push(search) { + const history = this.getHistory(); + + history.recent.unshift(search); + + // Remove duplicates and clamp to max entries + history.recent = history.recent.reduce((acc, cur) => { + if (acc.length >= MAX_ENTRIES) return acc; + if (acc.indexOf(cur) >= 0) return acc; + acc.push(cur); + return acc; + }, []); + + const newHistoryJson = JSON.stringify(history); + localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson); + } + + getRecentSearches(query, max) { + const history = this.getHistory(); + + return history.recent + .filter( + (search) => + !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0, + ) + .slice(0, max); + } +} diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte new file mode 100644 index 0000000..9c7588d --- /dev/null +++ b/bookmarks/frontend/components/TagAutocomplete.svelte @@ -0,0 +1,170 @@ + + +
+ +
+ + +
+ + + +
+ + diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js new file mode 100644 index 0000000..ec74571 --- /dev/null +++ b/bookmarks/frontend/index.js @@ -0,0 +1,14 @@ +import TagAutoComplete from "./components/TagAutocomplete.svelte"; +import SearchAutoComplete from "./components/SearchAutoComplete.svelte"; +import { ApiClient } from "./api"; +import "./behaviors/bookmark-page"; +import "./behaviors/bulk-edit"; +import "./behaviors/confirm-button"; +import "./behaviors/global-shortcuts"; +import "./behaviors/tag-autocomplete"; + +export default { + ApiClient, + TagAutoComplete, + SearchAutoComplete, +}; diff --git a/bookmarks/frontend/util.js b/bookmarks/frontend/util.js new file mode 100644 index 0000000..5c9a974 --- /dev/null +++ b/bookmarks/frontend/util.js @@ -0,0 +1,37 @@ +export function debounce(callback, delay = 250) { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + timeoutId = null; + callback(...args); + }, delay); + }; +} + +export function clampText(text, maxChars = 30) { + if (!text || text.length <= 30) return text; + + return text.substr(0, maxChars) + "..."; +} + +export function getCurrentWordBounds(input) { + const text = input.value; + const end = input.selectionStart; + let start = end; + + let currentChar = text.charAt(start - 1); + + while (currentChar && currentChar !== " " && start > 0) { + start--; + currentChar = text.charAt(start - 1); + } + + return { start, end }; +} + +export function getCurrentWord(input) { + const bounds = getCurrentWordBounds(input); + + return input.value.substring(bounds.start, bounds.end); +} diff --git a/bookmarks/static/bookmark_list.js b/bookmarks/static/bookmark_list.js deleted file mode 100644 index 9f3b84a..0000000 --- a/bookmarks/static/bookmark_list.js +++ /dev/null @@ -1,171 +0,0 @@ -(function () { - function allowBulkEdit() { - return !!document.getElementById('bulk-edit-mode'); - } - - function setupBulkEdit() { - if (!allowBulkEdit()) { - return; - } - - const bulkEditToggle = document.getElementById('bulk-edit-mode') - const bulkEditBar = document.querySelector('.bulk-edit-bar') - const singleToggles = document.querySelectorAll('.bulk-edit-toggle input') - const allToggle = document.querySelector('.bulk-edit-all-toggle input') - - function isAllSelected() { - let result = true - - singleToggles.forEach(function (toggle) { - result = result && toggle.checked - }) - - return result - } - - function selectAll() { - singleToggles.forEach(function (toggle) { - toggle.checked = true - }) - } - - function deselectAll() { - singleToggles.forEach(function (toggle) { - toggle.checked = false - }) - } - - // Toggle all - allToggle.addEventListener('change', function (e) { - if (e.target.checked) { - selectAll() - } else { - deselectAll() - } - }) - - // Toggle single - singleToggles.forEach(function (toggle) { - toggle.addEventListener('change', function () { - allToggle.checked = isAllSelected() - }) - }) - - // Allow overflow when bulk edit mode is active to be able to display tag auto complete menu - let bulkEditToggleTimeout - if (bulkEditToggle.checked) { - bulkEditBar.style.overflow = 'visible'; - } - bulkEditToggle.addEventListener('change', function (e) { - if (bulkEditToggleTimeout) { - clearTimeout(bulkEditToggleTimeout); - bulkEditToggleTimeout = null; - } - if (e.target.checked) { - bulkEditToggleTimeout = setTimeout(function () { - bulkEditBar.style.overflow = 'visible'; - }, 500); - } else { - bulkEditBar.style.overflow = 'hidden'; - } - }); - } - - function setupBulkEditTagAutoComplete() { - if (!allowBulkEdit()) { - return; - } - - const wrapper = document.createElement('div'); - const tagInput = document.getElementById('bulk-edit-tags-input'); - const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ''; - const apiClient = new linkding.ApiClient(apiBaseUrl) - - new linkding.TagAutoComplete({ - target: wrapper, - props: { - id: 'bulk-edit-tags-input', - name: tagInput.name, - value: tagInput.value, - apiClient: apiClient, - variant: 'small' - } - }); - - tagInput.parentElement.replaceChild(wrapper, tagInput); - } - - function setupListNavigation() { - // Add logic for navigating bookmarks with arrow keys - document.addEventListener('keydown', event => { - // Skip if event occurred within an input element - // or does not use arrow keys - const targetNodeName = event.target.nodeName; - const isInputTarget = targetNodeName === 'INPUT' - || targetNodeName === 'SELECT' - || targetNodeName === 'TEXTAREA'; - const isArrowUp = event.key === 'ArrowUp'; - const isArrowDown = event.key === 'ArrowDown'; - - if (isInputTarget || !(isArrowUp || isArrowDown)) { - return; - } - event.preventDefault(); - - // Detect current bookmark list item - const path = event.composedPath(); - const currentItem = path.find(item => item.hasAttribute && item.hasAttribute('data-is-bookmark-item')); - - // Find next item - let nextItem; - if (currentItem) { - nextItem = isArrowUp - ? currentItem.previousElementSibling - : currentItem.nextElementSibling; - } else { - // Select first item - nextItem = document.querySelector('li[data-is-bookmark-item]'); - } - // Focus first link - if (nextItem) { - nextItem.querySelector('a').focus(); - } - }); - } - - function setupNotes() { - // Shortcut for toggling all notes - document.addEventListener('keydown', function(event) { - // Filter for shortcut key - if (event.key !== 'e') return; - // Skip if event occurred within an input element - const targetNodeName = event.target.nodeName; - const isInputTarget = targetNodeName === 'INPUT' - || targetNodeName === 'SELECT' - || targetNodeName === 'TEXTAREA'; - - if (isInputTarget) return; - - const list = document.querySelector('.bookmark-list'); - list.classList.toggle('show-notes'); - }); - - // Toggle notes for single bookmark - const bookmarks = document.querySelectorAll('.bookmark-list li'); - bookmarks.forEach(bookmark => { - const toggleButton = bookmark.querySelector('.toggle-notes'); - if (toggleButton) { - toggleButton.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); - bookmark.classList.toggle('show-notes'); - }); - } - }); - } - - setupBulkEdit(); - setupBulkEditTagAutoComplete(); - setupListNavigation(); - setupNotes(); -})() diff --git a/bookmarks/static/shared.js b/bookmarks/static/shared.js deleted file mode 100644 index cc02f2d..0000000 --- a/bookmarks/static/shared.js +++ /dev/null @@ -1,83 +0,0 @@ -(function () { - - function initConfirmationButtons() { - const buttonEls = document.querySelectorAll('.btn-confirmation'); - - function showConfirmation(buttonEl) { - const cancelEl = document.createElement(buttonEl.nodeName); - cancelEl.innerText = 'Cancel'; - cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1'; - cancelEl.addEventListener('click', function () { - container.remove(); - buttonEl.style = ''; - }); - - const confirmEl = document.createElement(buttonEl.nodeName); - confirmEl.innerText = 'Confirm'; - confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action'; - - if (buttonEl.nodeName === 'BUTTON') { - confirmEl.type = buttonEl.type; - confirmEl.name = buttonEl.name; - confirmEl.value = buttonEl.value; - } - if (buttonEl.nodeName === 'A') { - confirmEl.href = buttonEl.href; - } - - const container = document.createElement('span'); - container.className = 'confirmation' - container.appendChild(cancelEl); - container.appendChild(confirmEl); - buttonEl.parentElement.insertBefore(container, buttonEl); - buttonEl.style = 'display: none'; - } - - buttonEls.forEach(function (linkEl) { - linkEl.addEventListener('click', function (e) { - e.preventDefault(); - showConfirmation(linkEl); - }); - }); - } - - function initGlobalShortcuts() { - // Focus search button - document.addEventListener('keydown', function (event) { - // Filter for shortcut key - if (event.key !== 's') return; - // Skip if event occurred within an input element - const targetNodeName = event.target.nodeName; - const isInputTarget = targetNodeName === 'INPUT' - || targetNodeName === 'SELECT' - || targetNodeName === 'TEXTAREA'; - - if (isInputTarget) return; - - const searchInput = document.querySelector('input[type="search"]'); - - if (searchInput) { - searchInput.focus(); - event.preventDefault(); - } - }); - - // Add new bookmark - document.addEventListener('keydown', function(event) { - // Filter for new entry shortcut key - if (event.key !== 'n') return; - // Skip if event occurred within an input element - const targetNodeName = event.target.nodeName; - const isInputTarget = targetNodeName === 'INPUT' - || targetNodeName === 'SELECT' - || targetNodeName === 'TEXTAREA'; - - if (isInputTarget) return; - - window.location.assign("/bookmarks/new"); - }); - } - - initConfirmationButtons(); - initGlobalShortcuts(); -})() \ No newline at end of file diff --git a/bookmarks/styles/bookmarks.scss b/bookmarks/styles/bookmarks.scss index 91c7731..1ed4925 100644 --- a/bookmarks/styles/bookmarks.scss +++ b/bookmarks/styles/bookmarks.scss @@ -1,3 +1,4 @@ +/* Bookmark search box */ .bookmarks-page .search { $searchbox-width: 180px; $searchbox-width-md: 300px; @@ -37,12 +38,6 @@ } } -.bookmarks-page .content-area-header { - span.btn { - margin-left: 8px; - } -} - /* Bookmark list */ ul.bookmark-list { list-style: none; @@ -51,9 +46,10 @@ ul.bookmark-list { } /* Bookmarks */ -ul.bookmark-list li { +li[ld-bookmark-item] { + position: relative; - .bulk-edit-toggle { + [ld-bulk-edit-checkbox].form-checkbox { display: none; } @@ -88,14 +84,11 @@ ul.bookmark-list li { display: flex; align-items: baseline; flex-wrap: wrap; + gap: 0.4rem; } .actions { - > *:not(:last-child) { - margin-right: 0.4rem; - } - - a, button { + a, button.btn-link { color: $gray-color; padding: 0; height: auto; @@ -235,6 +228,7 @@ ul.bookmark-list .notes-content { > *:first-child { margin-top: 0; } + > *:last-child { margin-bottom: 0; } @@ -266,14 +260,13 @@ ul.bookmark-list .notes-content { } } -/* Bookmark actions / bulk edit */ +/* Bookmark bulk edit */ $bulk-edit-toggle-width: 16px; $bulk-edit-toggle-offset: 8px; $bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset); $bulk-edit-transition-duration: 400ms; -.bookmarks-page form.bookmark-actions { - +[ld-bulk-edit] { .bulk-edit-bar { margin-top: -17px; margin-bottom: 16px; @@ -283,56 +276,27 @@ $bulk-edit-transition-duration: 400ms; transition: max-height $bulk-edit-transition-duration; } - .bulk-edit-actions { - display: flex; - align-items: baseline; - padding: 4px 0; - border-top: solid 1px $border-color; - - button:hover { - text-decoration: underline; - } - - > label.form-checkbox { - min-height: 1rem; - } - - > button { - padding: 0; - margin-left: 8px; - } - - > span { - margin-left: 8px; - } - - > input, .form-autocomplete { - width: auto; - margin-left: 4px; - max-width: 200px; - -webkit-appearance: none; - } - - span.confirmation { - display: flex; - } - - span.confirmation button { - padding: 0; - } + &.active .bulk-edit-bar { + max-height: 37px; + border-bottom: solid 1px $border-color; } - .bulk-edit-all-toggle { + /* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */ + &.active:not(.activating) .bulk-edit-bar { + overflow: visible; + } + + /* All checkbox */ + [ld-bulk-edit-checkbox][all].form-checkbox { + display: block; width: $bulk-edit-toggle-width; margin: 0 0 0 $bulk-edit-toggle-offset; padding: 0; + min-height: 1rem; } - ul.bookmark-list li { - position: relative; - } - - ul.bookmark-list li .bulk-edit-toggle { + /* Bookmark checkboxes */ + li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox { display: block; position: absolute; width: $bulk-edit-toggle-width; @@ -344,22 +308,36 @@ $bulk-edit-transition-duration: 400ms; opacity: 0; transition: all $bulk-edit-transition-duration; - i { + .form-icon { top: 0.2rem; } } -} -#bulk-edit-mode { - display: none; -} + &.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox { + visibility: visible; + opacity: 1; + } -#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle { - visibility: visible; - opacity: 1; -} + /* Actions */ + .bulk-edit-actions { + display: flex; + align-items: baseline; + padding: 4px 0; + border-top: solid 1px $border-color; + gap: 8px; -#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar { - max-height: 37px; - border-bottom: solid 1px $border-color; + button { + padding: 0 !important; + } + + button:hover { + text-decoration: underline; + } + + > input, .form-autocomplete { + width: auto; + max-width: 200px; + -webkit-appearance: none; + } + } } diff --git a/bookmarks/styles/shared.scss b/bookmarks/styles/shared.scss index f6c2b74..11c0c69 100644 --- a/bookmarks/styles/shared.scss +++ b/bookmarks/styles/shared.scss @@ -14,9 +14,15 @@ section.content-area { } // Confirm button component -.btn-confirmation-action { +span.confirmation { + display: flex; + align-items: baseline; +} + +span.confirmation .btn.btn-link { color: $error-color !important; + &:hover { text-decoration: underline; } -} \ No newline at end of file +} diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html index 407089b..e060a86 100644 --- a/bookmarks/templates/bookmarks/archive.html +++ b/bookmarks/templates/bookmarks/archive.html @@ -4,43 +4,42 @@ {% load bookmarks %} {% block content %} - - {% include 'bookmarks/bulk_edit/state.html' %} - -
+
{# Bookmark list #}

Archived bookmarks

- {% bookmark_search filters tags mode='archived' %} + {% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
-
{% csrf_token %} {% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %} - {% if empty %} - {% include 'bookmarks/empty_bookmarks.html' %} - {% else %} - {% bookmark_list bookmarks return_url link_target %} - {% endif %} +
+ {% include 'bookmarks/bookmark_list.html' %} +
- {# Tag list #} + {# Tag cloud #}

Tags

- {% tag_cloud tags selected_tags %} +
+ {% include 'bookmarks/tag_cloud.html' %} +
- - {% endblock %} diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index 51733ae..d991fc4 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -1,128 +1,136 @@ {% load static %} {% load shared %} {% load pagination %} - -
- {% pagination bookmarks %} -
+
+ {% pagination bookmark_list.bookmarks_page %} +
+{% endif %} diff --git a/bookmarks/templates/bookmarks/bulk_edit/bar.html b/bookmarks/templates/bookmarks/bulk_edit/bar.html index 933c30e..4826c8b 100644 --- a/bookmarks/templates/bookmarks/bulk_edit/bar.html +++ b/bookmarks/templates/bookmarks/bulk_edit/bar.html @@ -1,34 +1,34 @@ {% load shared %} {% htmlmin %} -
-
- - {% if mode == 'archive' %} - + {% else %} + + {% endif %} + • + - {% else %} - - {% endif %} - • - - • - - - - + +
-
{% endhtmlmin %} diff --git a/bookmarks/templates/bookmarks/bulk_edit/state.html b/bookmarks/templates/bookmarks/bulk_edit/state.html deleted file mode 100644 index f846a50..0000000 --- a/bookmarks/templates/bookmarks/bulk_edit/state.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/bookmarks/templates/bookmarks/bulk_edit/toggle.html b/bookmarks/templates/bookmarks/bulk_edit/toggle.html index af3b391..13cbaed 100644 --- a/bookmarks/templates/bookmarks/bulk_edit/toggle.html +++ b/bookmarks/templates/bookmarks/bulk_edit/toggle.html @@ -1,9 +1,7 @@ - + diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html index 2eef25b..39530df 100644 --- a/bookmarks/templates/bookmarks/form.html +++ b/bookmarks/templates/bookmarks/form.html @@ -21,7 +21,7 @@
- {{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} + {{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
Enter any number of tags separated by space and without the hash (#). If a tag does not exist it will be @@ -118,23 +118,6 @@ {# Replace tag input with auto-complete component #} - - - {% endblock %} diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html index 30ed02c..a020b75 100644 --- a/bookmarks/templates/bookmarks/layout.html +++ b/bookmarks/templates/bookmarks/layout.html @@ -29,7 +29,7 @@ media="(prefers-color-scheme: light)"/> {% endif %} - +
{% if has_toasts %}
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html index 268493d..cc271e3 100644 --- a/bookmarks/templates/bookmarks/shared.html +++ b/bookmarks/templates/bookmarks/shared.html @@ -4,26 +4,26 @@ {% load bookmarks %} {% block content %} - -
+
{# Bookmark list #}

Shared bookmarks

- {% bookmark_search filters tags mode='shared' %} + {% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
-
{% csrf_token %} - {% if empty %} - {% include 'bookmarks/empty_bookmarks.html' %} - {% else %} - {% bookmark_list bookmarks return_url link_target %} - {% endif %} +
+ {% include 'bookmarks/bookmark_list.html' %} +
@@ -39,11 +39,11 @@

Tags

- {% tag_cloud tags selected_tags %} +
+ {% include 'bookmarks/tag_cloud.html' %} +
- - {% endblock %} diff --git a/bookmarks/templates/bookmarks/tag_cloud.html b/bookmarks/templates/bookmarks/tag_cloud.html index 02b44a8..52a8ad2 100644 --- a/bookmarks/templates/bookmarks/tag_cloud.html +++ b/bookmarks/templates/bookmarks/tag_cloud.html @@ -1,9 +1,9 @@ {% load shared %} {% htmlmin %}
- {% if has_selected_tags %} + {% if tag_cloud.has_selected_tags %}

- {% for tag in selected_tags %} + {% for tag in tag_cloud.selected_tags %} -{{ tag.name }} @@ -12,7 +12,7 @@

{% endif %}
- {% for group in groups %} + {% for group in tag_cloud.groups %}

{% for tag in group.tags %} {# Highlight first char of first tag in group #} diff --git a/bookmarks/templatetags/bookmarks.py b/bookmarks/templatetags/bookmarks.py index 793c520..fd4754e 100644 --- a/bookmarks/templatetags/bookmarks.py +++ b/bookmarks/templatetags/bookmarks.py @@ -1,10 +1,8 @@ -from typing import List, Set +from typing import List from django import template -from django.core.paginator import Page from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User -from bookmarks.utils import unique register = template.Library() @@ -20,60 +18,6 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int } -class TagGroup: - def __init__(self, char): - self.tags = [] - self.char = char - - -def create_tag_groups(tags: Set[Tag]): - # Ensure groups, as well as tags within groups, are ordered alphabetically - sorted_tags = sorted(tags, key=lambda x: str.lower(x.name)) - group = None - groups = [] - - # Group tags that start with a different character than the previous one - for tag in sorted_tags: - tag_char = tag.name[0].lower() - - if not group or group.char != tag_char: - group = TagGroup(tag_char) - groups.append(group) - - group.tags.append(tag) - - return groups - - -@register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True) -def tag_cloud(context, tags: List[Tag], selected_tags: List[Tag]): - # Only display each tag name once, ignoring casing - # This covers cases where the tag cloud contains shared tags with duplicate names - # Also means that the cloud can not make assumptions that it will necessarily contain - # all tags of the current user - unique_tags = unique(tags, key=lambda x: str.lower(x.name)) - unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name)) - - has_selected_tags = len(unique_selected_tags) > 0 - unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags) - groups = create_tag_groups(unselected_tags) - return { - 'groups': groups, - 'selected_tags': unique_selected_tags, - 'has_selected_tags': has_selected_tags, - } - - -@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True) -def bookmark_list(context, bookmarks: Page, return_url: str, link_target: str = '_blank'): - return { - 'request': context['request'], - 'bookmarks': bookmarks, - 'return_url': return_url, - 'link_target': link_target, - } - - @register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True) def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''): tag_names = [tag.name for tag in tags] diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index e72f2fd..eb8a6b6 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -71,6 +71,44 @@ class BookmarkFactoryMixin: bookmark.save() return bookmark + def setup_numbered_bookmarks(self, + count: int, + prefix: str = '', + suffix: str = '', + tag_prefix: str = '', + archived: bool = False, + shared: bool = False, + with_tags: bool = False, + user: User = None): + user = user or self.get_or_create_test_user() + + if not prefix: + if archived: + prefix = 'Archived Bookmark' + elif shared: + prefix = 'Shared Bookmark' + else: + prefix = 'Bookmark' + + if not tag_prefix: + if archived: + tag_prefix = 'Archived Tag' + elif shared: + tag_prefix = 'Shared Tag' + else: + tag_prefix = 'Tag' + + for i in range(1, count + 1): + title = f'{prefix} {i}{suffix}' + tags = [] + if with_tags: + tag_name = f'{tag_prefix} {i}{suffix}' + tags = [self.setup_tag(name=tag_name)] + self.setup_bookmark(title=title, is_archived=archived, shared=shared, tags=tags, user=user) + + def get_numbered_bookmark(self, title: str): + return Bookmark.objects.get(title=title) + def setup_tag(self, user: User = None, name: str = ''): if user is None: user = self.get_or_create_test_user() diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py index 98a2fef..b293c2d 100644 --- a/bookmarks/tests/test_bookmark_archived_view.py +++ b/bookmarks/tests/test_bookmark_archived_view.py @@ -16,7 +16,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): html = response.content.decode() - self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) + self.assertContains(response, '

  • ', count=len(bookmarks)) for bookmark in bookmarks: self.assertInHTML( diff --git a/bookmarks/tests/test_bookmark_archived_view_performance.py b/bookmarks/tests/test_bookmark_archived_view_performance.py index 72159ac..3336e9f 100644 --- a/bookmarks/tests/test_bookmark_archived_view_performance.py +++ b/bookmarks/tests/test_bookmark_archived_view_performance.py @@ -27,7 +27,7 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto context = CaptureQueriesContext(self.get_connection()) with context: response = self.client.get(reverse('bookmarks:archived')) - self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) + self.assertContains(response, '
  • ', num_initial_bookmarks) number_of_queries = context.final_queries @@ -39,4 +39,4 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto # assert num queries doesn't increase with self.assertNumQueries(number_of_queries): response = self.client.get(reverse('bookmarks:archived')) - self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) + self.assertContains(response, '
  • ', num_initial_bookmarks + num_additional_bookmarks) diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py index 93b4c20..99b86e7 100644 --- a/bookmarks/tests/test_bookmark_edit_view.py +++ b/bookmarks/tests/test_bookmark_edit_view.py @@ -89,7 +89,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin): 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 b96970a..c92c5a1 100644 --- a/bookmarks/tests/test_bookmark_index_view.py +++ b/bookmarks/tests/test_bookmark_index_view.py @@ -17,7 +17,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): html = response.content.decode() - self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) + self.assertContains(response, '
  • ', count=len(bookmarks)) for bookmark in bookmarks: self.assertInHTML( diff --git a/bookmarks/tests/test_bookmark_index_view_performance.py b/bookmarks/tests/test_bookmark_index_view_performance.py index f13f532..122b00f 100644 --- a/bookmarks/tests/test_bookmark_index_view_performance.py +++ b/bookmarks/tests/test_bookmark_index_view_performance.py @@ -27,7 +27,7 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM context = CaptureQueriesContext(self.get_connection()) with context: response = self.client.get(reverse('bookmarks:index')) - self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) + self.assertContains(response, '
  • ', num_initial_bookmarks) number_of_queries = context.final_queries @@ -39,4 +39,4 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM # assert num queries doesn't increase with self.assertNumQueries(number_of_queries): response = self.client.get(reverse('bookmarks:index')) - self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) + self.assertContains(response, '
  • ', num_initial_bookmarks + num_additional_bookmarks) diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py index b94156a..eb393e5 100644 --- a/bookmarks/tests/test_bookmark_shared_view.py +++ b/bookmarks/tests/test_bookmark_shared_view.py @@ -22,7 +22,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): html = response.content.decode() - self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) + self.assertContains(response, '
  • ', count=len(bookmarks)) for bookmark in bookmarks: self.assertBookmarkCount(html, bookmark, 1, link_target) diff --git a/bookmarks/tests/test_bookmark_shared_view_performance.py b/bookmarks/tests/test_bookmark_shared_view_performance.py index f9aaceb..588442c 100644 --- a/bookmarks/tests/test_bookmark_shared_view_performance.py +++ b/bookmarks/tests/test_bookmark_shared_view_performance.py @@ -28,7 +28,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory context = CaptureQueriesContext(self.get_connection()) with context: response = self.client.get(reverse('bookmarks:shared')) - self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) + self.assertContains(response, '
  • ', num_initial_bookmarks) number_of_queries = context.final_queries @@ -41,4 +41,4 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory # assert num queries doesn't increase with self.assertNumQueries(number_of_queries): response = self.client.get(reverse('bookmarks:shared')) - self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) + self.assertContains(response, '
  • ', num_initial_bookmarks + num_additional_bookmarks) diff --git a/bookmarks/tests/test_bookmarks_list_tag.py b/bookmarks/tests/test_bookmarks_list_template.py similarity index 75% rename from bookmarks/tests/test_bookmarks_list_tag.py rename to bookmarks/tests/test_bookmarks_list_template.py index e213e0c..39b2f8f 100644 --- a/bookmarks/tests/test_bookmarks_list_tag.py +++ b/bookmarks/tests/test_bookmarks_list_template.py @@ -1,18 +1,20 @@ +from typing import Type + from dateutil.relativedelta import relativedelta from django.contrib.auth.models import AnonymousUser -from django.core.paginator import Paginator from django.http import HttpResponse from django.template import Template, RequestContext from django.test import TestCase, RequestFactory from django.urls import reverse from django.utils import timezone, formats +from bookmarks.middlewares import UserProfileMiddleware from bookmarks.models import Bookmark, UserProfile, User from bookmarks.tests.helpers import BookmarkFactoryMixin -from bookmarks.middlewares import UserProfileMiddleware +from bookmarks.views.partials import contexts -class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): +class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin): def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'): unread = bookmark.unread @@ -60,7 +62,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): # Edit link edit_url = reverse('bookmarks:edit', args=[bookmark.id]) self.assertInHTML(f''' - Edit + Edit ''', html, count=count) # Archive link self.assertInHTML(f''' @@ -69,8 +71,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): ''', html, count=count) # Delete link self.assertInHTML(f''' - + ''', html, count=count) def assertShareInfo(self, html: str, bookmark: Bookmark): @@ -138,36 +140,24 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): ''', html, count=count) - def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test', + def render_template(self, + url='/bookmarks', + context_type: Type[contexts.BookmarkListContext] = contexts.ActiveBookmarkListContext, user: User | AnonymousUser = None) -> str: rf = RequestFactory() request = rf.get(url) request.user = user or self.get_or_create_test_user() middleware = UserProfileMiddleware(lambda r: HttpResponse()) middleware(request) - paginator = Paginator(bookmarks, 10) - page = paginator.page(1) - context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'}) + bookmark_list_context = context_type(request) + context = RequestContext(request, {'bookmark_list': bookmark_list_context}) + + template = Template( + "{% include 'bookmarks/bookmark_list.html' %}" + ) return template.render(context) - def render_default_template(self, bookmarks: [Bookmark], url: str = '/test', - user: User | AnonymousUser = None) -> str: - template = Template( - '{% load bookmarks %}' - '{% bookmark_list bookmarks return_url %}' - ) - return self.render_template(bookmarks, template, url, user) - - def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str: - template = Template( - f''' - {{% load bookmarks %}} - {{% bookmark_list bookmarks return_url '{link_target}' %}} - ''' - ) - return self.render_template(bookmarks, template) - def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''): bookmark = self.setup_bookmark() bookmark.date_added = timezone.now() - relativedelta(days=8) @@ -180,7 +170,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): def test_should_respect_absolute_date_setting(self): bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE) - html = self.render_default_template([bookmark]) + html = self.render_template() formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT') self.assertDateLabel(html, formatted_date) @@ -188,45 +178,38 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): def test_should_render_web_archive_link_with_absolute_date_setting(self): bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'https://web.archive.org/web/20210811214511/https://wanikani.com/') - html = self.render_default_template([bookmark]) + html = self.render_template() formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT') self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url) def test_should_respect_relative_date_setting(self): - bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE) - html = self.render_default_template([bookmark]) + self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE) + html = self.render_template() self.assertDateLabel(html, '1 week ago') def test_should_render_web_archive_link_with_relative_date_setting(self): bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE, 'https://web.archive.org/web/20210811214511/https://wanikani.com/') - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url) def test_bookmark_link_target_should_be_blank_by_default(self): bookmark = self.setup_bookmark() - - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertBookmarksLink(html, bookmark, link_target='_blank') - def test_bookmark_link_target_should_respect_link_target_parameter(self): + def test_bookmark_link_target_should_respect_user_profile(self): + profile = self.get_or_create_test_user().profile + profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF + profile.save() + bookmark = self.setup_bookmark() + html = self.render_template() - html = self.render_template_with_link_target([bookmark], '_self') - - self.assertBookmarksLink(html, bookmark, link_target='_self') - - def test_bookmark_link_target_should_respect_unread_flag(self): - bookmark = self.setup_bookmark() - html = self.render_template_with_link_target([bookmark], '_self') - self.assertBookmarksLink(html, bookmark, link_target='_self') - - bookmark = self.setup_bookmark(unread=True) - html = self.render_template_with_link_target([bookmark], '_self') self.assertBookmarksLink(html, bookmark, link_target='_self') def test_web_archive_link_target_should_be_blank_by_default(self): @@ -235,39 +218,55 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): bookmark.web_archive_snapshot_url = 'https://example.com' bookmark.save() - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank') - def test_web_archive_link_target_respect_link_target_parameter(self): + def test_web_archive_link_target_should_respect_user_profile(self): + profile = self.get_or_create_test_user().profile + profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF + profile.save() + bookmark = self.setup_bookmark() bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.web_archive_snapshot_url = 'https://example.com' bookmark.save() - html = self.render_template_with_link_target([bookmark], '_self') + html = self.render_template() self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self') + def test_should_respect_unread_flag(self): + bookmark = self.setup_bookmark(unread=True) + html = self.render_template() + + self.assertBookmarksLink(html, bookmark) + def test_show_bookmark_actions_for_owned_bookmarks(self): bookmark = self.setup_bookmark() - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertBookmarkActions(html, bookmark) self.assertNoShareInfo(html, bookmark) def test_show_share_info_for_non_owned_bookmarks(self): other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') - bookmark = self.setup_bookmark(user=other_user) - html = self.render_default_template([bookmark]) + other_user.profile.enable_sharing = True + other_user.profile.save() + + bookmark = self.setup_bookmark(user=other_user, shared=True) + html = self.render_template(context_type=contexts.SharedBookmarkListContext) self.assertNoBookmarkActions(html, bookmark) self.assertShareInfo(html, bookmark) def test_share_info_user_link_keeps_query_params(self): other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') - bookmark = self.setup_bookmark(user=other_user) - html = self.render_default_template([bookmark], url='/test?q=foo') + other_user.profile.enable_sharing = True + other_user.profile.save() + + bookmark = self.setup_bookmark(user=other_user, shared=True, title='foo') + html = self.render_template(url='/bookmarks?q=foo', context_type=contexts.SharedBookmarkListContext) self.assertInHTML(f''' Shared by @@ -281,7 +280,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.save() bookmark = self.setup_bookmark(favicon_file='https_example_com.png') - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertFaviconVisible(html, bookmark) @@ -291,7 +290,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.save() bookmark = self.setup_bookmark(favicon_file='') - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertFaviconHidden(html, bookmark) @@ -301,7 +300,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.save() bookmark = self.setup_bookmark(favicon_file='https_example_com.png') - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertFaviconHidden(html, bookmark) @@ -310,7 +309,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.save() bookmark = self.setup_bookmark() - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertBookmarkURLHidden(html, bookmark) @@ -320,7 +319,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.save() bookmark = self.setup_bookmark() - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertBookmarkURLVisible(html, bookmark) @@ -330,68 +329,67 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.save() bookmark = self.setup_bookmark() - html = self.render_default_template([bookmark]) + html = self.render_template() self.assertBookmarkURLHidden(html, bookmark) def test_without_notes(self): - bookmark = self.setup_bookmark() - html = self.render_default_template([bookmark]) + self.setup_bookmark() + html = self.render_template() self.assertNotes(html, '', 0) self.assertNotesToggle(html, 0) def test_with_notes(self): - bookmark = self.setup_bookmark(notes='Test note') - html = self.render_default_template([bookmark]) + self.setup_bookmark(notes='Test note') + html = self.render_template() note_html = '

    Test note

    ' self.assertNotes(html, note_html, 1) def test_note_renders_markdown(self): - bookmark = self.setup_bookmark(notes='**Example:** `print("Hello world!")`') - html = self.render_default_template([bookmark]) + self.setup_bookmark(notes='**Example:** `print("Hello world!")`') + html = self.render_template() note_html = '

    Example: print("Hello world!")

    ' self.assertNotes(html, note_html, 1) def test_note_cleans_html(self): - bookmark = self.setup_bookmark(notes='') - html = self.render_default_template([bookmark]) + self.setup_bookmark(notes='') + html = self.render_template() note_html = '<script>alert("test")</script>' self.assertNotes(html, note_html, 1) def test_notes_are_hidden_initially_by_default(self): - html = self.render_default_template([]) + self.setup_bookmark(notes='Test note') + html = self.render_template() - self.assertInHTML(""" -
      - """, html) + self.assertIn('
        ', html) def test_notes_are_hidden_initially_with_permanent_notes_disabled(self): profile = self.get_or_create_test_user().profile profile.permanent_notes = False profile.save() - html = self.render_default_template([]) - self.assertInHTML(""" -
          - """, html) + self.setup_bookmark(notes='Test note') + html = self.render_template() + + self.assertIn('
            ', html) def test_notes_are_visible_initially_with_permanent_notes_enabled(self): profile = self.get_or_create_test_user().profile profile.permanent_notes = True profile.save() - html = self.render_default_template([]) - self.assertInHTML(""" -
              - """, html) + self.setup_bookmark(notes='Test note') + html = self.render_template() + + self.assertIn('
                ', html) def test_toggle_notes_is_visible_by_default(self): - bookmark = self.setup_bookmark(notes='Test note') - html = self.render_default_template([bookmark]) + self.setup_bookmark(notes='Test note') + html = self.render_template() self.assertNotesToggle(html, 1) @@ -400,8 +398,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.permanent_notes = False profile.save() - bookmark = self.setup_bookmark(notes='Test note') - html = self.render_default_template([bookmark]) + self.setup_bookmark(notes='Test note') + html = self.render_template() self.assertNotesToggle(html, 1) @@ -410,20 +408,26 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): profile.permanent_notes = True profile.save() - bookmark = self.setup_bookmark(notes='Test note') - html = self.render_default_template([bookmark]) + self.setup_bookmark(notes='Test note') + html = self.render_template() self.assertNotesToggle(html, 0) def test_with_anonymous_user(self): + profile = self.get_or_create_test_user().profile + profile.enable_sharing = True + profile.enable_public_sharing = True + profile.save() + bookmark = self.setup_bookmark() bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com' bookmark.notes = '**Example:** `print("Hello world!")`' bookmark.favicon_file = 'https_example_com.png' + bookmark.shared = True bookmark.save() - html = self.render_default_template([bookmark], '/test', AnonymousUser()) + html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser()) self.assertBookmarksLink(html, bookmark, link_target='_blank') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank') self.assertNoBookmarkActions(html, bookmark) @@ -431,3 +435,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): note_html = '

                Example: print("Hello world!")

                ' self.assertNotes(html, note_html, 1) self.assertFaviconVisible(html, bookmark) + + def test_empty_state(self): + html = self.render_template() + + self.assertInHTML('

                You have no bookmarks yet

                ', html) diff --git a/bookmarks/tests/test_tag_cloud_tag.py b/bookmarks/tests/test_tag_cloud_template.py similarity index 69% rename from bookmarks/tests/test_tag_cloud_tag.py rename to bookmarks/tests/test_tag_cloud_template.py index 92a0608..8da5a01 100644 --- a/bookmarks/tests/test_tag_cloud_tag.py +++ b/bookmarks/tests/test_tag_cloud_template.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Type from django.contrib.auth.models import User, AnonymousUser from django.http import HttpResponse @@ -6,29 +6,26 @@ from django.template import Template, RequestContext from django.test import TestCase, RequestFactory from bookmarks.middlewares import UserProfileMiddleware -from bookmarks.models import Tag, UserProfile +from bookmarks.models import UserProfile from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin +from bookmarks.views.partials import contexts -class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): - def render_template(self, tags: List[Tag], selected_tags: List[Tag] = None, url: str = '/test', +class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): + def render_template(self, + context_type: Type[contexts.TagCloudContext] = contexts.ActiveTagCloudContext, + url: str = '/test', user: User | AnonymousUser = None): - if not selected_tags: - selected_tags = [] - rf = RequestFactory() request = rf.get(url) request.user = user or self.get_or_create_test_user() middleware = UserProfileMiddleware(lambda r: HttpResponse()) middleware(request) - context = RequestContext(request, { - 'request': request, - 'tags': tags, - 'selected_tags': selected_tags, - }) + + tag_cloud_context = context_type(request) + context = RequestContext(request, {'tag_cloud': tag_cloud_context}) template_to_render = Template( - '{% load bookmarks %}' - '{% tag_cloud tags selected_tags %}' + "{% include 'bookmarks/tag_cloud.html' %}" ) return template_to_render.render(context) @@ -54,7 +51,7 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.assertEqual(len(link_elements), count) def test_group_alphabetically(self): - tags = [ + tags = ([ self.setup_tag(name='Cockatoo'), self.setup_tag(name='Badger'), self.setup_tag(name='Buffalo'), @@ -64,9 +61,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.setup_tag(name='Aardvark'), self.setup_tag(name='Bumblebee'), self.setup_tag(name='Armadillo'), - ] + ]) + self.setup_bookmark(tags=tags) - rendered_template = self.render_template(tags) + rendered_template = self.render_template() self.assertTagGroups(rendered_template, [ [ @@ -88,12 +86,14 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): def test_no_duplicate_tag_names(self): tags = [ - self.setup_tag(name='shared', user=self.setup_user()), - self.setup_tag(name='shared', user=self.setup_user()), - self.setup_tag(name='shared', user=self.setup_user()), + self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)), + self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)), + self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)), ] + for tag in tags: + self.setup_bookmark(tags=[tag], user=tag.owner, shared=True) - rendered_template = self.render_template(tags) + rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext) self.assertTagGroups(rendered_template, [ [ @@ -106,8 +106,9 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.setup_tag(name='tag1'), self.setup_tag(name='tag2'), ] + self.setup_bookmark(tags=tags) - rendered_template = self.render_template(tags, tags, url='/test?q=%23tag1 %23tag2') + rendered_template = self.render_template(url='/test?q=%23tag1 %23tag2') self.assertNumSelectedTags(rendered_template, 2) @@ -134,9 +135,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.setup_tag(name='tag1'), self.setup_tag(name='tag2'), ] + self.setup_bookmark(tags=tags) # Filter by tag name without hash - rendered_template = self.render_template(tags, tags, url='/test?q=tag1 %23tag2') + rendered_template = self.render_template(url='/test?q=tag1 %23tag2') self.assertNumSelectedTags(rendered_template, 2) @@ -159,8 +161,9 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): tags = [ self.setup_tag(name='TEST'), ] + self.setup_bookmark(tags=tags) - rendered_template = self.render_template(tags, tags, url='/test?q=%23test') + rendered_template = self.render_template(url='/test?q=%23test') self.assertInHTML(''' -tag1 @@ -205,18 +212,20 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.setup_tag(name='tag4'), self.setup_tag(name='tag5'), ] - selected_tags = [ - tags[0], - tags[1], - ] + self.setup_bookmark(tags=tags) - rendered_template = self.render_template(tags, selected_tags) + rendered_template = self.render_template(url='/test?q=%23tag1 %23tag2') self.assertTagGroups(rendered_template, [ ['tag3', 'tag4', 'tag5'] ]) def test_with_anonymous_user(self): + profile = self.get_or_create_test_user().profile + profile.enable_sharing = True + profile.enable_public_sharing = True + profile.save() + tags = [ self.setup_tag(name='tag1'), self.setup_tag(name='tag2'), @@ -224,12 +233,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.setup_tag(name='tag4'), self.setup_tag(name='tag5'), ] - selected_tags = [ - tags[0], - tags[1], - ] + self.setup_bookmark(tags=tags, shared=True) - rendered_template = self.render_template(tags, selected_tags, url='/test?q=%23tag1 %23tag2', + rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext, + url='/test?q=%23tag1 %23tag2', user=AnonymousUser()) self.assertTagGroups(rendered_template, [ diff --git a/bookmarks/urls.py b/bookmarks/urls.py index 834a2f4..03f6f53 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -1,10 +1,11 @@ -from django.urls import re_path from django.urls import path, include +from django.urls import re_path from django.views.generic import RedirectView -from bookmarks.api.routes import router from bookmarks import views +from bookmarks.api.routes import router from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed +from bookmarks.views import partials app_name = 'bookmarks' urlpatterns = [ @@ -18,6 +19,16 @@ urlpatterns = [ path('bookmarks/close', views.bookmarks.close, name='close'), path('bookmarks//edit', views.bookmarks.edit, name='edit'), path('bookmarks/action', views.bookmarks.action, name='action'), + # 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/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/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'), # Settings path('settings', views.settings.general, name='settings.index'), path('settings/general', views.settings.general, name='settings.general'), diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py index 7e2a7e2..64aa152 100644 --- a/bookmarks/views/bookmarks.py +++ b/bookmarks/views/bookmarks.py @@ -1,108 +1,49 @@ -import urllib.parse -from typing import List - from django.contrib.auth.decorators import login_required -from django.core.handlers.wsgi import WSGIRequest -from django.core.paginator import Paginator -from django.db.models import QuerySet, Q, prefetch_related_objects from django.http import HttpResponseRedirect, Http404 from django.shortcuts import render from django.urls import reverse from bookmarks import queries -from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, UserProfile, Tag, build_tag_string +from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks from bookmarks.utils import get_safe_return_url +from bookmarks.views.partials import contexts _default_page_size = 30 @login_required def index(request): - filters = BookmarkFilters(request) - query_set = queries.query_bookmarks(request.user, request.user_profile, filters.query) - tags = queries.query_bookmark_tags(request.user, request.user_profile, filters.query) - base_url = reverse('bookmarks:index') - context = get_bookmark_view_context(request, filters, query_set, tags, base_url) - return render(request, 'bookmarks/index.html', context) + bookmark_list = contexts.ActiveBookmarkListContext(request) + tag_cloud = contexts.ActiveTagCloudContext(request) + return render(request, 'bookmarks/index.html', { + 'bookmark_list': bookmark_list, + 'tag_cloud': tag_cloud, + }) @login_required def archived(request): - filters = BookmarkFilters(request) - query_set = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query) - tags = queries.query_archived_bookmark_tags(request.user, request.user_profile, filters.query) - base_url = reverse('bookmarks:archived') - context = get_bookmark_view_context(request, filters, query_set, tags, base_url) - return render(request, 'bookmarks/archive.html', context) + bookmark_list = contexts.ArchivedBookmarkListContext(request) + tag_cloud = contexts.ArchivedTagCloudContext(request) + return render(request, 'bookmarks/archive.html', { + 'bookmark_list': bookmark_list, + 'tag_cloud': tag_cloud, + }) def shared(request): filters = BookmarkFilters(request) - user = User.objects.filter(username=filters.user).first() + bookmark_list = contexts.SharedBookmarkListContext(request) + tag_cloud = contexts.SharedTagCloudContext(request) public_only = not request.user.is_authenticated - query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only) - tags = queries.query_shared_bookmark_tags(user, request.user_profile, filters.query, public_only) users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only) - base_url = reverse('bookmarks:shared') - context = get_bookmark_view_context(request, filters, query_set, tags, base_url) - context['users'] = users - return render(request, 'bookmarks/shared.html', context) - - -def _get_selected_tags(tags: List[Tag], query_string: str, profile: UserProfile): - parsed_query = queries.parse_query_string(query_string) - tag_names = parsed_query['tag_names'] - if profile.tag_search == UserProfile.TAG_SEARCH_LAX: - tag_names = tag_names + parsed_query['search_terms'] - tag_names = [tag_name.lower() for tag_name in tag_names] - - return [tag for tag in tags if tag.name.lower() in tag_names] - - -def get_bookmark_view_context(request: WSGIRequest, - filters: BookmarkFilters, - query_set: QuerySet[Bookmark], - tags: QuerySet[Tag], - base_url: str): - page = request.GET.get('page') - paginator = Paginator(query_set, _default_page_size) - bookmarks = paginator.get_page(page) - tags = list(tags) - selected_tags = _get_selected_tags(tags, filters.query, request.user_profile) - # Prefetch related objects, this avoids n+1 queries when accessing fields in templates - prefetch_related_objects(bookmarks.object_list, 'owner', 'tags') - return_url = generate_return_url(base_url, page, filters) - link_target = request.user_profile.bookmark_link_target - - if request.GET.get('tag'): - mod = request.GET.copy() - mod.pop('tag') - request.GET = mod - - return { - 'bookmarks': bookmarks, - 'tags': tags, - 'selected_tags': selected_tags, - 'filters': filters, - 'empty': paginator.count == 0, - 'return_url': return_url, - 'link_target': link_target, - } - - -def generate_return_url(base_url: str, page: int, filters: BookmarkFilters): - url_query = {} - if filters.query: - url_query['q'] = filters.query - if filters.user: - url_query['user'] = filters.user - if page is not None: - url_query['page'] = page - url_params = urllib.parse.urlencode(url_query) - return_url = base_url if url_params == '' else base_url + '?' + url_params - return urllib.parse.quote_plus(return_url) + return render(request, 'bookmarks/shared.html', { + 'bookmark_list': bookmark_list, + 'tag_cloud': tag_cloud, + 'users': users + }) def convert_tag_string(tag_string: str): diff --git a/bookmarks/views/partials/__init__.py b/bookmarks/views/partials/__init__.py new file mode 100644 index 0000000..6b4a79a --- /dev/null +++ b/bookmarks/views/partials/__init__.py @@ -0,0 +1,58 @@ +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 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 shared_bookmark_list(request): + bookmark_list_context = contexts.SharedBookmarkListContext(request) + + return render(request, 'bookmarks/bookmark_list.html', { + 'bookmark_list': bookmark_list_context + }) + + +@login_required +def shared_tag_cloud(request): + tag_cloud_context = contexts.SharedTagCloudContext(request) + + return render(request, 'bookmarks/tag_cloud.html', { + 'tag_cloud': tag_cloud_context + }) diff --git a/bookmarks/views/partials/contexts.py b/bookmarks/views/partials/contexts.py new file mode 100644 index 0000000..b2ddd15 --- /dev/null +++ b/bookmarks/views/partials/contexts.py @@ -0,0 +1,168 @@ +import urllib.parse +from typing import Set, List + +from django.core.handlers.wsgi import WSGIRequest +from django.core.paginator import Paginator +from django.db import models +from django.urls import reverse + +from bookmarks import queries +from bookmarks.models import BookmarkFilters, User, UserProfile, Tag +from bookmarks.utils import unique + +DEFAULT_PAGE_SIZE = 30 + + +class BookmarkListContext: + def __init__(self, request: WSGIRequest) -> None: + self.request = request + self.filters = BookmarkFilters(self.request) + + query_set = self.get_bookmark_query_set() + page_number = request.GET.get('page') + paginator = Paginator(query_set, DEFAULT_PAGE_SIZE) + bookmarks_page = paginator.get_page(page_number) + # Prefetch related objects, this avoids n+1 queries when accessing fields in templates + models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags') + + self.is_empty = paginator.count == 0 + self.bookmarks_page = bookmarks_page + self.return_url = self.generate_return_url(page_number) + self.link_target = request.user_profile.bookmark_link_target + self.date_display = request.user_profile.bookmark_date_display + self.show_url = request.user_profile.display_url + self.show_favicons = request.user_profile.enable_favicons + self.show_notes = request.user_profile.permanent_notes + + def generate_return_url(self, page: int): + base_url = self.get_base_url() + url_query = {} + if self.filters.query: + url_query['q'] = self.filters.query + if self.filters.user: + url_query['user'] = self.filters.user + if page is not None: + url_query['page'] = page + url_params = urllib.parse.urlencode(url_query) + return_url = base_url if url_params == '' else base_url + '?' + url_params + return urllib.parse.quote_plus(return_url) + + def get_base_url(self): + raise Exception(f'Must be implemented by subclass') + + def get_bookmark_query_set(self): + raise Exception(f'Must be implemented by subclass') + + +class ActiveBookmarkListContext(BookmarkListContext): + def get_base_url(self): + return reverse('bookmarks:index') + + def get_bookmark_query_set(self): + return queries.query_bookmarks(self.request.user, + self.request.user_profile, + self.filters.query) + + +class ArchivedBookmarkListContext(BookmarkListContext): + def get_base_url(self): + return reverse('bookmarks:archived') + + def get_bookmark_query_set(self): + return queries.query_archived_bookmarks(self.request.user, + self.request.user_profile, + self.filters.query) + + +class SharedBookmarkListContext(BookmarkListContext): + def get_base_url(self): + return reverse('bookmarks:shared') + + def get_bookmark_query_set(self): + user = User.objects.filter(username=self.filters.user).first() + public_only = not self.request.user.is_authenticated + return queries.query_shared_bookmarks(user, + self.request.user_profile, + self.filters.query, + public_only) + + +class TagGroup: + def __init__(self, char: str): + self.tags = [] + self.char = char + + @staticmethod + def create_tag_groups(tags: Set[Tag]): + # Ensure groups, as well as tags within groups, are ordered alphabetically + sorted_tags = sorted(tags, key=lambda x: str.lower(x.name)) + group = None + groups = [] + + # Group tags that start with a different character than the previous one + for tag in sorted_tags: + tag_char = tag.name[0].lower() + + if not group or group.char != tag_char: + group = TagGroup(tag_char) + groups.append(group) + + group.tags.append(tag) + + return groups + + +class TagCloudContext: + def __init__(self, request: WSGIRequest) -> None: + self.request = request + self.filters = BookmarkFilters(self.request) + + query_set = self.get_tag_query_set() + tags = list(query_set) + selected_tags = self.get_selected_tags(tags) + unique_tags = unique(tags, key=lambda x: str.lower(x.name)) + unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name)) + has_selected_tags = len(unique_selected_tags) > 0 + unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags) + groups = TagGroup.create_tag_groups(unselected_tags) + + self.tags = unique_tags + self.groups = groups + self.selected_tags = unique_selected_tags + self.has_selected_tags = has_selected_tags + + def get_tag_query_set(self): + raise Exception(f'Must be implemented by subclass') + + def get_selected_tags(self, tags: List[Tag]): + parsed_query = queries.parse_query_string(self.filters.query) + tag_names = parsed_query['tag_names'] + if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX: + tag_names = tag_names + parsed_query['search_terms'] + tag_names = [tag_name.lower() for tag_name in tag_names] + + return [tag for tag in tags if tag.name.lower() in tag_names] + + +class ActiveTagCloudContext(TagCloudContext): + def get_tag_query_set(self): + return queries.query_bookmark_tags(self.request.user, + self.request.user_profile, + self.filters.query) + + +class ArchivedTagCloudContext(TagCloudContext): + def get_tag_query_set(self): + return queries.query_archived_bookmark_tags(self.request.user, + self.request.user_profile, + self.filters.query) + + +class SharedTagCloudContext(TagCloudContext): + def get_tag_query_set(self): + user = User.objects.filter(username=self.filters.user).first() + public_only = not self.request.user.is_authenticated + return queries.query_shared_bookmark_tags(user, + self.request.user_profile, + self.filters.query, + public_only) diff --git a/package-lock.json b/package-lock.json index 03e9013..2ead481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkding", - "version": "1.16.0", + "version": "1.19.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "linkding", - "version": "1.16.0", + "version": "1.19.1", "license": "ISC", "dependencies": { "@rollup/plugin-commonjs": "^21.0.2", @@ -16,6 +16,9 @@ "rollup-plugin-terser": "^7.0.2", "spectre.css": "^0.5.8", "svelte": "^3.49.0" + }, + "devDependencies": { + "prettier": "^3.0.2" } }, "node_modules/@babel/code-frame": { @@ -477,6 +480,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prettier": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1025,6 +1043,12 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" }, + "prettier": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", + "dev": true + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/package.json b/package.json index b4b32f8..e6b0e76 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "rollup-plugin-terser": "^7.0.2", "spectre.css": "^0.5.8", "svelte": "^3.49.0" + }, + "devDependencies": { + "prettier": "^3.0.2" } } diff --git a/rollup.config.js b/rollup.config.js index 270bb06..f91e1e8 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -6,7 +6,7 @@ import { terser } from 'rollup-plugin-terser'; const production = !process.env.ROLLUP_WATCH; export default { - input: 'bookmarks/components/index.js', + input: 'bookmarks/frontend/index.js', output: { sourcemap: true, format: 'iife',