Compare commits

...

6 Commits

Author SHA1 Message Date
Sascha Ißbrücker
9b8929e697 Bump version 2023-08-25 16:38:05 +02:00
dependabot[bot]
5b8ff86029 Bump uwsgi from 2.0.20 to 2.0.22 (#516)
Bumps [uwsgi](https://github.com/unbit/uwsgi-docs) from 2.0.20 to 2.0.22.
- [Commits](https://github.com/unbit/uwsgi-docs/commits)

---
updated-dependencies:
- dependency-name: uwsgi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-25 16:35:49 +02:00
Sascha Ißbrücker
e2e5930985 Allow bulk editing unread and shared state of bookmarks (#517)
* Move bulk actions into select

* Update tests

* Implement bulk read / unread actions

* Implement bulk share/unshare actions

* Show correct archiving actions

* Allow selecting bookmarks across pages

* Dynamically update select across checkbox

* Filter available bulk actions

* Refactor tag autocomplete toggling
2023-08-25 13:54:23 +02:00
Sascha Ißbrücker
2ceac9a87d Display shared state in bookmark list (#515)
* Add unshare action

* Show shared state in bookmark list

* Update tests

* Reflect unread and shared state as CSS class
2023-08-24 19:11:36 +02:00
Sascha Ißbrücker
bca9bf9b11 Various CSS improvements (#514)
* Replace flexbox grid with CSS grid

* Update new and edit forms

* Update settings views

* Update auth views

* Fix margin in menu

* Remove unused Spectre modules

* Simplify navbar

* Reuse CSS variables

* Fix grid gap on small screen sizes

* Simplify grid system

* Improve section headers

* Restructure SASS files

* Cleanup base styles

* Update test
2023-08-24 14:46:47 +02:00
Sascha Ißbrücker
768f1346a3 Make search autocomplete respect link target setting (#513) 2023-08-24 10:22:05 +02:00
54 changed files with 2009 additions and 732 deletions

View File

@@ -0,0 +1,232 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def setup_test_data(self):
self.setup_numbered_bookmarks(50)
self.setup_numbered_bookmarks(50, archived=True)
self.setup_numbered_bookmarks(50, prefix='foo')
self.setup_numbered_bookmarks(50, archived=True, prefix='foo')
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_active_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_archived_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_active_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:index') + '?q=foo', p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_archived_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived') + '?q=foo', p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
def test_select_all_toggles_all_checkboxes(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
page = self.open(url, p)
self.locate_bulk_edit_toggle().click()
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
self.assertEqual(6, checkboxes.count())
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).to_be_checked()
self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
def test_select_all_shows_select_across(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).to_be_visible()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
def test_select_across_is_unchecked_when_toggling_all(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
# Show select across, check it
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling select all
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_select_across_is_unchecked_when_toggling_bookmark(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
# Show select across, check it
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling a single bookmark
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_execute_resets_all_checkboxes(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse('bookmarks:index')
page = self.open(url, p)
# Select all bookmarks, enable select across
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
# Get reference for bookmark list
bookmark_list = page.locator('ul[ld-bookmark-list]')
# Execute bulk action
self.select_bulk_action('Mark as unread')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
# Verify bulk edit checkboxes are reset
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
self.assertEqual(31, checkboxes.count())
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
# Toggle select all and verify select across is reset
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_update_select_across_bookmark_count(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_bar().get_by_text('All pages (100 bookmarks)')).to_be_visible()
self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_bar().get_by_text('All pages (70 bookmarks)')).to_be_visible()

View File

@@ -119,10 +119,27 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p: with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p) self.open(reverse('bookmarks:index'), p)
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic') expect(self.locate_bookmark('Bookmark 2')).to_have_class('unread')
self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click() self.locate_bookmark('Bookmark 2').get_by_text('Unread').click()
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic') expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('unread')
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_unshare(self):
self.setup_fixture()
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
bookmark2.shared = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
expect(self.locate_bookmark('Bookmark 2')).to_have_class('shared')
self.locate_bookmark('Bookmark 2').get_by_text('Shared').click()
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('shared')
self.assertReloads(0) self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_archive(self): def test_active_bookmarks_partial_update_on_bulk_archive(self):
@@ -133,7 +150,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click() self.select_bulk_action('Archive')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
@@ -148,7 +166,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click() self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click() self.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3']) self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
@@ -180,7 +199,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3']) self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0) self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_archive(self): def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
self.setup_fixture() self.setup_fixture()
with sync_playwright() as p: with sync_playwright() as p:
@@ -188,7 +207,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').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.select_bulk_action('Unarchive')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
@@ -203,7 +223,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').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.select_bulk_action('Delete')
self.locate_bulk_edit_bar().get_by_text('Execute').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click() self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3']) self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])

View File

@@ -41,5 +41,14 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def locate_bulk_edit_bar(self): def locate_bulk_edit_bar(self):
return self.page.locator('.bulk-edit-bar') return self.page.locator('.bulk-edit-bar')
def locate_bulk_edit_select_all(self):
return self.locate_bulk_edit_bar().locator('label[ld-bulk-edit-checkbox][all]')
def locate_bulk_edit_select_across(self):
return self.locate_bulk_edit_bar().locator('label.select-across')
def locate_bulk_edit_toggle(self): def locate_bulk_edit_toggle(self):
return self.page.get_by_title('Bulk edit') return self.page.get_by_title('Bulk edit')
def select_bulk_action(self, value: str):
return self.locate_bulk_edit_bar().locator('select[name="bulk_action"]').select_option(value)

View File

@@ -36,8 +36,18 @@ class BookmarkPage {
swap(this.bookmarkList, bookmarkListHtml); swap(this.bookmarkList, bookmarkListHtml);
swap(this.tagCloud, tagCloudHtml); swap(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(
"ul[data-bookmarks-total]",
);
const bookmarksTotal =
(listElement && listElement.dataset.bookmarksTotal) || 0;
this.bookmarkList.dispatchEvent( this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", { bubbles: true }), new CustomEvent("bookmark-list-updated", {
bubbles: true,
detail: { bookmarksTotal },
}),
); );
}); });
} }

View File

@@ -4,6 +4,9 @@ class BulkEdit {
constructor(element) { constructor(element) {
this.element = element; this.element = element;
this.active = false; this.active = false;
this.actionSelect = element.querySelector("select[name='bulk_action']");
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
this.selectAcross = element.querySelector("label.select-across");
element.addEventListener( element.addEventListener(
"bulk-edit-toggle-active", "bulk-edit-toggle-active",
@@ -21,6 +24,11 @@ class BulkEdit {
"bookmark-list-updated", "bookmark-list-updated",
this.onListUpdated.bind(this), this.onListUpdated.bind(this),
); );
this.actionSelect.addEventListener(
"change",
this.onActionSelected.bind(this),
);
} }
get allCheckbox() { get allCheckbox() {
@@ -48,23 +56,56 @@ class BulkEdit {
} }
onToggleBookmark() { onToggleBookmark() {
this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => { const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked; return checkbox.checked;
}); });
this.allCheckbox.checked = allChecked;
this.updateSelectAcross(allChecked);
} }
onToggleAll() { onToggleAll() {
const checked = this.allCheckbox.checked; const allChecked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => { this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = checked; checkbox.checked = allChecked;
}); });
this.updateSelectAcross(allChecked);
} }
onListUpdated() { onActionSelected() {
const action = this.actionSelect.value;
if (action === "bulk_tag" || action === "bulk_untag") {
this.tagAutoComplete.classList.remove("d-none");
} else {
this.tagAutoComplete.classList.add("d-none");
}
}
onListUpdated(event) {
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const total = event.detail.bookmarksTotal;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
updateSelectAcross(allChecked) {
if (allChecked) {
this.selectAcross.classList.remove("d-none");
} else {
this.selectAcross.classList.add("d-none");
this.selectAcross.querySelector("input").checked = false;
}
}
reset() {
this.allCheckbox.checked = false; this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => { this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false; checkbox.checked = false;
}); });
this.updateSelectAcross(false);
} }
} }

View File

@@ -16,9 +16,31 @@ class ConfirmButtonBehavior {
onClick(event) { onClick(event) {
event.preventDefault(); event.preventDefault();
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.button.getAttribute("confirm-icon");
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
}
const question = this.button.getAttribute("confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
const cancelButton = document.createElement(this.button.nodeName); const cancelButton = document.createElement(this.button.nodeName);
cancelButton.type = "button"; cancelButton.type = "button";
cancelButton.innerText = "Cancel"; cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = "btn btn-link btn-sm mr-1"; cancelButton.className = "btn btn-link btn-sm mr-1";
cancelButton.addEventListener("click", this.reset.bind(this)); cancelButton.addEventListener("click", this.reset.bind(this));
@@ -26,12 +48,10 @@ class ConfirmButtonBehavior {
confirmButton.type = this.button.dataset.type; confirmButton.type = this.button.dataset.type;
confirmButton.name = this.button.dataset.name; confirmButton.name = this.button.dataset.name;
confirmButton.value = this.button.dataset.value; confirmButton.value = this.button.dataset.value;
confirmButton.innerText = "Confirm"; confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = "btn btn-link btn-sm"; confirmButton.className = "btn btn-link btn-sm";
confirmButton.addEventListener("click", this.reset.bind(this)); confirmButton.addEventListener("click", this.reset.bind(this));
const container = document.createElement("span");
container.className = "confirmation";
container.append(cancelButton, confirmButton); container.append(cancelButton, confirmButton);
this.container = container; this.container = container;

View File

@@ -14,12 +14,13 @@ class TagAutocomplete {
id: element.id, id: element.id,
name: element.name, name: element.name,
value: element.value, value: element.value,
placeholder: element.getAttribute("placeholder") || "",
apiClient: apiClient, apiClient: apiClient,
variant: element.getAttribute("variant"), variant: element.getAttribute("variant"),
}, },
}); });
element.replaceWith(wrapper); element.replaceWith(wrapper.firstElementChild);
} }
} }

View File

@@ -11,6 +11,7 @@
export let mode = ''; export let mode = '';
export let apiClient; export let apiClient;
export let filters; export let filters;
export let linkTarget = '_blank';
let isFocus = false; let isFocus = false;
let isOpen = false; let isOpen = false;
@@ -164,7 +165,7 @@
close() close()
} }
if (suggestion.type === 'bookmark') { if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank') window.open(suggestion.bookmark.url, linkTarget)
close() close()
} }
if (suggestion.type === 'tag') { if (suggestion.type === 'tag') {
@@ -209,11 +210,7 @@
{#each suggestions.tags as suggestion} {#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}> <li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}> <a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered"> {suggestion.label}
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a> </a>
</li> </li>
{/each} {/each}
@@ -224,11 +221,7 @@
{#each suggestions.search as suggestion} {#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}> <li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}> <a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered"> {suggestion.label}
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a> </a>
</li> </li>
{/each} {/each}
@@ -239,11 +232,7 @@
{#each suggestions.bookmarks as suggestion} {#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}> <li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}> <a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered"> {suggestion.label}
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a> </a>
</li> </li>
{/each} {/each}

View File

@@ -4,6 +4,7 @@
export let id; export let id;
export let name; export let name;
export let value; export let value;
export let placeholder;
export let apiClient; export let apiClient;
export let variant = 'default'; export let variant = 'default';
@@ -118,7 +119,7 @@
<!-- autocomplete input container --> <!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}> <div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box --> <!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;" <input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off" class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown} on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}> on:focus={handleFocus} on:blur={handleBlur}>
@@ -131,11 +132,7 @@
{#each suggestions as tag,i} {#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}> <li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}> <a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered"> {tag.name}
<div class="tile-content">
{tag.name}
</div>
</div>
</a> </a>
</li> </li>
{/each} {/each}
@@ -156,6 +153,7 @@
.form-autocomplete.small .form-autocomplete-input { .form-autocomplete.small .form-autocomplete-input {
height: 1.4rem; height: 1.4rem;
min-height: 1.4rem; min-height: 1.4rem;
padding: 0.05rem 0.3rem;
} }
.form-autocomplete.small .form-autocomplete-input input { .form-autocomplete.small .form-autocomplete-input input {

View File

@@ -119,6 +119,34 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
Bookmark.objects.bulk_update(bookmarks, ['date_modified']) Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
def mark_bookmarks_as_read(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(unread=False, date_modified=timezone.now())
def mark_bookmarks_as_unread(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(unread=True, date_modified=timezone.now())
def share_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(shared=True, date_modified=timezone.now())
def unshare_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(shared=False, date_modified=timezone.now())
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

View File

@@ -1,6 +0,0 @@
.auth-page {
> .columns {
align-items: center;
justify-content: center;
}
}

View File

@@ -1,14 +1,29 @@
/* Main layout */
body { body {
margin: 20px 10px; margin: 20px 10px;
@media (min-width: $size-sm) { @media (min-width: $size-sm) {
// High horizontal padding accounts for checkboxes that show up in bulk edit mode // Horizontal padding accounts for checkboxes that show up in bulk edit mode
margin: 20px 24px; margin: 20px 32px;
} }
} }
header { header {
margin-bottom: 40px; margin-bottom: $unit-10;
.logo {
width: 28px;
height: 28px;
}
a:hover {
text-decoration: none;
}
h1 {
margin: 0 0 0 $unit-3;
font-size: $font-size-lg;
}
} }
header .toasts { header .toasts {
@@ -23,97 +38,101 @@ header .toasts {
} }
} }
.navbar { /* Shared components */
.navbar-brand { // Content area component
section.content-area {
h2 {
font-size: $font-size-lg;
}
.content-area-header {
border-bottom: solid 1px $border-color;
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
padding-bottom: $unit-2;
margin-bottom: $unit-4;
.logo { h2 {
width: 28px; line-height: 1.8rem;
height: 28px; margin-right: auto;
margin-bottom: 0;
} }
h1 {
text-transform: uppercase;
display: inline-block;
margin: 0 0 0 8px;
}
}
.dropdown-toggle {
} }
} }
/* Overrides */ // Confirm button component
span.confirmation {
display: flex;
align-items: baseline;
gap: $unit-1;
color: $error-color !important;
// Reduce heading sizes svg {
h1 { align-self: center;
font-size: inherit; }
.btn.btn-link {
color: $error-color !important;
&:hover {
text-decoration: underline;
}
}
} }
h2 { /* Additional utilities */
font-size: .85rem;
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text-sm {
font-size: 0.7rem;
}
.text-gray-dark {
color: $gray-color-dark; color: $gray-color-dark;
} }
// Fix up visited styles .align-baseline {
a:visited { align-items: baseline;
color: $link-color;
}
a:visited:hover {
color: $link-color-dark;
}
.btn-link:visited:not(.btn-primary) {
color: $link-color;
}
.btn-link:visited:not(.btn-primary):hover {
color: $link-color-dark;
} }
code { .align-center {
color: $gray-color-dark; align-items: center;
background-color: $code-bg-color;
box-shadow: 1px 1px 0 $code-shadow-color;
} }
// Increase spacing between columns .justify-between {
.container > .columns > .column:not(:first-child) { justify-content: space-between;
padding-left: 2rem;
} }
// Remove left padding from first pagination link .mb-4 {
.pagination .page-item:first-child a { margin-bottom: $unit-4;
padding-left: 0;
} }
// Override border color for tab block .mx-auto {
.tab-block { margin-left: auto;
border-bottom: solid 1px $border-color; margin-right: auto;
} }
// Form auto-complete menu .ml-auto {
.form-autocomplete .menu { margin-left: auto;
.menu-item.selected > a, .menu-item > a:hover { }
background: $secondary-color;
color: $primary-color; .btn.btn-wide {
padding-left: $unit-6;
padding-right: $unit-6;
}
.btn.btn-sm.btn-icon {
display: inline-flex;
align-items: baseline;
gap: $unit-h;
svg {
align-self: center;
} }
}
.group-item, .group-item:hover {
color: $gray-color;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}
// Increase input font size on small viewports to prevent zooming on focus the input
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
// viewport size
@media screen and (max-width: 430px) {
.form-input {
font-size: 16px;
}
}

View File

@@ -0,0 +1,50 @@
.bookmarks-form {
.btn.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
color: $gray-color;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
> svg {
width: 20px;
height: 20px;
}
}
.has-icon-right > input, .has-icon-right > textarea {
padding-right: 30px;
}
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
.form-icon.loading {
visibility: hidden;
}
.form-input-hint.bookmark-exists {
display: none;
color: $warning-color;
a {
color: $warning-color;
text-decoration: underline;
font-weight: bold;
}
}
details.notes textarea {
box-sizing: border-box;
}
}

View File

@@ -1,3 +1,7 @@
.bookmarks-page.grid {
grid-gap: $unit-10;
}
/* Bookmark search box */ /* Bookmark search box */
.bookmarks-page .search { .bookmarks-page .search {
$searchbox-width: 180px; $searchbox-width: 180px;
@@ -62,9 +66,14 @@ li[ld-bookmark-item] {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
&.unread .title a {
font-style: italic;
}
.title img { .title img {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-right: $unit-h;
vertical-align: text-top; vertical-align: text-top;
} }
@@ -80,11 +89,18 @@ li[ld-bookmark-item] {
} }
} }
.actions { .actions, .extra-actions {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; column-gap: $unit-2;
}
@media (max-width: $size-sm) {
.extra-actions {
width: 100%;
margin-top: $unit-1;
}
} }
.actions { .actions {
@@ -108,24 +124,17 @@ li[ld-bookmark-item] {
.separator { .separator {
align-self: flex-start; align-self: flex-start;
} }
.toggle-notes {
align-self: center;
display: flex;
align-items: center;
gap: 0.1rem;
}
} }
} }
.bookmark-pagination { .bookmark-pagination {
margin-top: 1rem; margin-top: $unit-4;
} }
.tag-cloud { .tag-cloud {
.selected-tags { .selected-tags {
margin-bottom: 0.8rem; margin-bottom: $unit-4;
a, a:visited:hover { a, a:visited:hover {
color: $error-color; color: $error-color;
@@ -139,7 +148,7 @@ li[ld-bookmark-item] {
} }
.group { .group {
margin-bottom: 0.4rem; margin-bottom: $unit-2;
} }
.highlight-char { .highlight-char {
@@ -149,63 +158,12 @@ li[ld-bookmark-item] {
} }
} }
.bookmarks-form {
.btn.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
color: $gray-color;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
> svg {
width: 20px;
height: 20px;
}
}
.has-icon-right > input, .has-icon-right > textarea {
padding-right: 30px;
}
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
.form-icon.loading {
visibility: hidden;
}
.form-input-hint.bookmark-exists {
display: none;
color: $warning-color;
a {
color: $warning-color;
text-decoration: underline;
font-weight: bold;
}
}
details.notes textarea {
box-sizing: border-box;
}
}
/* Bookmark notes */ /* Bookmark notes */
ul.bookmark-list { ul.bookmark-list {
.notes { .notes {
display: none; display: none;
max-height: 300px; max-height: 300px;
margin: 4px 0; margin: $unit-1 0;
overflow: auto; overflow: auto;
} }
@@ -218,11 +176,11 @@ ul.bookmark-list {
/* Bookmark notes markdown styles */ /* Bookmark notes markdown styles */
ul.bookmark-list .notes-content { ul.bookmark-list .notes-content {
& { & {
padding: 0.4rem 0.6rem; padding: $unit-2 $unit-3;
} }
p, ul, ol, pre, blockquote { p, ul, ol, pre, blockquote {
margin: 0 0 0.4rem 0; margin: 0 0 $unit-2 0;
} }
> *:first-child { > *:first-child {
@@ -234,17 +192,17 @@ ul.bookmark-list .notes-content {
} }
ul, ol { ul, ol {
margin-left: 0.8rem; margin-left: $unit-4;
} }
ul li, ol li { ul li, ol li {
margin-top: 0.2rem; margin-top: $unit-1;
} }
pre { pre {
padding: 0.2rem 0.4rem; padding: $unit-1 $unit-2;
background-color: $code-bg-color; background-color: $code-bg-color;
border-radius: 0.2rem; border-radius: $unit-1;
} }
pre code { pre code {
@@ -268,9 +226,9 @@ $bulk-edit-transition-duration: 400ms;
[ld-bulk-edit] { [ld-bulk-edit] {
.bulk-edit-bar { .bulk-edit-bar {
margin-top: -17px; margin-top: -1px;
margin-bottom: 16px;
margin-left: -$bulk-edit-bar-offset; margin-left: -$bulk-edit-bar-offset;
margin-bottom: $unit-4;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height $bulk-edit-transition-duration; transition: max-height $bulk-edit-transition-duration;
@@ -309,7 +267,7 @@ $bulk-edit-transition-duration: 400ms;
transition: all $bulk-edit-transition-duration; transition: all $bulk-edit-transition-duration;
.form-icon { .form-icon {
top: 0.2rem; top: $unit-1;
} }
} }
@@ -322,9 +280,9 @@ $bulk-edit-transition-duration: 400ms;
.bulk-edit-actions { .bulk-edit-actions {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
padding: 4px 0; padding: $unit-1 0;
border-top: solid 1px $border-color; border-top: solid 1px $border-color;
gap: 8px; gap: $unit-2;
button { button {
padding: 0 !important; padding: 0 !important;
@@ -334,10 +292,15 @@ $bulk-edit-transition-duration: 400ms;
text-decoration: underline; text-decoration: underline;
} }
> input, .form-autocomplete { > input, .form-autocomplete, select {
width: auto; width: auto;
max-width: 200px; max-width: 140px;
-webkit-appearance: none; -webkit-appearance: none;
} }
.select-across {
margin: 0 0 0 auto;
font-size: $font-size-sm;
}
} }
} }

View File

@@ -1,32 +0,0 @@
/* Dark theme overrides */
/* Buttons */
.btn.btn-primary {
background: $dt-primary-button-color;
border-color: darken($dt-primary-button-color, 5%);
&:hover, &:active, &:focus {
background: darken($dt-primary-button-color, 5%);
border-color: darken($dt-primary-button-color, 10%);
}
}
/* Focus ring*/
a:focus, .btn:focus {
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
}
/* Forms */
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
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;
}

View File

@@ -0,0 +1,108 @@
.container {
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: $size-lg;
}
.show-sm,
.show-md {
display: none !important;
}
.width-25 {
width: 25%;
}
.width-50 {
width: 50%;
}
.width-75 {
width: 75%;
}
.width-100 {
width: 100%;
}
.grid {
--grid-columns: 3;
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
grid-gap: $unit-4;
}
.grid > * {
min-width: 0;
}
.col-1 {
grid-column: unquote("span min(1, var(--grid-columns))");
}
.col-2 {
grid-column: unquote("span min(2, var(--grid-columns))");
}
.col-3 {
grid-column: unquote("span min(3, var(--grid-columns))");
}
@media (max-width: $size-md) {
.hide-md {
display: none !important;
}
.show-md {
display: block !important;
}
.width-md-25 {
width: 25%;
}
.width-md-50 {
width: 50%;
}
.width-md-75 {
width: 75%;
}
.width-md-100 {
width: 100%;
}
.columns-md-1 {
--grid-columns: 1;
}
.columns-md-2 {
--grid-columns: 2;
}
}
@media (max-width: $size-sm) {
.hide-sm {
display: none !important;
}
.show-sm {
display: block !important;
}
.width-sm-25 {
width: 25%;
}
.width-sm-50 {
width: 50%;
}
.width-sm-75 {
width: 75%;
}
.width-sm-100 {
width: 100%;
}
.columns-sm-1 {
--grid-columns: 1;
}
.columns-sm-2 {
--grid-columns: 2;
}
}

View File

@@ -1,10 +1,9 @@
.settings-page { .settings-page {
section.content-area { section.content-area {
margin-bottom: 2rem; margin-bottom: $unit-12;
h2 { h2 {
font-size: 1.0rem; margin-bottom: $unit-4;
margin-bottom: 0.8rem;
} }
} }

View File

@@ -1,28 +0,0 @@
// Content area component
section.content-area {
.content-area-header {
border-bottom: solid 1px $border-color;
display: flex;
flex-direction: row;
margin-bottom: 16px;
h2 {
line-height: 1.8rem;
}
}
}
// Confirm button component
span.confirmation {
display: flex;
align-items: baseline;
}
span.confirmation .btn.btn-link {
color: $error-color !important;
&:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,110 @@
// Customized Spectre CSS imports, removing modules that are not used
// See node_modules/spectre.css/src/spectre.scss for the original version
// Variables and mixins
@import "../../node_modules/spectre.css/src/variables";
@import "../../node_modules/spectre.css/src/mixins";
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
// Reset and dependencies
@import "../../node_modules/spectre.css/src/normalize";
@import "../../node_modules/spectre.css/src/base";
// Elements
@import "../../node_modules/spectre.css/src/typography";
@import "../../node_modules/spectre.css/src/asian";
@import "../../node_modules/spectre.css/src/tables";
@import "../../node_modules/spectre.css/src/buttons";
@import "../../node_modules/spectre.css/src/forms";
@import "../../node_modules/spectre.css/src/labels";
@import "../../node_modules/spectre.css/src/codes";
@import "../../node_modules/spectre.css/src/media";
// Components
@import "../../node_modules/spectre.css/src/dropdowns";
@import "../../node_modules/spectre.css/src/empty";
@import "../../node_modules/spectre.css/src/menus";
@import "../../node_modules/spectre.css/src/pagination";
@import "../../node_modules/spectre.css/src/tabs";
@import "../../node_modules/spectre.css/src/toasts";
@import "../../node_modules/spectre.css/src/tooltips";
// Utility classes
@import "../../node_modules/spectre.css/src/animations";
@import "../../node_modules/spectre.css/src/utilities";
// Auto-complete component
@import "../../node_modules/spectre.css/src/autocomplete";
/* Spectre overrides / fixes */
// Fix up visited styles
a:visited {
color: $link-color;
}
a:visited:hover {
color: $link-color-dark;
}
.btn-link:visited:not(.btn-primary) {
color: $link-color;
}
.btn-link:visited:not(.btn-primary):hover {
color: $link-color-dark;
}
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
.btn {
transition: none !important;
}
// Make code work with light and dark theme
code {
color: $gray-color-dark;
background-color: $code-bg-color;
box-shadow: 1px 1px 0 $code-shadow-color;
}
// Remove left padding from first pagination link
.pagination .page-item:first-child a {
padding-left: 0;
}
// Override border color for tab block
.tab-block {
border-bottom: solid 1px $border-color;
}
// Fix padding for first menu item
ul.menu li:first-child {
margin-top: 0;
}
// Form auto-complete menu
.form-autocomplete .menu {
.menu-item.selected > a, .menu-item > a:hover {
background: $secondary-color;
color: $primary-color;
}
.group-item, .group-item:hover {
color: $gray-color;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}
// Increase input font size on small viewports to prevent zooming on focus the input
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
// viewport size
@media screen and (max-width: 430px) {
.form-input {
font-size: 16px;
}
}

View File

@@ -2,16 +2,49 @@
@import "variables-dark"; @import "variables-dark";
// Import Spectre CSS lib // Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre"; @import "spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import style modules // Import style modules
@import "base"; @import "base";
@import "util"; @import "responsive";
@import "shared"; @import "bookmark-page";
@import "bookmarks"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "auth";
// Dark theme overrides /* Dark theme overrides */
@import "dark";
// Buttons
.btn.btn-primary {
background: $dt-primary-button-color;
border-color: darken($dt-primary-button-color, 5%);
&:hover, &:active, &:focus {
background: darken($dt-primary-button-color, 5%);
border-color: darken($dt-primary-button-color, 10%);
}
}
// Focus ring
a:focus, .btn:focus {
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
}
// Forms
.form-input:not(:placeholder-shown):invalid,
.form-input:not(:placeholder-shown):invalid:focus,
.has-error .form-input,
.form-input.is-error,
.has-error .form-select,
.form-select.is-error {
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;
}

View File

@@ -2,13 +2,11 @@
@import "variables-light"; @import "variables-light";
// Import Spectre CSS lib // Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre"; @import "spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import style modules // Import style modules
@import "base"; @import "base";
@import "util"; @import "responsive";
@import "shared"; @import "bookmark-page";
@import "bookmarks"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "auth";

View File

@@ -1,21 +0,0 @@
.spacer {
flex: 1 1 0;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text-sm {
font-size: 0.7rem;
}
.text-gray-dark {
color: $gray-color-dark;
}
.align-baseline {
align-items: baseline;
}

View File

@@ -4,25 +4,26 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns" <div class="bookmarks-page grid columns-md-1"
ld-bulk-edit ld-bulk-edit
ld-bookmark-page ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}" bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}"> tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area column col-8 col-md-12"> <section class="content-area col-2">
<div class="content-area-header"> <div class="content-area-header mb-0">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="spacer"></div> <div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %} {% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div class="bookmark-list-container"> <div class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
@@ -31,7 +32,7 @@
</section> </section>
{# Tag cloud #} {# Tag cloud #}
<section class="content-area column col-4 hide-md"> <section class="content-area col-1 hide-md">
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>

View File

@@ -5,125 +5,117 @@
{% if bookmark_list.is_empty %} {% if bookmark_list.is_empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"> <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
{% for bookmark in bookmark_list.bookmarks_page %} data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
<li ld-bookmark-item> {% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<label ld-bulk-edit-checkbox class="form-checkbox"> <label ld-bulk-edit-checkbox class="form-checkbox">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}"> <input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
<div class="title"> <div class="title">
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
class="{% if bookmark.unread %}text-italic{% endif %}"> {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
{% if bookmark.favicon_file and bookmark_list.show_favicons %} <img src="{% static bookmark_item.favicon_file %}" alt="">
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %} {% endif %}
{{ bookmark.resolved_title }} {{ bookmark_item.title }}
</a> </a>
</div> </div>
{% if bookmark_list.show_url %} {% if bookmark_list.show_url %}
<div class="url-path truncate"> <div class="url-path truncate">
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
class="url-display text-sm"> class="url-display text-sm">
{{ bookmark.url }} {{ bookmark_item.url }}
</a> </a>
</div> </div>
{% endif %} {% endif %}
<div class="description truncate"> <div class="description truncate">
{% if bookmark.tag_names %} {% if bookmark_item.tag_names %}
<span> <span>
{% for tag_name in bookmark.tag_names %} {% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %} {% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark.resolved_description %} {% if bookmark_item.description %}
<span>{{ bookmark.resolved_description }}</span> <span>{{ bookmark_item.description }}</span>
{% endif %} {% endif %}
</div> </div>
{% if bookmark.notes %} {% if bookmark_item.notes %}
<div class="notes bg-gray text-gray-dark"> <div class="notes bg-gray text-gray-dark">
<div class="notes-content"> <div class="notes-content">
{% markdown bookmark.notes %} {% markdown bookmark_item.notes %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="actions text-gray text-sm"> <div class="actions text-gray text-sm">
{% if bookmark_list.date_display == 'relative' %} {% if bookmark_item.display_date %}
<span> {% if bookmark_item.web_archive_snapshot_url %}
{% if bookmark.web_archive_snapshot_url %} <a href="{{ bookmark_item.web_archive_snapshot_url }}"
<a href="{{ bookmark.web_archive_snapshot_url }}" title="Show snapshot on the Internet Archive Wayback Machine"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ bookmark_list.link_target }}"
target="{{ bookmark_list.link_target }}" rel="noopener">
rel="noopener"> {{ bookmark_item.display_date }} ∞
{% endif %} </a>
<span>{{ bookmark.date_added|humanize_relative_date }}</span> {% else %}
{% if bookmark.web_archive_snapshot_url %} <span>{{ bookmark_item.display_date }}</span>
{% endif %}
</a>
{% endif %}
</span>
<span class="separator">|</span> <span class="separator">|</span>
{% endif %} {% endif %}
{% if bookmark_list.date_display == 'absolute' %} {% if bookmark_item.is_editable %}
<span>
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
<span class="separator">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #} {# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ bookmark_list.return_url }}">Edit</a> <a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
{% if bookmark.is_archived %} {% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}" <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive class="btn btn-link btn-sm">Unarchive
</button> </button>
{% else %} {% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}" <button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive class="btn btn-link btn-sm">Archive
</button> </button>
{% endif %} {% endif %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}" <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove class="btn btn-link btn-sm">Remove
</button> </button>
{% if bookmark.unread %}
<span class="separator">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read
</button>
{% endif %}
{% else %} {% else %}
{# Shared bookmark actions #} {# Shared bookmark actions #}
<span>Shared by <span>Shared by
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a> <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span> </span>
{% endif %} {% endif %}
{% if bookmark.notes and not bookmark_list.show_notes %} {% if bookmark_item.has_extra_actions %}
<span class="separator">|</span> <div class="extra-actions">
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes"> <span class="separator hide-sm">|</span>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" {% if bookmark_item.show_mark_as_read %}
height="16" <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" class="btn btn-link btn-sm btn-icon"
stroke-linejoin="round"> ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path> <use xlink:href="#ld-icon-unread"></use>
<path d="M9 7l6 0"></path> </svg>
<path d="M9 11l6 0"></path> Unread
<path d="M9 15l4 0"></path> </button>
</svg> {% endif %}
<span>Notes</span> {% if bookmark_item.show_unshare %}
</button> <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
</li> </li>

View File

@@ -3,32 +3,37 @@
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray"> <div class="bulk-edit-actions bg-gray">
<label ld-bulk-edit-checkbox all class="form-checkbox"> <label ld-bulk-edit-checkbox all class="form-checkbox">
<input type="checkbox" style="display: none"> <input type="checkbox">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
{% if mode == 'archive' %} <select name="bulk_action" class="form-select select-sm">
<button ld-confirm-button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm" {% if not 'bulk_archive' in disable_actions %}
title="Unarchive selected bookmarks">Unarchive <option value="bulk_archive">Archive</option>
</button> {% endif %}
{% else %} {% if not 'bulk_unarchive' in disable_actions %}
<button ld-confirm-button type="submit" name="bulk_archive" class="btn btn-link btn-sm" <option value="bulk_unarchive">Unarchive</option>
title="Archive selected bookmarks">Archive {% endif %}
</button> <option value="bulk_delete">Delete</option>
{% endif %} <option value="bulk_tag">Add tags</option>
<span class="text-sm text-gray-dark"></span> <option value="bulk_untag">Remove tags</option>
<button ld-confirm-button type="submit" name="bulk_delete" class="btn btn-link btn-sm" <option value="bulk_read">Mark as read</option>
title="Delete selected bookmarks">Delete <option value="bulk_unread">Mark as unread</option>
</button> {% if request.user_profile.enable_sharing %}
<span class="text-sm text-gray-dark"></span> <option value="bulk_share">Share</option>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span> <option value="bulk_unshare">Unshare</option>
<input ld-tag-autocomplete variant="small" {% endif %}
name="bulk_tag_string" class="form-input input-sm" placeholder="&nbsp;"> </select>
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm" <div class="tag-autocomplete d-none">
title="Add tags to selected bookmarks">Add <input ld-tag-autocomplete variant="small"
</button> name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm" </div>
title="Remove tags from selected bookmarks">Remove <button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
</button>
<label class="form-checkbox select-across d-none">
<input type="checkbox" name="bulk_select_across">
<i class="form-icon"></i>
All pages (<span class="total">{{ bookmark_list.bookmarks_total }}</span> bookmarks)
</label>
</div> </div>
</div> </div>
{% endhtmlmin %} {% endhtmlmin %}

View File

@@ -2,15 +2,13 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="columns"> <section class="content-area">
<section class="content-area column col-12"> <div class="content-area-header">
<div class="content-area-header"> <h2>Edit bookmark</h2>
<h2>Edit bookmark</h2> </div>
</div> <form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" class="width-50 width-md-100" novalidate>
class="col-6 col-md-12" novalidate> {% bookmark_form form return_url bookmark_id %}
{% bookmark_form form return_url bookmark_id %} </form>
</form> </section>
</section>
</div>
{% endblock %} {% endblock %}

View File

@@ -4,25 +4,26 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns" <div class="bookmarks-page grid columns-md-1"
ld-bulk-edit ld-bulk-edit
ld-bookmark-page ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}" bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}"> tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area column col-8 col-md-12"> <section class="content-area col-2">
<div class="content-area-header"> <div class="content-area-header mb-0">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="spacer"></div> <div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags %} {% bookmark_search bookmark_list.filters tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
method="post"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div class="bookmark-list-container"> <div class="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %} {% include 'bookmarks/bookmark_list.html' %}
@@ -31,8 +32,8 @@
</section> </section>
{# Tag cloud #} {# Tag cloud #}
<section class="content-area column col-4 hide-md"> <section class="content-area col-1 hide-md">
<div class="content-area-header"> <div class="content-area-header mb-4">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
<div class="tag-cloud-container"> <div class="tag-cloud-container">

View File

@@ -30,9 +30,69 @@
{% endif %} {% endif %}
</head> </head>
<body ld-global-shortcuts> <body ld-global-shortcuts>
<header>
<div class="d-none">
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unread" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6l0 13"></path>
<path d="M12 6l0 13"></path>
<path d="M21 6l0 13"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
<path d="M3 6v13"></path>
<path d="M12 6v2m0 4v7"></path>
<path d="M21 6v11"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M8.7 10.7l6.6 -3.4"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unshare" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</symbol>
</svg>
</div>
<header class="container">
{% if has_toasts %} {% if has_toasts %}
<div class="toasts container grid-lg"> <div class="toasts">
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post"> <form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %} {% csrf_token %}
{% for toast in toast_messages %} {% for toast in toast_messages %}
@@ -44,27 +104,21 @@
</form> </form>
</div> </div>
{% endif %} {% endif %}
<div class="navbar container grid-lg"> <div class="d-flex justify-between">
<section class="navbar-section"> <a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold"> <img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo"> <h1>LINKDING</h1>
<h1>linkding</h1> </a>
</a>
</section>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #} {# Only show nav items menu when logged in #}
<section class="navbar-section"> {% include 'bookmarks/nav_menu.html' %}
{% include 'bookmarks/nav_menu.html' %}
</section>
{% elif has_public_shares %} {% elif has_public_shares %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #} {# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
<section class="navbar-section"> <a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
</section>
{% endif %} {% endif %}
</div> </div>
</header> </header>
<div class="content container grid-lg"> <div class="content container">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -2,14 +2,12 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="columns"> <section class="content-area">
<section class="content-area column col-12"> <div class="content-area-header">
<div class="content-area-header"> <h2>New bookmark</h2>
<h2>New bookmark</h2> </div>
</div> <form action="{% url 'bookmarks:new' %}" method="post" class="width-50 width-md-100" novalidate>
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate> {% bookmark_form form return_url auto_close=auto_close %}
{% bookmark_form form return_url auto_close=auto_close %} </form>
</form> </section>
</section>
</div>
{% endblock %} {% endblock %}

View File

@@ -34,6 +34,7 @@
value: '{{ filters.query }}', value: '{{ filters.query }}',
tags: uniqueTags, tags: uniqueTags,
mode: '{{ mode }}', mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
apiClient, apiClient,
filters, filters,
} }

View File

@@ -4,20 +4,19 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns" <div class="bookmarks-page grid columns-md-1"
ld-bookmark-page ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}" bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}"> tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
{# Bookmark list #} {# Bookmark list #}
<section class="content-area column col-8 col-md-12"> <section class="content-area col-2">
<div class="content-area-header"> <div class="content-area-header">
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="spacer"></div>
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %} {% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
@@ -28,7 +27,7 @@
</section> </section>
{# Filters #} {# Filters #}
<section class="content-area column col-4 hide-md"> <section class="content-area col-1 hide-md">
<div class="content-area-header"> <div class="content-area-header">
<h2>User</h2> <h2>User</h2>
</div> </div>

View File

@@ -4,13 +4,5 @@
{% block title %}Registration complete{% endblock %} {% block title %}Registration complete{% endblock %}
{% block content %} {% block content %}
<p>Registration complete. You can now use the application.</p>
<div class="auth-page">
<div class="columns">
<section class="content-area column col-12">
<p>Registration complete. You can now use the application.</p>
</section>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -4,41 +4,35 @@
{% block title %}Registration{% endblock %} {% block title %}Registration{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100">
<div class="auth-page"> <div class="content-area-header">
<div class="columns"> <h2>Register</h2>
<section class="content-area column col-5 col-md-12">
<div class="content-area-header">
<h2>Register</h2>
</div>
<form method="post" action="{% url 'django_registration_register' %}">
{% csrf_token %}
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input' }}
<div class="form-input-hint">{{ form.errors.username }}</div>
</div>
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email|add_class:'form-input' }}
<div class="form-input-hint">{{ form.errors.email }}</div>
</div>
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1|add_class:'form-input' }}
<div class="form-input-hint">{{ form.errors.password1 }}</div>
</div>
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
{{ form.password2|add_class:'form-input' }}
<div class="form-input-hint">{{ form.errors.password2 }}</div>
</div>
<br/>
<input type="submit" value="Register" class="btn btn-primary col-md-12">
<input type="hidden" name="next" value="{{ next }}">
</form>
</section>
</div>
</div> </div>
<form method="post" action="{% url 'django_registration_register' %}" novalidate>
{% csrf_token %}
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
<div class="form-input-hint">{{ form.errors.username }}</div>
</div>
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email|add_class:'form-input'|attr:"placeholder: " }}
<div class="form-input-hint">{{ form.errors.email }}</div>
</div>
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1|add_class:'form-input'|attr:"placeholder: " }}
<div class="form-input-hint">{{ form.errors.password1 }}</div>
</div>
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
{{ form.password2|add_class:'form-input'|attr:"placeholder: " }}
<div class="form-input-hint">{{ form.errors.password2 }}</div>
</div>
<br/>
<input type="submit" value="Register" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}">
</form>
</section>
{% endblock %} {% endblock %}

View File

@@ -4,46 +4,35 @@
{% block title %}Login{% endblock %} {% block title %}Login{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100">
<div class="auth-page"> <div class="content-area-header">
<div class="columns"> <h2>Login</h2>
<section class="content-area column col-5 col-md-12">
<div class="content-area-header">
<h2>Login</h2>
</div>
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{% if form.errors %}
<div class="form-group has-error">
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
</div>
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
</div>
<br/>
<div class="columns">
<div class="column col-3">
<input type="submit" value="Login" class="btn btn-primary">
<input type="hidden" name="next" value="{{ next }}">
</div>
{% if allow_registration %}
<div class="column col-auto col-ml-auto">
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
</div>
{% endif %}
</div>
</form>
</section>
</div>
</div> </div>
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{% if form.errors %}
<div class="form-group has-error">
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
</div>
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
</div>
<br/>
<div class="d-flex justify-between">
<input type="submit" value="Login" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}">
{% if allow_registration %}
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
{% endif %}
</div>
</form>
</section>
{% endblock %} {% endblock %}

View File

@@ -4,18 +4,12 @@
{% block title %}Password changed{% endblock %} {% block title %}Password changed{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100">
<div class="auth-page"> <div class="content-area-header">
<div class="columns"> <h2>Password Changed</h2>
<section class="content-area column col-5 col-md-12">
<div class="content-area-header">
<h2>Password Changed</h2>
</div>
<p class="text-success">
Your password was changed successfully.
</p>
</section>
</div>
</div> </div>
<p class="text-success">
Your password was changed successfully.
</p>
</section>
{% endblock %} {% endblock %}

View File

@@ -4,52 +4,42 @@
{% block title %}Change Password{% endblock %} {% block title %}Change Password{% endblock %}
{% block content %} {% block content %}
<section class="content-area mx-auto width-50 width-md-100">
<div class="auth-page"> <div class="content-area-header">
<div class="columns"> <h2>Change Password</h2>
<section class="content-area column col-5 col-md-12">
<div class="content-area-header">
<h2>Change Password</h2>
</div>
<form method="post" action="{% url 'change_password' %}">
{% csrf_token %}
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
{% if form.old_password.errors %}
<div class="form-input-hint">
{{ form.old_password.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password1.errors %}
<div class="form-input-hint">
{{ form.new_password1.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password2.errors %}
<div class="form-input-hint">
{{ form.new_password2.errors }}
</div>
{% endif %}
</div>
<br/>
<div class="columns">
<div class="column col-3">
<input type="submit" value="Change Password" class="btn btn-primary">
</div>
</div>
</form>
</section>
</div>
</div> </div>
<form method="post" action="{% url 'change_password' %}">
{% csrf_token %}
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
{% if form.old_password.errors %}
<div class="form-input-hint">
{{ form.old_password.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password1.errors %}
<div class="form-input-hint">
{{ form.new_password1.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password2.errors %}
<div class="form-input-hint">
{{ form.new_password2.errors }}
</div>
{% endif %}
</div>
<br/>
<input type="submit" value="Change Password" class="btn btn-primary btn-wide">
</form>
</section>
{% endblock %} {% endblock %}

View File

@@ -16,14 +16,14 @@
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label> <label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }} {{ form.theme|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings. Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label> <label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }} {{ form.bookmark_date_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can
be hidden. be hidden.
@@ -50,14 +50,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label> <label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }} {{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Whether to open bookmarks a new page or in the same page. Whether to open bookmarks a new page or in the same page.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label> <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }} {{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
In strict mode, tags must be prefixed with a hash character (#). In strict mode, tags must be prefixed with a hash character (#).
In lax mode, tags can also be searched without the hash character. In lax mode, tags can also be searched without the hash character.
@@ -92,7 +92,7 @@
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label> integration</label>
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }} {{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a Enabling this feature will automatically create snapshots of bookmarked websites on the <a
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
@@ -155,9 +155,9 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-group col-8 col-md-12"> <div class="input-group width-75 width-md-100">
<input class="form-input" type="file" name="import_file"> <input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload"> <input type="submit" class="input-group-btn btn btn-primary" value="Upload">
</div> </div>
{% if import_success_message %} {% if import_success_message %}
<div class="has-success"> <div class="has-success">

View File

@@ -33,7 +33,7 @@
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p> <p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group"> <div class="form-group">
<div class="columns"> <div class="columns">
<div class="column col-6 col-md-12"> <div class="column width-50 width-md-100">
<input class="form-input" value="{{ api_token }}" readonly> <input class="form-input" value="{{ api_token }}" readonly>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,7 @@ def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str =
tag_names = [tag.name for tag in tags] tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ') tags_string = build_tag_string(tag_names, ' ')
return { return {
'request': context['request'],
'filters': filters, 'filters': filters,
'tags_string': tags_string, 'tags_string': tags_string,
'mode': mode, 'mode': mode,

View File

@@ -100,11 +100,12 @@ class BookmarkFactoryMixin:
for i in range(1, count + 1): for i in range(1, count + 1):
title = f'{prefix} {i}{suffix}' title = f'{prefix} {i}{suffix}'
url = f'https://example.com/{prefix}/{i}'
tags = [] tags = []
if with_tags: if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}' tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)] tags = [self.setup_tag(name=tag_name)]
self.setup_bookmark(title=title, is_archived=archived, shared=shared, tags=tags, user=user) self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags, user=user)
def get_numbered_bookmark(self, title: str): def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title) return Bookmark.objects.get(title=title)
@@ -251,3 +252,8 @@ def disable_logging(f):
return result return result
return wrapper return wrapper
def collapse_whitespace(text: str):
text = text.replace('\n', '').replace('\r', '')
return ' '.join(text.split())

View File

@@ -22,7 +22,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_archive_should_archive_bookmark(self): def test_archive_should_archive_bookmark(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'archive': [bookmark.id], 'archive': [bookmark.id],
}) })
@@ -34,7 +34,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'archive': [bookmark.id], 'archive': [bookmark.id],
}) })
@@ -46,7 +46,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_unarchive_should_unarchive_bookmark(self): def test_unarchive_should_unarchive_bookmark(self):
bookmark = self.setup_bookmark(is_archived=True) bookmark = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'unarchive': [bookmark.id], 'unarchive': [bookmark.id],
}) })
bookmark.refresh_from_db() bookmark.refresh_from_db()
@@ -57,7 +57,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(is_archived=True, user=other_user) bookmark = self.setup_bookmark(is_archived=True, user=other_user)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'unarchive': [bookmark.id], 'unarchive': [bookmark.id],
}) })
bookmark.refresh_from_db() bookmark.refresh_from_db()
@@ -68,7 +68,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_delete_should_delete_bookmark(self): def test_delete_should_delete_bookmark(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'remove': [bookmark.id], 'remove': [bookmark.id],
}) })
@@ -78,7 +78,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'remove': [bookmark.id], 'remove': [bookmark.id],
}) })
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@@ -87,20 +87,45 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def test_mark_as_read(self): def test_mark_as_read(self):
bookmark = self.setup_bookmark(unread=True) bookmark = self.setup_bookmark(unread=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'mark_as_read': [bookmark.id], 'mark_as_read': [bookmark.id],
}) })
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(bookmark.unread) self.assertFalse(bookmark.unread)
def test_unshare_should_unshare_bookmark(self):
bookmark = self.setup_bookmark(shared=True)
self.client.post(reverse('bookmarks:index.action'), {
'unshare': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertFalse(bookmark.shared)
def test_can_only_unshare_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user, shared=True)
response = self.client.post(reverse('bookmarks:index.action'), {
'unshare': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.shared)
def test_bulk_archive(self): def test_bulk_archive(self):
bookmark1 = self.setup_bookmark() bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -114,8 +139,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(user=other_user) bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user) bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -128,8 +154,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True) bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True) bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:archived.action'), {
'bulk_unarchive': [''], 'bulk_action': ['bulk_unarchive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -143,8 +170,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user) bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user) bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:archived.action'), {
'bulk_unarchive': [''], 'bulk_action': ['bulk_unarchive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -157,8 +185,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_delete': [''], 'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -172,8 +201,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(user=other_user) bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user) bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_delete': [''], 'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -188,8 +218,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_tag': [''], 'bulk_action': ['bulk_tag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -210,8 +241,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_tag': [''], 'bulk_action': ['bulk_tag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -231,8 +263,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2]) bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2]) bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_untag': [''], 'bulk_action': ['bulk_untag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -253,8 +286,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user) bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user) bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bulk_untag': [''], 'bulk_action': ['bulk_untag'],
'bulk_execute': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'], 'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -267,18 +301,240 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_bulk_mark_as_read(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_read'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_can_only_bulk_mark_as_read_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=True, user=other_user)
bookmark2 = self.setup_bookmark(unread=True, user=other_user)
bookmark3 = self.setup_bookmark(unread=True, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_read'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_bulk_mark_as_unread(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unread'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_can_only_bulk_mark_as_unread_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=False, user=other_user)
bookmark2 = self.setup_bookmark(unread=False, user=other_user)
bookmark3 = self.setup_bookmark(unread=False, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unread'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_bulk_share(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_share'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_can_only_bulk_share_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=False, user=other_user)
bookmark2 = self.setup_bookmark(shared=False, user=other_user)
bookmark3 = self.setup_bookmark(shared=False, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_share'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_bulk_unshare(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unshare'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_can_only_bulk_unshare_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=True, user=other_user)
bookmark2 = self.setup_bookmark(shared=True, user=other_user)
bookmark3 = self.setup_bookmark(shared=True, user=other_user)
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_unshare'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_bulk_select_across(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
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_select_across_respects_query(self):
self.setup_numbered_bookmarks(3, prefix='foo')
self.setup_numbered_bookmarks(3, prefix='bar')
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
def test_bulk_select_across_ignores_page(self):
self.setup_numbered_bookmarks(100)
self.client.post(reverse('bookmarks:index.action') + '?page=2', {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(0, Bookmark.objects.count())
def setup_bulk_edit_scope_test_data(self):
# create a number of bookmarks with different states / visibility
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 test_index_action_bulk_select_across_only_affects_active_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 1').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 2').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 3').first())
self.client.post(reverse('bookmarks:index.action'), {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(6, Bookmark.objects.count())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 1').first())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 2').first())
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 3').first())
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
self.client.post(reverse('bookmarks:archived.action'), {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(6, Bookmark.objects.count())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data()
response = self.client.post(reverse('bookmarks:shared.action'), {
'bulk_action': ['bulk_delete'],
'bulk_execute': [''],
'bulk_select_across': ['on'],
})
self.assertEqual(response.status_code, 400)
def test_handles_empty_bookmark_id(self): def test_handles_empty_bookmark_id(self):
bookmark1 = self.setup_bookmark() bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
response = self.client.post(reverse('bookmarks:action'), { response = self.client.post(reverse('bookmarks:index.action'), {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [], 'bookmark_id': [],
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -290,7 +546,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), { self.client.post(reverse('bookmarks:index.action'), {
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -301,9 +557,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index') url = reverse('bookmarks:index.action') + '?return_url=' + reverse('bookmarks:settings.index')
response = self.client.post(url, { response = self.client.post(url, {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}) })
@@ -315,9 +572,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark3 = self.setup_bookmark() bookmark3 = self.setup_bookmark()
def post_with(return_url, follow=None): def post_with(return_url, follow=None):
url = reverse('bookmarks:action') + f'?return_url={return_url}' url = reverse('bookmarks:index.action') + f'?return_url={return_url}'
return self.client.post(url, { return self.client.post(url, {
'bulk_archive': [''], 'bulk_action': ['bulk_archive'],
'bulk_execute': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)], 'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}, follow=follow) }, follow=follow)

View File

@@ -5,7 +5,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@@ -20,7 +20,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>', f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html html
) )
@@ -29,7 +29,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>', f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html, html,
count=0 count=0
) )
@@ -68,8 +68,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
] ]
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -86,8 +88,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
] ]
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue') response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -214,3 +218,41 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_allowed_bulk_actions(self):
url = reverse('bookmarks:archived')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
</select>
''', html)
def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse('bookmarks:archived')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_unarchive">Unarchive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
</select>
''', html)

View File

@@ -6,7 +6,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
@@ -21,7 +21,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>', f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html html
) )
@@ -30,7 +30,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>', f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html, html,
count=0 count=0
) )
@@ -69,8 +69,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
] ]
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -87,8 +89,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
] ]
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue') response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -240,3 +244,41 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}">Edit</a> <a href="{edit_url}?return_url={return_url}">Edit</a>
''', html) ''', html)
def test_allowed_bulk_actions(self):
url = reverse('bookmarks:index')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
</select>
''', html)
def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse('bookmarks:index')
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(f'''
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
</select>
''', html)

View File

@@ -5,7 +5,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -16,13 +16,13 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'): def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
self.assertInHTML( self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>', f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html, count=count html, count=count
) )
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item class="shared">', count=len(bookmarks))
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertBookmarkCount(html, bookmark, 1, link_target) self.assertBookmarkCount(html, bookmark, 1, link_target)
@@ -84,8 +84,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
] ]
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -124,8 +126,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
] ]
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue') response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -145,8 +149,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
] ]
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
html = collapse_whitespace(response.content.decode())
self.assertContains(response, '<ul class="bookmark-list">') # Should render list # Should render list
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
self.assertVisibleBookmarks(response, visible_bookmarks) self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks) self.assertInvisibleBookmarks(response, invisible_bookmarks)

View File

@@ -28,7 +28,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@@ -41,4 +41,4 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks + num_additional_bookmarks)

View File

@@ -10,21 +10,19 @@ from django.utils import timezone, formats
from bookmarks.middlewares import UserProfileMiddleware from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Bookmark, UserProfile, User from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin): class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'): def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
unread = bookmark.unread
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else '' favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
self.assertInHTML( self.assertInHTML(
f''' f'''
<a href="{bookmark.url}" <a href="{bookmark.url}"
target="{link_target}" target="{link_target}"
rel="noopener" rel="noopener">
class="{'text-italic' if unread else ''}">
{favicon_img} {favicon_img}
{bookmark.resolved_title} {bookmark.resolved_title}
</a> </a>
@@ -34,21 +32,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertDateLabel(self, html: str, label_content: str): def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f''' self.assertInHTML(f'''
<span> <span>{label_content}</span>
<span>{label_content}</span>
</span>
<span class="separator">|</span> <span class="separator">|</span>
''', html) ''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'): def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f''' self.assertInHTML(f'''
<span> <a href="{url}"
<a href="{url}" title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener"> {label_content}
<span>{label_content}</span> </a>
</a>
</span>
<span class="separator">|</span> <span class="separator">|</span>
''', html) ''', html)
@@ -126,18 +119,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertNotesToggle(self, html: str, count=1): def assertNotesToggle(self, html: str, count=1):
self.assertInHTML(f''' self.assertInHTML(f'''
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes"> <button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" <use xlink:href="#ld-icon-note"></use>
stroke-linejoin="round"> </svg>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> Notes
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path> </button>
<path d="M9 7l6 0"></path> ''', html, count=count)
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path> def assertUnshareButton(self, html: str, bookmark: Bookmark, count=1):
</svg> self.assertInHTML(f'''
<span>Notes</span> <button type="submit" name="unshare" value="{bookmark.id}"
</button> class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
''', html, count=count)
def assertMarkAsReadButton(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
Unread
</button>
''', html, count=count) ''', html, count=count)
def render_template(self, def render_template(self,
@@ -236,11 +247,31 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
def test_should_respect_unread_flag(self): def test_should_reflect_unread_state_as_css_class(self):
bookmark = self.setup_bookmark(unread=True) self.setup_bookmark(unread=True)
html = self.render_template() html = self.render_template()
self.assertBookmarksLink(html, bookmark) self.assertIn('<li ld-bookmark-item class="unread">', html)
def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
self.setup_bookmark(shared=True)
html = self.render_template()
self.assertIn('<li ld-bookmark-item class="shared">', html)
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
self.setup_bookmark(unread=True, shared=True)
html = self.render_template()
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
def test_show_bookmark_actions_for_owned_bookmarks(self): def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -333,6 +364,66 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertBookmarkURLHidden(html, bookmark) self.assertBookmarkURLHidden(html, bookmark)
def test_show_mark_as_read_when_unread(self):
bookmark = self.setup_bookmark(unread=True)
html = self.render_template()
self.assertMarkAsReadButton(html, bookmark)
def test_hide_mark_as_read_when_read(self):
bookmark = self.setup_bookmark(unread=False)
html = self.render_template()
self.assertMarkAsReadButton(html, bookmark, count=0)
def test_hide_mark_as_read_for_non_owned_bookmarks(self):
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True, unread=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertBookmarksLink(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
def test_show_unshare_button_when_shared(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=True)
html = self.render_template()
self.assertUnshareButton(html, bookmark)
def test_hide_unshare_button_when_not_shared(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=False)
html = self.render_template()
self.assertUnshareButton(html, bookmark, count=0)
def test_hide_unshare_button_when_sharing_is_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = False
profile.save()
bookmark = self.setup_bookmark(shared=True)
html = self.render_template()
self.assertUnshareButton(html, bookmark, count=0)
def test_hide_unshare_for_non_owned_bookmarks(self):
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertBookmarksLink(html, bookmark)
self.assertUnshareButton(html, bookmark, count=0)
def test_without_notes(self): def test_without_notes(self):
self.setup_bookmark() self.setup_bookmark()
html = self.render_template() html = self.render_template()
@@ -363,9 +454,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def test_notes_are_hidden_initially_by_default(self): def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_template() html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list">', html) self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self): def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -373,9 +464,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_template() html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list">', html) self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self): def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -383,9 +474,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_template() html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list show-notes">', html) self.assertIn('<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html)
def test_toggle_notes_is_visible_by_default(self): def test_toggle_notes_is_visible_by_default(self):
self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
@@ -425,6 +516,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark.notes = '**Example:** `print("Hello world!")`' bookmark.notes = '**Example:** `print("Hello world!")`'
bookmark.favicon_file = 'https_example_com.png' bookmark.favicon_file = 'https_example_com.png'
bookmark.shared = True bookmark.shared = True
bookmark.unread = True
bookmark.save() bookmark.save()
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser()) html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
@@ -432,6 +524,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
self.assertUnshareButton(html, bookmark, count=0)
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>' note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
self.assertFaviconVisible(html, bookmark) self.assertFaviconVisible(html, bookmark)

View File

@@ -8,7 +8,8 @@ from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -452,3 +453,183 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark1.tags.all(), []) self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), []) self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), []) self.assertCountEqual(bookmark3.tags.all(), [])
def test_mark_bookmarks_as_read(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_read_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_read_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
inaccessible_bookmark = self.setup_bookmark(unread=True, user=other_user)
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
def test_mark_bookmarks_as_read_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
inaccessible_bookmark = self.setup_bookmark(unread=False, user=other_user)
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
def test_mark_bookmarks_as_unread_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_share_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_share_bookmarks_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_share_bookmarks_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
inaccessible_bookmark = self.setup_bookmark(shared=False, user=other_user)
share_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
def test_share_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks_should_only_update_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks_should_only_update_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
inaccessible_bookmark = self.setup_bookmark(shared=True, user=other_user)
unshare_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
def test_unshare_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)

View File

@@ -30,7 +30,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
# Should render toasts container # Should render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">') self.assertContains(response, '<div class="toasts">')
# Should render two toasts # Should render two toasts
self.assertContains(response, '<div class="toast">', count=2) self.assertContains(response, '<div class="toast">', count=2)

View File

@@ -13,12 +13,14 @@ urlpatterns = [
re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)), re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
# Bookmarks # Bookmarks
path('bookmarks', views.bookmarks.index, name='index'), path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/action', views.bookmarks.index_action, name='index.action'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'), path('bookmarks/archived', views.bookmarks.archived, name='archived'),
path('bookmarks/archived/action', views.bookmarks.archived_action, name='archived.action'),
path('bookmarks/shared', views.bookmarks.shared, name='shared'), path('bookmarks/shared', views.bookmarks.shared, name='shared'),
path('bookmarks/shared/action', views.bookmarks.shared_action, name='shared.action'),
path('bookmarks/new', views.bookmarks.new, name='new'), path('bookmarks/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'), path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'), path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
path('bookmarks/action', views.bookmarks.action, name='action'),
# Partials # Partials
path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list, path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list,
name='partials.bookmark_list.active'), name='partials.bookmark_list.active'),

View File

@@ -1,12 +1,14 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, Http404 from django.db.models import QuerySet
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, 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, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -145,6 +147,16 @@ def unarchive(request, bookmark_id: int):
unarchive_bookmark(bookmark) unarchive_bookmark(bookmark)
def unshare(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404('Bookmark does not exist')
bookmark.shared = False
bookmark.save()
def mark_as_read(request, bookmark_id: int): def mark_as_read(request, bookmark_id: int):
try: try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user) bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
@@ -156,8 +168,26 @@ def mark_as_read(request, bookmark_id: int):
@login_required @login_required
def action(request): def index_action(request):
# Determine action filters = BookmarkFilters(request)
query = queries.query_bookmarks(request.user, request.user_profile, filters.query)
return action(request, query)
@login_required
def archived_action(request):
filters = BookmarkFilters(request)
query = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query)
return action(request, query)
@login_required
def shared_action(request):
return action(request)
def action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions
if 'archive' in request.POST: if 'archive' in request.POST:
archive(request, request.POST['archive']) archive(request, request.POST['archive'])
if 'unarchive' in request.POST: if 'unarchive' in request.POST:
@@ -166,23 +196,44 @@ def action(request):
remove(request, request.POST['remove']) remove(request, request.POST['remove'])
if 'mark_as_read' in request.POST: if 'mark_as_read' in request.POST:
mark_as_read(request, request.POST['mark_as_read']) mark_as_read(request, request.POST['mark_as_read'])
if 'bulk_archive' in request.POST: if 'unshare' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id') unshare(request, request.POST['unshare'])
archive_bookmarks(bookmark_ids, request.user)
if 'bulk_unarchive' in request.POST: # Bulk actions
bookmark_ids = request.POST.getlist('bookmark_id') if 'bulk_execute' in request.POST:
unarchive_bookmarks(bookmark_ids, request.user) if query is None:
if 'bulk_delete' in request.POST: return HttpResponseBadRequest('View does not support bulk actions')
bookmark_ids = request.POST.getlist('bookmark_id')
delete_bookmarks(bookmark_ids, request.user) bulk_action = request.POST['bulk_action']
if 'bulk_tag' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id') # Determine set of bookmarks
tag_string = convert_tag_string(request.POST['bulk_tag_string']) if request.POST.get('bulk_select_across') == 'on':
tag_bookmarks(bookmark_ids, tag_string, request.user) # Query full list of bookmarks across all pages
if 'bulk_untag' in request.POST: bookmark_ids = query.only('id').values_list('id', flat=True)
bookmark_ids = request.POST.getlist('bookmark_id') else:
tag_string = convert_tag_string(request.POST['bulk_tag_string']) # Use only selected bookmarks
untag_bookmarks(bookmark_ids, tag_string, request.user) bookmark_ids = request.POST.getlist('bookmark_id')
if 'bulk_archive' == bulk_action:
archive_bookmarks(bookmark_ids, request.user)
if 'bulk_unarchive' == bulk_action:
unarchive_bookmarks(bookmark_ids, request.user)
if 'bulk_delete' == bulk_action:
delete_bookmarks(bookmark_ids, request.user)
if 'bulk_tag' == bulk_action:
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
tag_bookmarks(bookmark_ids, tag_string, request.user)
if 'bulk_untag' == bulk_action:
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
untag_bookmarks(bookmark_ids, tag_string, request.user)
if 'bulk_read' == bulk_action:
mark_bookmarks_as_read(bookmark_ids, request.user)
if 'bulk_unread' == bulk_action:
mark_bookmarks_as_unread(bookmark_ids, request.user)
if 'bulk_share' == bulk_action:
share_bookmarks(bookmark_ids, request.user)
if 'bulk_unshare' == bulk_action:
unshare_bookmarks(bookmark_ids, request.user)
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index')) return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
return HttpResponseRedirect(return_url) return HttpResponseRedirect(return_url)

View File

@@ -7,17 +7,58 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import BookmarkFilters, User, UserProfile, Tag from bookmarks.models import Bookmark, BookmarkFilters, User, UserProfile, Tag
from bookmarks.utils import unique from bookmarks import utils
DEFAULT_PAGE_SIZE = 30 DEFAULT_PAGE_SIZE = 30
class BookmarkItem:
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
self.bookmark = bookmark
is_editable = bookmark.owner == user
self.is_editable = is_editable
self.id = bookmark.id
self.url = bookmark.url
self.title = bookmark.resolved_title
self.description = bookmark.resolved_description
self.notes = bookmark.notes
self.tag_names = bookmark.tag_names
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
self.favicon_file = bookmark.favicon_file
self.is_archived = bookmark.is_archived
self.unread = bookmark.unread
self.owner = bookmark.owner
css_classes = []
if bookmark.unread:
css_classes.append('unread')
if bookmark.shared:
css_classes.append('shared')
self.css_classes = ' '.join(css_classes)
if profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE:
self.display_date = utils.humanize_relative_date(bookmark.date_added)
elif profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE:
self.display_date = utils.humanize_absolute_date(bookmark.date_added)
self.show_notes_button = bookmark.notes and not profile.permanent_notes
self.show_mark_as_read = is_editable and bookmark.unread
self.show_unshare = is_editable and bookmark.shared and profile.enable_sharing
self.has_extra_actions = self.show_notes_button or self.show_mark_as_read or self.show_unshare
class BookmarkListContext: class BookmarkListContext:
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
self.request = request self.request = request
self.filters = BookmarkFilters(self.request) self.filters = BookmarkFilters(self.request)
user = request.user
user_profile = request.user_profile
query_set = self.get_bookmark_query_set() query_set = self.get_bookmark_query_set()
page_number = request.GET.get('page') page_number = request.GET.get('page')
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE) paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
@@ -25,14 +66,17 @@ class BookmarkListContext:
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates # Prefetch related objects, this avoids n+1 queries when accessing fields in templates
models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags') models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags')
self.items = [BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page]
self.is_empty = paginator.count == 0 self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count
self.return_url = self.generate_return_url(page_number) self.return_url = self.generate_return_url(page_number)
self.link_target = request.user_profile.bookmark_link_target self.link_target = user_profile.bookmark_link_target
self.date_display = request.user_profile.bookmark_date_display self.date_display = user_profile.bookmark_date_display
self.show_url = request.user_profile.display_url self.show_url = user_profile.display_url
self.show_favicons = request.user_profile.enable_favicons self.show_favicons = user_profile.enable_favicons
self.show_notes = request.user_profile.permanent_notes self.show_notes = user_profile.permanent_notes
def generate_return_url(self, page: int): def generate_return_url(self, page: int):
base_url = self.get_base_url() base_url = self.get_base_url()
@@ -120,8 +164,8 @@ class TagCloudContext:
query_set = self.get_tag_query_set() query_set = self.get_tag_query_set()
tags = list(query_set) tags = list(query_set)
selected_tags = self.get_selected_tags(tags) selected_tags = self.get_selected_tags(tags)
unique_tags = unique(tags, key=lambda x: str.lower(x.name)) unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name)) unique_selected_tags = utils.unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0 has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags) unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(unselected_tags) groups = TagGroup.create_tag_groups(unselected_tags)

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.20.1", "version": "1.21.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -24,6 +24,6 @@ sqlparse==0.4.4
supervisor==4.2.4 supervisor==4.2.4
typing-extensions==3.10.0.0 typing-extensions==3.10.0.0
urllib3==1.26.11 urllib3==1.26.11
uWSGI==2.0.20 uWSGI==2.0.22
waybackpy==3.0.6 waybackpy==3.0.6
webencodings==0.5.1 webencodings==0.5.1

View File

@@ -1 +1 @@
1.20.1 1.21.0