diff --git a/README.md b/README.md index 798c356..537da1c 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ The name comes from: **Feature Overview:** - Tags for organizing bookmarks - Search by text or tags +- Bulk editing +- Bookmark archive - Automatically provides titles and descriptions from linked websites - Import and export bookmarks in Netscape HTML format -- Bookmark archive - Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe) - Bookmarklet that should work in most browsers - Dark mode @@ -22,7 +23,7 @@ The name comes from: - Works without Javascript - ...but has several UI enhancements when Javascript is enabled - REST API for developing 3rd party apps -- Admin panel for user self-service and bulk operations +- Admin panel for user self-service and raw data access **Demo:** https://demo.linkding.link/ (configured with open registration) diff --git a/bookmarks/components/TagAutocomplete.svelte b/bookmarks/components/TagAutocomplete.svelte index 1ef81aa..5f85e63 100644 --- a/bookmarks/components/TagAutocomplete.svelte +++ b/bookmarks/components/TagAutocomplete.svelte @@ -4,8 +4,10 @@ export let id; export let name; export let value; - export let tags; + export let apiClient; + export let variant = 'default'; + let tags = []; let isFocus = false; let isOpen = false; let input = null; @@ -13,6 +15,18 @@ let suggestions = []; let selectedIndex = 0; + init(); + + async function init() { + // For now we cache all tags on load as the template did before + try { + tags = await apiClient.getTags({limit: 1000, offset: 0}); + tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase())) + } catch (e) { + console.warn('TagAutocomplete: Error loading tag list'); + } + } + function handleFocus() { isFocus = true; } @@ -27,7 +41,9 @@ const word = getCurrentWord(input); - suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : []; + suggestions = word + ? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0) + : []; if (word && suggestions.length > 0) { open(); @@ -70,7 +86,7 @@ function complete(suggestion) { const bounds = getCurrentWordBounds(input); const value = input.value; - input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end); + input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end); close(); } @@ -87,11 +103,11 @@ } -
+
- @@ -105,7 +121,7 @@ complete(tag)}>
- {tag} + {tag.name}
@@ -124,4 +140,17 @@ .menu.open { display: block; } + + .form-autocomplete.small .form-autocomplete-input { + height: 1.4rem; + min-height: 1.4rem; + } + .form-autocomplete.small .form-autocomplete-input input { + margin: 0; + padding: 0; + font-size: 0.7rem; + } + .form-autocomplete.small .menu .menu-item { + font-size: 0.7rem; + } diff --git a/bookmarks/components/api.js b/bookmarks/components/api.js index 292c892..06ed981 100644 --- a/bookmarks/components/api.js +++ b/bookmarks/components/api.js @@ -5,7 +5,7 @@ export class ApiClient { getBookmarks(query, options = {limit: 100, offset: 0}) { const encodedQuery = encodeURIComponent(query) - const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}` + const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}` return fetch(url) .then(response => response.json()) @@ -14,7 +14,15 @@ export class ApiClient { getArchivedBookmarks(query, options = {limit: 100, offset: 0}) { const encodedQuery = encodeURIComponent(query) - const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}` + const url = `${this.baseUrl}bookmarks/archived/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}` + + 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()) diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py index 2769fe0..7223c30 100644 --- a/bookmarks/services/bookmarks.py +++ b/bookmarks/services/bookmarks.py @@ -1,3 +1,5 @@ +from typing import Union + from django.contrib.auth.models import User from django.utils import timezone @@ -46,6 +48,13 @@ def archive_bookmark(bookmark: Bookmark): return bookmark +def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): + sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) + bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) + + bookmarks.update(is_archived=True, date_modified=timezone.now()) + + def unarchive_bookmark(bookmark: Bookmark): bookmark.is_archived = False bookmark.date_modified = timezone.now() @@ -53,6 +62,46 @@ def unarchive_bookmark(bookmark: Bookmark): return bookmark +def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): + sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) + bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) + + bookmarks.update(is_archived=False, date_modified=timezone.now()) + + +def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): + sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) + bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) + + bookmarks.delete() + + +def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User): + sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) + bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) + tag_names = parse_tag_string(tag_string, ' ') + tags = get_or_create_tags(tag_names, current_user) + + for bookmark in bookmarks: + bookmark.tags.add(*tags) + bookmark.date_modified = timezone.now() + + Bookmark.objects.bulk_update(bookmarks, ['date_modified']) + + +def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User): + sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) + bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) + tag_names = parse_tag_string(tag_string, ' ') + tags = get_or_create_tags(tag_names, current_user) + + for bookmark in bookmarks: + bookmark.tags.remove(*tags) + bookmark.date_modified = timezone.now() + + Bookmark.objects.bulk_update(bookmarks, ['date_modified']) + + def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): to_bookmark.title = from_bookmark.title to_bookmark.description = from_bookmark.description @@ -68,3 +117,8 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User): tag_names = parse_tag_string(tag_string, ' ') tags = get_or_create_tags(tag_names, user) bookmark.tags.set(tags) + + +def _sanitize_id_list(bookmark_ids: [Union[int, str]]) -> [int]: + # Convert string ids to int if necessary + return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids] diff --git a/bookmarks/static/bulk_edit.js b/bookmarks/static/bulk_edit.js new file mode 100644 index 0000000..851c969 --- /dev/null +++ b/bookmarks/static/bulk_edit.js @@ -0,0 +1,86 @@ +(function () { + 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'; + } + }); + + // Init tag auto-complete + function initTagAutoComplete() { + 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); + } + + initTagAutoComplete(); +})() diff --git a/bookmarks/static/shared.js b/bookmarks/static/shared.js new file mode 100644 index 0000000..dfd17e5 --- /dev/null +++ b/bookmarks/static/shared.js @@ -0,0 +1,45 @@ +(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; + } + 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); + }); + }); + } + + + initConfirmationButtons() +})() \ No newline at end of file diff --git a/bookmarks/styles/base.scss b/bookmarks/styles/base.scss index 6299542..f7f1a96 100644 --- a/bookmarks/styles/base.scss +++ b/bookmarks/styles/base.scss @@ -1,5 +1,10 @@ body { margin: 20px 10px; + + @media (min-width: $size-sm) { + // High horizontal padding accounts for checkboxes that show up in bulk edit mode + margin: 20px 24px; + } } header { diff --git a/bookmarks/styles/bookmarks.scss b/bookmarks/styles/bookmarks.scss index c1d1a90..73d4975 100644 --- a/bookmarks/styles/bookmarks.scss +++ b/bookmarks/styles/bookmarks.scss @@ -30,6 +30,12 @@ } } +.bookmarks-page .content-area-header { + span.btn { + margin-left: 8px; + } +} + ul.bookmark-list { list-style: none; @@ -57,11 +63,8 @@ ul.bookmark-list { } } - .actions .btn-link.bm-remove-confirm { - color: $error-color; - &:hover { - text-decoration: underline; - } + .bulk-edit-toggle { + display: none; } } @@ -103,3 +106,100 @@ ul.bookmark-list { } } } + +/* 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; + +.bulk-edit-form { + + .bulk-edit-bar { + margin-top: -17px; + margin-bottom: 16px; + margin-left: -$bulk-edit-bar-offset; + max-height: 0; + overflow: hidden; + 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; + } + } + + .bulk-edit-all-toggle { + width: $bulk-edit-toggle-width; + margin: 0 0 0 $bulk-edit-toggle-offset; + padding: 0; + } + + ul.bookmark-list li { + position: relative; + } + + ul.bookmark-list li .bulk-edit-toggle { + display: block; + position: absolute; + width: $bulk-edit-toggle-width; + left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset; + top: 0; + padding: 0; + margin: 0; + visibility: hidden; + opacity: 0; + transition: all $bulk-edit-transition-duration; + + i { + top: 0.2rem; + } + } +} + +#bulk-edit-mode { + display: none; +} + +#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle { + visibility: visible; + opacity: 1; +} + +#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar { + max-height: 37px; + border-bottom: solid 1px $border-color; +} \ No newline at end of file diff --git a/bookmarks/styles/dark.scss b/bookmarks/styles/dark.scss index 7cc4cb4..ac8b2a9 100644 --- a/bookmarks/styles/dark.scss +++ b/bookmarks/styles/dark.scss @@ -21,6 +21,11 @@ a:focus, .btn:focus { background: darken($error-color, 40%); } +.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon { + background: $dt-primary-button-color; + border-color: $dt-primary-button-color; +} + /* Pagination */ .pagination .page-item.active a { background: $dt-primary-button-color; diff --git a/bookmarks/styles/shared.scss b/bookmarks/styles/shared.scss index 62d03cc..f6c2b74 100644 --- a/bookmarks/styles/shared.scss +++ b/bookmarks/styles/shared.scss @@ -1,3 +1,4 @@ +// Content area component section.content-area { .content-area-header { @@ -11,3 +12,11 @@ section.content-area { } } } + +// Confirm button component +.btn-confirmation-action { + color: $error-color !important; + &:hover { + text-decoration: underline; + } +} \ No newline at end of file diff --git a/bookmarks/styles/util.scss b/bookmarks/styles/util.scss index 2e7991d..8d70b38 100644 --- a/bookmarks/styles/util.scss +++ b/bookmarks/styles/util.scss @@ -7,3 +7,11 @@ overflow: hidden; text-overflow: ellipsis; } + +.text-sm { + font-size: 0.7rem; +} + +.text-gray-dark { + color: $gray-color-dark; +} \ No newline at end of file diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html index 7ebfb1e..a69dd11 100644 --- a/bookmarks/templates/bookmarks/archive.html +++ b/bookmarks/templates/bookmarks/archive.html @@ -4,6 +4,9 @@ {% load bookmarks %} {% block content %} + + {% include 'bookmarks/bulk_edit/state.html' %} +
{# Bookmark list #} @@ -12,13 +15,20 @@

Archived bookmarks

{% bookmark_search query tags mode='archive' %} + {% include 'bookmarks/bulk_edit/toggle.html' %}
- {% if empty %} - {% include 'bookmarks/empty_bookmarks.html' %} - {% else %} - {% bookmark_list bookmarks return_url %} - {% endif %} +
+ {% csrf_token %} + {% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %} + + {% if empty %} + {% include 'bookmarks/empty_bookmarks.html' %} + {% else %} + {% bookmark_list bookmarks return_url %} + {% endif %} +
{# Tag list #} @@ -31,4 +41,6 @@
+ + {% endblock %} diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index b1510e4..a41a830 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -4,16 +4,20 @@
\ No newline at end of file diff --git a/bookmarks/templates/bookmarks/bulk_edit/bar.html b/bookmarks/templates/bookmarks/bulk_edit/bar.html new file mode 100644 index 0000000..bc473fe --- /dev/null +++ b/bookmarks/templates/bookmarks/bulk_edit/bar.html @@ -0,0 +1,31 @@ +
+
+ + {% if mode == 'archive' %} + + {% else %} + + {% endif %} + + + + + + + +
+
diff --git a/bookmarks/templates/bookmarks/bulk_edit/state.html b/bookmarks/templates/bookmarks/bulk_edit/state.html new file mode 100644 index 0000000..f846a50 --- /dev/null +++ b/bookmarks/templates/bookmarks/bulk_edit/state.html @@ -0,0 +1 @@ + diff --git a/bookmarks/templates/bookmarks/bulk_edit/toggle.html b/bookmarks/templates/bookmarks/bulk_edit/toggle.html new file mode 100644 index 0000000..9c006d3 --- /dev/null +++ b/bookmarks/templates/bookmarks/bulk_edit/toggle.html @@ -0,0 +1,8 @@ + diff --git a/bookmarks/templates/bookmarks/edit.html b/bookmarks/templates/bookmarks/edit.html index 875769e..f60b82a 100644 --- a/bookmarks/templates/bookmarks/edit.html +++ b/bookmarks/templates/bookmarks/edit.html @@ -8,7 +8,7 @@

Edit bookmark

- {% bookmark_form form all_tags return_url bookmark_id %} + {% bookmark_form form return_url bookmark_id %}
diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html index 9a1dead..954bb57 100644 --- a/bookmarks/templates/bookmarks/form.html +++ b/bookmarks/templates/bookmarks/form.html @@ -64,8 +64,7 @@ + + {% endblock %} diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html index abc439b..c0e5950 100644 --- a/bookmarks/templates/bookmarks/layout.html +++ b/bookmarks/templates/bookmarks/layout.html @@ -2,7 +2,8 @@ {% load sass_tags %} - +{# Use data attributes as storage for access in static scripts #} + diff --git a/bookmarks/templates/bookmarks/new.html b/bookmarks/templates/bookmarks/new.html index 67aa91d..62d0144 100644 --- a/bookmarks/templates/bookmarks/new.html +++ b/bookmarks/templates/bookmarks/new.html @@ -8,7 +8,7 @@

New bookmark

- {% bookmark_form form all_tags return_url auto_close=auto_close %} + {% bookmark_form form return_url auto_close=auto_close %}
diff --git a/bookmarks/templatetags/bookmarks.py b/bookmarks/templatetags/bookmarks.py index 8ef1274..5f42f52 100644 --- a/bookmarks/templatetags/bookmarks.py +++ b/bookmarks/templatetags/bookmarks.py @@ -9,15 +9,10 @@ register = template.Library() @register.inclusion_tag('bookmarks/form.html', name='bookmark_form') -def bookmark_form(form: BookmarkForm, all_tags: List[Tag], cancel_url: str, bookmark_id: int = 0, auto_close: bool = False): - - all_tag_names = [tag.name for tag in all_tags] - all_tags_string = build_tag_string(all_tag_names, ' ') - +def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False): return { 'form': form, 'auto_close': auto_close, - 'all_tags': all_tags_string, 'bookmark_id': bookmark_id, 'cancel_url': cancel_url } diff --git a/bookmarks/tests/test_bookmarks_service.py b/bookmarks/tests/test_bookmarks_service.py index 3f173e9..7d9beba 100644 --- a/bookmarks/tests/test_bookmarks_service.py +++ b/bookmarks/tests/test_bookmarks_service.py @@ -2,18 +2,20 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone -from bookmarks.models import Bookmark -from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark +from bookmarks.models import Bookmark, Tag +from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \ + delete_bookmarks, tag_bookmarks, untag_bookmarks +from bookmarks.tests.helpers import BookmarkFactoryMixin User = get_user_model() -class BookmarkServiceTestCase(TestCase): +class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin): def setUp(self) -> None: self.user = User.objects.create_user('testuser', 'test@example.com', 'password123') - def test_archive(self): + def test_archive_bookmark(self): bookmark = Bookmark( url='https://example.com', date_added=timezone.now(), @@ -30,7 +32,7 @@ class BookmarkServiceTestCase(TestCase): self.assertTrue(updated_bookmark.is_archived) - def test_unarchive(self): + def test_unarchive_bookmark(self): bookmark = Bookmark( url='https://example.com', date_added=timezone.now(), @@ -45,3 +47,297 @@ class BookmarkServiceTestCase(TestCase): updated_bookmark = Bookmark.objects.get(id=bookmark.id) self.assertFalse(updated_bookmark.is_archived) + + def test_archive_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + archive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_archive_bookmarks_should_only_archive_specified_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + archive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_archive_bookmarks_should_only_archive_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + inaccessible_bookmark = self.setup_bookmark(user=other_user) + + archive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user()) + + self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived) + + def test_archive_bookmarks_should_accept_mix_of_int_and_string_ids(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + archive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user()) + + self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_unarchive_bookmarks(self): + bookmark1 = self.setup_bookmark(is_archived=True) + bookmark2 = self.setup_bookmark(is_archived=True) + bookmark3 = self.setup_bookmark(is_archived=True) + + unarchive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_unarchive_bookmarks_should_only_unarchive_specified_bookmarks(self): + bookmark1 = self.setup_bookmark(is_archived=True) + bookmark2 = self.setup_bookmark(is_archived=True) + bookmark3 = self.setup_bookmark(is_archived=True) + + unarchive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_unarchive_bookmarks_should_only_unarchive_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + bookmark1 = self.setup_bookmark(is_archived=True) + bookmark2 = self.setup_bookmark(is_archived=True) + inaccessible_bookmark = self.setup_bookmark(is_archived=True, user=other_user) + + unarchive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user()) + + self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived) + + def test_unarchive_bookmarks_should_accept_mix_of_int_and_string_ids(self): + bookmark1 = self.setup_bookmark(is_archived=True) + bookmark2 = self.setup_bookmark(is_archived=True) + bookmark3 = self.setup_bookmark(is_archived=True) + + unarchive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user()) + + self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_delete_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first()) + self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first()) + self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first()) + + def test_delete_bookmarks_should_only_delete_specified_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + delete_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first()) + self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first()) + self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first()) + + def test_delete_bookmarks_should_only_delete_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + inaccessible_bookmark = self.setup_bookmark(user=other_user) + + delete_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user()) + + self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first()) + self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first()) + self.assertIsNotNone(Bookmark.objects.filter(id=inaccessible_bookmark.id).first()) + + def test_delete_bookmarks_should_accept_mix_of_int_and_string_ids(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()) + + self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first()) + self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first()) + self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first()) + + def test_tag_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + tag1 = self.setup_tag() + tag2 = self.setup_tag() + + tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}', + self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) + + def test_tag_bookmarks_should_create_tags(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertEqual(2, Tag.objects.count()) + + tag1 = Tag.objects.filter(name='tag1').first() + tag2 = Tag.objects.filter(name='tag2').first() + + self.assertIsNotNone(tag1) + self.assertIsNotNone(tag2) + + self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) + + def test_tag_bookmarks_should_only_tag_specified_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + tag1 = self.setup_tag() + tag2 = self.setup_tag() + + tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark2.tags.all(), []) + self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) + + def test_tag_bookmarks_should_only_tag_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + inaccessible_bookmark = self.setup_bookmark(user=other_user) + tag1 = self.setup_tag() + tag2 = self.setup_tag() + + tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}', + self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + inaccessible_bookmark.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) + self.assertCountEqual(inaccessible_bookmark.tags.all(), []) + + def test_tag_bookmarks_should_accept_mix_of_int_and_string_ids(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + tag1 = self.setup_tag() + tag2 = self.setup_tag() + + tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}', + self.get_or_create_test_user()) + + self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) + + def test_untag_bookmarks(self): + tag1 = self.setup_tag() + tag2 = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) + + untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}', + self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), []) + self.assertCountEqual(bookmark2.tags.all(), []) + self.assertCountEqual(bookmark3.tags.all(), []) + + def test_untag_bookmarks_should_only_tag_specified_bookmarks(self): + tag1 = self.setup_tag() + tag2 = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) + + untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), []) + self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark3.tags.all(), []) + + def test_untag_bookmarks_should_only_tag_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + tag1 = self.setup_tag() + tag2 = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) + inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2]) + + untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}', + self.get_or_create_test_user()) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + inaccessible_bookmark.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), []) + self.assertCountEqual(bookmark2.tags.all(), []) + self.assertCountEqual(inaccessible_bookmark.tags.all(), [tag1, tag2]) + + def test_untag_bookmarks_should_accept_mix_of_int_and_string_ids(self): + tag1 = self.setup_tag() + tag2 = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) + + untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}', + self.get_or_create_test_user()) + + self.assertCountEqual(bookmark1.tags.all(), []) + self.assertCountEqual(bookmark2.tags.all(), []) + self.assertCountEqual(bookmark3.tags.all(), []) diff --git a/bookmarks/tests/test_bulk_edit_integration.py b/bookmarks/tests/test_bulk_edit_integration.py new file mode 100644 index 0000000..fcac415 --- /dev/null +++ b/bookmarks/tests/test_bulk_edit_integration.py @@ -0,0 +1,132 @@ +from django.forms import model_to_dict +from django.test import TestCase +from django.urls import reverse + +from bookmarks.models import Bookmark +from bookmarks.tests.helpers import BookmarkFactoryMixin + + +class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin): + + def setUp(self) -> None: + user = self.get_or_create_test_user() + self.client.force_login(user) + + def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]): + self.assertEqual(len(bookmarks), Bookmark.objects.count()) + + for bookmark in bookmarks: + self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id))) + + def test_bulk_archive(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_archive': [''], + 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], + }) + + self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_bulk_unarchive(self): + bookmark1 = self.setup_bookmark(is_archived=True) + bookmark2 = self.setup_bookmark(is_archived=True) + bookmark3 = self.setup_bookmark(is_archived=True) + + self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_unarchive': [''], + 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], + }) + + self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived) + self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived) + + def test_bulk_delete(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_delete': [''], + 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], + }) + + self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first()) + self.assertFalse(Bookmark.objects.filter(id=bookmark2.id).first()) + self.assertFalse(Bookmark.objects.filter(id=bookmark3.id).first()) + + def test_bulk_tag(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + tag1 = self.setup_tag() + tag2 = self.setup_tag() + + self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_tag': [''], + 'bulk_tag_string': [f'{tag1.name} {tag2.name}'], + 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], + }) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) + self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) + + def test_bulk_untag(self): + tag1 = self.setup_tag() + tag2 = self.setup_tag() + bookmark1 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) + bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) + + self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_untag': [''], + 'bulk_tag_string': [f'{tag1.name} {tag2.name}'], + 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], + }) + + bookmark1.refresh_from_db() + bookmark2.refresh_from_db() + bookmark3.refresh_from_db() + + self.assertCountEqual(bookmark1.tags.all(), []) + self.assertCountEqual(bookmark2.tags.all(), []) + self.assertCountEqual(bookmark3.tags.all(), []) + + def test_bulk_edit_handles_empty_bookmark_id(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + response = self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_archive': [''], + }) + self.assertEqual(response.status_code, 302) + + response = self.client.post(reverse('bookmarks:bulk_edit'), { + 'bulk_archive': [''], + 'bookmark_id': [], + }) + self.assertEqual(response.status_code, 302) + + self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3]) + + def test_empty_action_does_not_modify_bookmarks(self): + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark() + bookmark3 = self.setup_bookmark() + + self.client.post(reverse('bookmarks:bulk_edit'), { + 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], + }) + + self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3]) diff --git a/bookmarks/urls.py b/bookmarks/urls.py index 1b0a95b..82053d5 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('bookmarks//remove', views.bookmarks.remove, name='remove'), path('bookmarks//archive', views.bookmarks.archive, name='archive'), path('bookmarks//unarchive', views.bookmarks.unarchive, name='unarchive'), + path('bookmarks/bulkedit', views.bookmarks.bulk_edit, name='bulk_edit'), # 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 cc2b889..78a0a73 100644 --- a/bookmarks/views/bookmarks.py +++ b/bookmarks/views/bookmarks.py @@ -8,7 +8,8 @@ from django.urls import reverse from bookmarks import queries from bookmarks.models import Bookmark, BookmarkForm, build_tag_string -from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark +from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ + unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks _default_page_size = 30 @@ -87,11 +88,9 @@ def new(request): if initial_auto_close: form.initial['auto_close'] = 'true' - all_tags = queries.get_user_tags(request.user) context = { 'form': form, 'auto_close': initial_auto_close, - 'all_tags': all_tags, 'return_url': reverse('bookmarks:index') } @@ -116,12 +115,10 @@ def edit(request, bookmark_id: int): form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ') form.initial['return_url'] = return_url - all_tags = queries.get_user_tags(request.user) context = { 'form': form, 'bookmark_id': bookmark_id, - 'all_tags': all_tags, 'return_url': return_url } @@ -155,6 +152,29 @@ def unarchive(request, bookmark_id: int): return HttpResponseRedirect(return_url) +@login_required +def bulk_edit(request): + bookmark_ids = request.POST.getlist('bookmark_id') + + # Determine action + if 'bulk_archive' in request.POST: + archive_bookmarks(bookmark_ids, request.user) + if 'bulk_unarchive' in request.POST: + unarchive_bookmarks(bookmark_ids, request.user) + if 'bulk_delete' in request.POST: + delete_bookmarks(bookmark_ids, request.user) + if 'bulk_tag' in request.POST: + tag_string = request.POST['bulk_tag_string'] + tag_bookmarks(bookmark_ids, tag_string, request.user) + if 'bulk_untag' in request.POST: + tag_string = request.POST['bulk_tag_string'] + untag_bookmarks(bookmark_ids, tag_string, request.user) + + return_url = request.GET.get('return_url') + return_url = return_url if return_url else reverse('bookmarks:index') + return HttpResponseRedirect(return_url) + + @login_required def close(request): return render(request, 'bookmarks/close.html')