Avoid page reload when triggering actions in bookmark list (#506)

* Extract bookmark view contexts

* Implement basic partial updates for bookmark list and tag cloud

* Refactor confirm button JS into web component

* Refactor bulk edit JS into web component

* Refactor tag autocomplete JS into web component

* Refactor bookmark page JS into web component

* Refactor global shortcuts JS into web component

* Update tests

* Add E2E test for partial updates

* Add partial updates for archived bookmarks

* Add partial updates for shared bookmarks

* Cleanup helpers

* Improve naming in bulk edit

* Refactor shared components into behaviors

* Refactor bulk edit components into behaviors

* Refactor bookmark list components into behaviors

* Update tests

* Combine all scripts into bundle

* Fix E2E CI
This commit is contained in:
Sascha Ißbrücker 2023-08-21 23:12:00 +02:00 committed by GitHub
parent 8206705876
commit be789ea9e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1942 additions and 1388 deletions

View file

@ -41,6 +41,9 @@ jobs:
run: | run: |
pip install -r requirements.txt pip install -r requirements.txt
playwright install chromium playwright install chromium
- name: Run build
run: |
npm run build
python manage.py compilescss python manage.py compilescss
python manage.py collectstatic --ignore=*.scss python manage.py collectstatic --ignore=*.scss
- name: Run tests - name: Run tests

View file

@ -1,271 +0,0 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let filters;
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionFilters = {
...filters,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank')
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.search.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View file

@ -1,48 +0,0 @@
const SEARCH_HISTORY_KEY = 'searchHistory'
const MAX_ENTRIES = 30
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
return historyJson ? JSON.parse(historyJson) : {
recent: []
}
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('q');
if (!searchParam) return
this.push(searchParam)
}
push(search) {
const history = this.getHistory()
history.recent.unshift(search)
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc
if (acc.indexOf(cur) >= 0) return acc
acc.push(cur)
return acc
}, [])
const newHistoryJson = JSON.stringify(history)
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
}
getRecentSearches(query, max) {
const history = this.getHistory()
return history.recent
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
.slice(0, max)
}
}

View file

@ -1,168 +0,0 @@
<script>
import {getCurrentWord, getCurrentWordBounds} from "./util";
export let id;
export let name;
export let value;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 1000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag.name}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
}
.form-autocomplete.small .form-autocomplete-input input {
margin: 0;
padding: 0;
font-size: 0.7rem;
}
.form-autocomplete.small .menu .menu-item {
font-size: 0.7rem;
}
</style>

View file

@ -1,32 +0,0 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl
}
listBookmarks(filters, options = {limit: 100, offset: 0, path: ''}) {
const query = [
`limit=${options.limit}`,
`offset=${options.offset}`,
]
Object.keys(filters).forEach(key => {
const value = filters[key]
if (value) {
query.push(`${key}=${encodeURIComponent(value)}`)
}
})
const queryString = query.join('&')
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
getTags(options = {limit: 100, offset: 0}) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
}

View file

@ -1,10 +0,0 @@
import TagAutoComplete from './TagAutocomplete.svelte'
import SearchAutoComplete from './SearchAutoComplete.svelte'
import {ApiClient} from './api'
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete
}

View file

@ -1,37 +0,0 @@
export function debounce(callback, delay = 250) {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
callback(...args)
}, delay)
}
}
export function clampText(text, maxChars = 30) {
if(!text || text.length <= 30) return text
return text.substr(0, maxChars) + '...'
}
export function getCurrentWordBounds(input) {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== ' ' && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return {start, end};
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View file

@ -6,17 +6,15 @@ from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase from bookmarks.e2e.helpers import LinkdingE2ETestCase
@skip("Fails in CI, needs investigation") class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
class BookmarkListE2ETestCase(LinkdingE2ETestCase): @skip("Fails in CI, needs investigation")
def test_toggle_notes_should_show_hide_notes(self): def test_toggle_notes_should_show_hide_notes(self):
self.setup_bookmark(notes='Test notes') bookmark = self.setup_bookmark(notes='Test notes')
with sync_playwright() as p: with sync_playwright() as p:
browser = self.setup_browser(p) page = self.open(reverse('bookmarks:index'), p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
notes = page.locator('li .notes') notes = self.locate_bookmark(bookmark.title).locator('.notes')
expect(notes).to_be_hidden() expect(notes).to_be_hidden()
toggle_notes = page.locator('li button.toggle-notes') toggle_notes = page.locator('li button.toggle-notes')

View file

@ -0,0 +1,252 @@
from typing import List
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def setup_fixture(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
# create a number of bookmarks with different states / visibility to
# verify correct data is loaded on update
self.setup_numbered_bookmarks(3, with_tags=True)
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
self.setup_numbered_bookmarks(3,
shared=True,
prefix="Joe's Bookmark",
user=self.setup_user(enable_sharing=True))
def assertVisibleBookmarks(self, titles: List[str]):
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
expect(bookmark_tags).to_have_count(len(titles))
for title in titles:
matching_tag = bookmark_tags.filter(has_text=title)
expect(matching_tag).to_be_visible()
def assertVisibleTags(self, titles: List[str]):
tag_tags = self.page.locator('.tag-cloud .unselected-tags a')
expect(tag_tags).to_have_count(len(titles))
for title in titles:
matching_tag = tag_tags.filter(has_text=title)
expect(matching_tag).to_be_visible()
def test_partial_update_respects_query(self):
self.setup_numbered_bookmarks(5, prefix='foo')
self.setup_numbered_bookmarks(5, prefix='bar')
with sync_playwright() as p:
url = reverse('bookmarks:index') + '?q=foo'
self.open(url, p)
self.assertVisibleBookmarks(['foo 1', 'foo 2', 'foo 3', 'foo 4', 'foo 5'])
self.locate_bookmark('foo 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['foo 1', 'foo 3', 'foo 4', 'foo 5'])
def test_partial_update_respects_page(self):
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix='foo', suffix='-')
with sync_playwright() as p:
url = reverse('bookmarks:index') + '?q=foo&page=2'
self.open(url, p)
# with descending sort, page two has 'foo 1' to 'foo 20'
expected_titles = [f'foo {i}-' for i in range(1, 21)]
self.assertVisibleBookmarks(expected_titles)
self.locate_bookmark('foo 20-').get_by_text('Archive').click()
expected_titles = [f'foo {i}-' for i in range(1, 20)]
self.assertVisibleBookmarks(expected_titles)
def test_multiple_partial_updates(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse('bookmarks:index')
self.open(url, p)
self.locate_bookmark('Bookmark 1').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 2', 'Bookmark 3', 'Bookmark 4', 'Bookmark 5'])
self.locate_bookmark('Bookmark 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 3', 'Bookmark 4', 'Bookmark 5'])
self.locate_bookmark('Bookmark 3').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 4', 'Bookmark 5'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bookmark('Bookmark 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bookmark('Bookmark 2').get_by_text('Remove').click()
self.locate_bookmark('Bookmark 2').get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_mark_as_read(self):
self.setup_fixture()
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
bookmark2.unread = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic')
self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click()
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic')
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:index'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
self.assertVisibleTags(['Tag 1', 'Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bookmark('Archived Bookmark 2').get_by_text('Unarchive').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bookmark('Archived Bookmark 2').get_by_text('Remove').click()
self.locate_bookmark('Archived Bookmark 2').get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Archive').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse('bookmarks:archived'), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
self.locate_bulk_edit_bar().get_by_text('Delete').click()
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True)
with sync_playwright() as p:
self.open(reverse('bookmarks:shared'), p)
self.locate_bookmark('My Bookmark 2').get_by_text('Archive').click()
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
self.assertVisibleBookmarks([
'My Bookmark 1',
'My Bookmark 2',
'My Bookmark 3',
"Joe's Bookmark 1",
"Joe's Bookmark 2",
"Joe's Bookmark 3",
])
self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 2', 'Shared Tag 3'])
self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True)
with sync_playwright() as p:
self.open(reverse('bookmarks:shared'), p)
self.locate_bookmark('My Bookmark 2').get_by_text('Remove').click()
self.locate_bookmark('My Bookmark 2').get_by_text('Confirm').click()
self.assertVisibleBookmarks([
'My Bookmark 1',
'My Bookmark 3',
"Joe's Bookmark 1",
"Joe's Bookmark 2",
"Joe's Bookmark 3",
])
self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 3'])
self.assertReloads(0)

View file

@ -1,5 +1,5 @@
from django.contrib.staticfiles.testing import LiveServerTestCase from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext from playwright.sync_api import BrowserContext, Playwright, Page
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@ -19,3 +19,27 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
'path': '/' 'path': '/'
}]) }])
return context return context
def open(self, url: str, playwright: Playwright) -> Page:
browser = self.setup_browser(playwright)
self.page = browser.new_page()
self.page.goto(self.live_server_url + url)
self.page.on('load', self.on_load)
self.num_loads = 0
return self.page
def on_load(self):
self.num_loads += 1
def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count)
def locate_bookmark(self, title: str):
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
return bookmark_tags.filter(has_text=title)
def locate_bulk_edit_bar(self):
return self.page.locator('.bulk-edit-bar')
def locate_bulk_edit_toggle(self):
return self.page.get_by_title('Bulk edit')

29
bookmarks/frontend/api.js Normal file
View file

@ -0,0 +1,29 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
listBookmarks(filters, options = { limit: 100, offset: 0, path: "" }) {
const query = [`limit=${options.limit}`, `offset=${options.offset}`];
Object.keys(filters).forEach((key) => {
const value = filters[key];
if (value) {
query.push(`${key}=${encodeURIComponent(value)}`);
}
});
const queryString = query.join("&");
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`;
return fetch(url)
.then((response) => response.json())
.then((data) => data.results);
}
getTags(options = { limit: 100, offset: 0 }) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;
return fetch(url)
.then((response) => response.json())
.then((data) => data.results);
}
}

View file

@ -0,0 +1,65 @@
import { registerBehavior, swap } from "./index";
class BookmarkPage {
constructor(element) {
this.element = element;
this.form = element.querySelector("form.bookmark-actions");
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.form.action;
const formData = new FormData(this.form);
formData.append(event.submitter.name, event.submitter.value);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
await this.refresh();
}
async refresh() {
const query = window.location.search;
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
const tagsUrl = this.element.getAttribute("tags-url");
Promise.all([
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swap(this.bookmarkList, bookmarkListHtml);
swap(this.tagCloud, tagCloudHtml);
this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", { bubbles: true }),
);
});
}
}
registerBehavior("ld-bookmark-page", BookmarkPage);
class BookmarkItem {
constructor(element) {
this.element = element;
const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
}
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();
this.element.classList.toggle("show-notes");
}
}
registerBehavior("ld-bookmark-item", BookmarkItem);

View file

@ -0,0 +1,100 @@
import { registerBehavior } from "./index";
class BulkEdit {
constructor(element) {
this.element = element;
this.active = false;
element.addEventListener(
"bulk-edit-toggle-active",
this.onToggleActive.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-all",
this.onToggleAll.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-bookmark",
this.onToggleBookmark.bind(this),
);
element.addEventListener(
"bookmark-list-updated",
this.onListUpdated.bind(this),
);
}
get allCheckbox() {
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
}
get bookmarkCheckboxes() {
return [
...this.element.querySelectorAll(
"[ld-bulk-edit-checkbox]:not([all]) input",
),
];
}
onToggleActive() {
this.active = !this.active;
if (this.active) {
this.element.classList.add("active", "activating");
setTimeout(() => {
this.element.classList.remove("activating");
}, 500);
} else {
this.element.classList.remove("active");
}
}
onToggleBookmark() {
this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked;
});
}
onToggleAll() {
const checked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = checked;
});
}
onListUpdated() {
this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
}
}
class BulkEditActiveToggle {
constructor(element) {
this.element = element;
element.addEventListener("click", this.onClick.bind(this));
}
onClick() {
this.element.dispatchEvent(
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
);
}
}
class BulkEditCheckbox {
constructor(element) {
this.element = element;
element.addEventListener("change", this.onChange.bind(this));
}
onChange() {
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
this.element.dispatchEvent(
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
);
}
}
registerBehavior("ld-bulk-edit", BulkEdit);
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);

View file

@ -0,0 +1,50 @@
import { registerBehavior } from "./index";
class ConfirmButtonBehavior {
constructor(element) {
const button = element;
button.dataset.type = button.type;
button.dataset.name = button.name;
button.dataset.value = button.value;
button.removeAttribute("type");
button.removeAttribute("name");
button.removeAttribute("value");
button.addEventListener("click", this.onClick.bind(this));
this.button = button;
}
onClick(event) {
event.preventDefault();
const cancelButton = document.createElement(this.button.nodeName);
cancelButton.type = "button";
cancelButton.innerText = "Cancel";
cancelButton.className = "btn btn-link btn-sm mr-1";
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName);
confirmButton.type = this.button.dataset.type;
confirmButton.name = this.button.dataset.name;
confirmButton.value = this.button.dataset.value;
confirmButton.innerText = "Confirm";
confirmButton.className = "btn btn-link btn-sm";
confirmButton.addEventListener("click", this.reset.bind(this));
const container = document.createElement("span");
container.className = "confirmation";
container.append(cancelButton, confirmButton);
this.container = container;
this.button.before(container);
this.button.classList.add("d-none");
}
reset() {
setTimeout(() => {
this.container.remove();
this.button.classList.remove("d-none");
});
}
}
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View file

@ -0,0 +1,73 @@
import { registerBehavior } from "./index";
class GlobalShortcuts {
constructor() {
document.addEventListener("keydown", this.onKeyDown.bind(this));
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
// Handle shortcuts for navigating bookmarks with arrow keys
const isArrowUp = event.key === "ArrowUp";
const isArrowDown = event.key === "ArrowDown";
if (isArrowUp || isArrowDown) {
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
);
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector("[ld-bookmark-item]");
}
// Focus first link
if (nextItem) {
nextItem.querySelector("a").focus();
}
}
// Handle shortcut for toggling all notes
if (event.key === "e") {
const list = document.querySelector(".bookmark-list");
if (list) {
list.classList.toggle("show-notes");
}
}
// Handle shortcut for focusing search input
if (event.key === "s") {
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
}
// Handle shortcut for adding new bookmark
if (event.key === "n") {
window.location.assign("/bookmarks/new");
}
}
}
registerBehavior("ld-global-shortcuts", GlobalShortcuts);

View file

@ -0,0 +1,36 @@
const behaviorRegistry = {};
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
applyBehaviors(document, [name]);
}
export function applyBehaviors(container, behaviorNames = null) {
if (!behaviorNames) {
behaviorNames = Object.keys(behaviorRegistry);
}
behaviorNames.forEach((behaviorName) => {
const behavior = behaviorRegistry[behaviorName];
const elements = container.querySelectorAll(`[${behaviorName}]`);
elements.forEach((element) => {
element.__behaviors = element.__behaviors || [];
const hasBehavior = element.__behaviors.some(
(b) => b instanceof behavior,
);
if (hasBehavior) {
return;
}
const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance);
});
});
}
export function swap(element, html) {
element.innerHTML = html;
applyBehaviors(element);
}

View file

@ -0,0 +1,26 @@
import { registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api";
class TagAutocomplete {
constructor(element) {
const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl);
new TagAutoCompleteComponent({
target: wrapper,
props: {
id: element.id,
name: element.name,
value: element.value,
apiClient: apiClient,
variant: element.getAttribute("variant"),
},
});
element.replaceWith(wrapper);
}
}
registerBehavior("ld-tag-autocomplete", TagAutocomplete);

View file

@ -0,0 +1,272 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let filters;
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionFilters = {
...filters,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank')
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.search.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View file

@ -0,0 +1,52 @@
const SEARCH_HISTORY_KEY = "searchHistory";
const MAX_ENTRIES = 30;
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY);
return historyJson
? JSON.parse(historyJson)
: {
recent: [],
};
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return;
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get("q");
if (!searchParam) return;
this.push(searchParam);
}
push(search) {
const history = this.getHistory();
history.recent.unshift(search);
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc;
if (acc.indexOf(cur) >= 0) return acc;
acc.push(cur);
return acc;
}, []);
const newHistoryJson = JSON.stringify(history);
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson);
}
getRecentSearches(query, max) {
const history = this.getHistory();
return history.recent
.filter(
(search) =>
!query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0,
)
.slice(0, max);
}
}

View file

@ -0,0 +1,170 @@
<script>
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 1000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag.name}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
}
.form-autocomplete.small .form-autocomplete-input input {
margin: 0;
padding: 0;
font-size: 0.7rem;
}
.form-autocomplete.small .menu .menu-item {
font-size: 0.7rem;
}
</style>

View file

@ -0,0 +1,14 @@
import TagAutoComplete from "./components/TagAutocomplete.svelte";
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
import { ApiClient } from "./api";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete,
};

View file

@ -0,0 +1,37 @@
export function debounce(callback, delay = 250) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
callback(...args);
}, delay);
};
}
export function clampText(text, maxChars = 30) {
if (!text || text.length <= 30) return text;
return text.substr(0, maxChars) + "...";
}
export function getCurrentWordBounds(input) {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== " " && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return { start, end };
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View file

@ -1,171 +0,0 @@
(function () {
function allowBulkEdit() {
return !!document.getElementById('bulk-edit-mode');
}
function setupBulkEdit() {
if (!allowBulkEdit()) {
return;
}
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
function isAllSelected() {
let result = true
singleToggles.forEach(function (toggle) {
result = result && toggle.checked
})
return result
}
function selectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = true
})
}
function deselectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = false
})
}
// Toggle all
allToggle.addEventListener('change', function (e) {
if (e.target.checked) {
selectAll()
} else {
deselectAll()
}
})
// Toggle single
singleToggles.forEach(function (toggle) {
toggle.addEventListener('change', function () {
allToggle.checked = isAllSelected()
})
})
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
let bulkEditToggleTimeout
if (bulkEditToggle.checked) {
bulkEditBar.style.overflow = 'visible';
}
bulkEditToggle.addEventListener('change', function (e) {
if (bulkEditToggleTimeout) {
clearTimeout(bulkEditToggleTimeout);
bulkEditToggleTimeout = null;
}
if (e.target.checked) {
bulkEditToggleTimeout = setTimeout(function () {
bulkEditBar.style.overflow = 'visible';
}, 500);
} else {
bulkEditBar.style.overflow = 'hidden';
}
});
}
function setupBulkEditTagAutoComplete() {
if (!allowBulkEdit()) {
return;
}
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
const apiClient = new linkding.ApiClient(apiBaseUrl)
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: 'bulk-edit-tags-input',
name: tagInput.name,
value: tagInput.value,
apiClient: apiClient,
variant: 'small'
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
}
function setupListNavigation() {
// Add logic for navigating bookmarks with arrow keys
document.addEventListener('keydown', event => {
// Skip if event occurred within an input element
// or does not use arrow keys
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
const isArrowUp = event.key === 'ArrowUp';
const isArrowDown = event.key === 'ArrowDown';
if (isInputTarget || !(isArrowUp || isArrowDown)) {
return;
}
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(item => item.hasAttribute && item.hasAttribute('data-is-bookmark-item'));
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector('li[data-is-bookmark-item]');
}
// Focus first link
if (nextItem) {
nextItem.querySelector('a').focus();
}
});
}
function setupNotes() {
// Shortcut for toggling all notes
document.addEventListener('keydown', function(event) {
// Filter for shortcut key
if (event.key !== 'e') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
const list = document.querySelector('.bookmark-list');
list.classList.toggle('show-notes');
});
// Toggle notes for single bookmark
const bookmarks = document.querySelectorAll('.bookmark-list li');
bookmarks.forEach(bookmark => {
const toggleButton = bookmark.querySelector('.toggle-notes');
if (toggleButton) {
toggleButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
bookmark.classList.toggle('show-notes');
});
}
});
}
setupBulkEdit();
setupBulkEditTagAutoComplete();
setupListNavigation();
setupNotes();
})()

View file

@ -1,83 +0,0 @@
(function () {
function initConfirmationButtons() {
const buttonEls = document.querySelectorAll('.btn-confirmation');
function showConfirmation(buttonEl) {
const cancelEl = document.createElement(buttonEl.nodeName);
cancelEl.innerText = 'Cancel';
cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1';
cancelEl.addEventListener('click', function () {
container.remove();
buttonEl.style = '';
});
const confirmEl = document.createElement(buttonEl.nodeName);
confirmEl.innerText = 'Confirm';
confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action';
if (buttonEl.nodeName === 'BUTTON') {
confirmEl.type = buttonEl.type;
confirmEl.name = buttonEl.name;
confirmEl.value = buttonEl.value;
}
if (buttonEl.nodeName === 'A') {
confirmEl.href = buttonEl.href;
}
const container = document.createElement('span');
container.className = 'confirmation'
container.appendChild(cancelEl);
container.appendChild(confirmEl);
buttonEl.parentElement.insertBefore(container, buttonEl);
buttonEl.style = 'display: none';
}
buttonEls.forEach(function (linkEl) {
linkEl.addEventListener('click', function (e) {
e.preventDefault();
showConfirmation(linkEl);
});
});
}
function initGlobalShortcuts() {
// Focus search button
document.addEventListener('keydown', function (event) {
// Filter for shortcut key
if (event.key !== 's') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
});
// Add new bookmark
document.addEventListener('keydown', function(event) {
// Filter for new entry shortcut key
if (event.key !== 'n') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
window.location.assign("/bookmarks/new");
});
}
initConfirmationButtons();
initGlobalShortcuts();
})()

View file

@ -1,3 +1,4 @@
/* Bookmark search box */
.bookmarks-page .search { .bookmarks-page .search {
$searchbox-width: 180px; $searchbox-width: 180px;
$searchbox-width-md: 300px; $searchbox-width-md: 300px;
@ -37,12 +38,6 @@
} }
} }
.bookmarks-page .content-area-header {
span.btn {
margin-left: 8px;
}
}
/* Bookmark list */ /* Bookmark list */
ul.bookmark-list { ul.bookmark-list {
list-style: none; list-style: none;
@ -51,9 +46,10 @@ ul.bookmark-list {
} }
/* Bookmarks */ /* Bookmarks */
ul.bookmark-list li { li[ld-bookmark-item] {
position: relative;
.bulk-edit-toggle { [ld-bulk-edit-checkbox].form-checkbox {
display: none; display: none;
} }
@ -88,14 +84,11 @@ ul.bookmark-list li {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem;
} }
.actions { .actions {
> *:not(:last-child) { a, button.btn-link {
margin-right: 0.4rem;
}
a, button {
color: $gray-color; color: $gray-color;
padding: 0; padding: 0;
height: auto; height: auto;
@ -235,6 +228,7 @@ ul.bookmark-list .notes-content {
> *:first-child { > *:first-child {
margin-top: 0; margin-top: 0;
} }
> *:last-child { > *:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -266,14 +260,13 @@ ul.bookmark-list .notes-content {
} }
} }
/* Bookmark actions / bulk edit */ /* Bookmark bulk edit */
$bulk-edit-toggle-width: 16px; $bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px; $bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset); $bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms; $bulk-edit-transition-duration: 400ms;
.bookmarks-page form.bookmark-actions { [ld-bulk-edit] {
.bulk-edit-bar { .bulk-edit-bar {
margin-top: -17px; margin-top: -17px;
margin-bottom: 16px; margin-bottom: 16px;
@ -283,56 +276,27 @@ $bulk-edit-transition-duration: 400ms;
transition: max-height $bulk-edit-transition-duration; transition: max-height $bulk-edit-transition-duration;
} }
.bulk-edit-actions { &.active .bulk-edit-bar {
display: flex; max-height: 37px;
align-items: baseline; border-bottom: solid 1px $border-color;
padding: 4px 0;
border-top: solid 1px $border-color;
button:hover {
text-decoration: underline;
}
> label.form-checkbox {
min-height: 1rem;
}
> button {
padding: 0;
margin-left: 8px;
}
> span {
margin-left: 8px;
}
> input, .form-autocomplete {
width: auto;
margin-left: 4px;
max-width: 200px;
-webkit-appearance: none;
}
span.confirmation {
display: flex;
}
span.confirmation button {
padding: 0;
}
} }
.bulk-edit-all-toggle { /* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */
&.active:not(.activating) .bulk-edit-bar {
overflow: visible;
}
/* All checkbox */
[ld-bulk-edit-checkbox][all].form-checkbox {
display: block;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset; margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0; padding: 0;
min-height: 1rem;
} }
ul.bookmark-list li { /* Bookmark checkboxes */
position: relative; li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
}
ul.bookmark-list li .bulk-edit-toggle {
display: block; display: block;
position: absolute; position: absolute;
width: $bulk-edit-toggle-width; width: $bulk-edit-toggle-width;
@ -344,22 +308,36 @@ $bulk-edit-transition-duration: 400ms;
opacity: 0; opacity: 0;
transition: all $bulk-edit-transition-duration; transition: all $bulk-edit-transition-duration;
i { .form-icon {
top: 0.2rem; top: 0.2rem;
} }
} }
}
#bulk-edit-mode { &.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
display: none; visibility: visible;
} opacity: 1;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle { /* Actions */
visibility: visible; .bulk-edit-actions {
opacity: 1; display: flex;
} align-items: baseline;
padding: 4px 0;
border-top: solid 1px $border-color;
gap: 8px;
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar { button {
max-height: 37px; padding: 0 !important;
border-bottom: solid 1px $border-color; }
button:hover {
text-decoration: underline;
}
> input, .form-autocomplete {
width: auto;
max-width: 200px;
-webkit-appearance: none;
}
}
} }

View file

@ -14,9 +14,15 @@ section.content-area {
} }
// Confirm button component // Confirm button component
.btn-confirmation-action { span.confirmation {
display: flex;
align-items: baseline;
}
span.confirmation .btn.btn-link {
color: $error-color !important; color: $error-color !important;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }

View file

@ -4,43 +4,42 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns"
{% include 'bookmarks/bulk_edit/state.html' %} ld-bulk-edit
ld-bookmark-page
<div class="bookmarks-page columns"> bookmarks-url="{% url 'bookmarks:partials.bookmark_list.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 column col-8 col-md-12">
<div class="content-area-header"> <div class="content-area-header">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search filters 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>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?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 mode='archive' %}
{% if empty %} <div class="bookmark-list-container">
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/bookmark_list.html' %}
{% else %} </div>
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form> </form>
</section> </section>
{# Tag list #} {# Tag cloud #}
<section class="content-area column col-4 hide-md"> <section class="content-area column col-4 hide-md">
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags selected_tags %} <div class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View file

@ -1,128 +1,136 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
<ul class="bookmark-list{% if request.user_profile.permanent_notes %} show-notes{% endif %}">
{% for bookmark in bookmarks %} {% if bookmark_list.is_empty %}
<li data-is-bookmark-item> {% include 'bookmarks/empty_bookmarks.html' %}
<label class="form-checkbox bulk-edit-toggle"> {% else %}
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}"> <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}">
<i class="form-icon"></i> {% for bookmark in bookmark_list.bookmarks_page %}
</label> <li ld-bookmark-item>
<div class="title"> <label ld-bulk-edit-checkbox class="form-checkbox">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" <input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
class="{% if bookmark.unread %}text-italic{% endif %}"> <i class="form-icon"></i>
{% if bookmark.favicon_file and request.user_profile.enable_favicons %} </label>
<img src="{% static bookmark.favicon_file %}" alt=""> <div class="title">
{% endif %} <a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
{{ bookmark.resolved_title }} class="{% if bookmark.unread %}text-italic{% endif %}">
</a> {% if bookmark.favicon_file and bookmark_list.show_favicons %}
</div> <img src="{% static bookmark.favicon_file %}" alt="">
{% if request.user_profile.display_url %} {% endif %}
<div class="url-path truncate"> {{ bookmark.resolved_title }}
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="url-display text-sm">
{{ bookmark.url }}
</a> </a>
</div> </div>
{% endif %} {% if bookmark_list.show_url %}
<div class="description truncate"> <div class="url-path truncate">
{% if bookmark.tag_names %} <a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
<span> class="url-display text-sm">
{{ bookmark.url }}
</a>
</div>
{% endif %}
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %} {% for tag_name in bookmark.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.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %} {% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span> <span>{{ bookmark.resolved_description }}</span>
{% endif %} {% endif %}
</div>
{% if bookmark.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{% markdown bookmark.notes %}
</div>
</div> </div>
{% endif %} {% if bookmark.notes %}
<div class="actions text-gray text-sm"> <div class="notes bg-gray text-gray-dark">
{% if request.user_profile.bookmark_date_display == 'relative' %} <div class="notes-content">
<span> {% markdown bookmark.notes %}
</div>
</div>
{% endif %}
<div class="actions text-gray text-sm">
{% if bookmark_list.date_display == 'relative' %}
<span>
{% if bookmark.web_archive_snapshot_url %} {% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}" <a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" title="Show snapshot on the Internet Archive Wayback Machine"
target="{{ bookmark_list.link_target }}"
rel="noopener"> rel="noopener">
{% endif %} {% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span> <span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
<span class="separator">|</span>
{% endif %}
{% if request.user_profile.bookmark_date_display == 'absolute' %}
<span>
{% if bookmark.web_archive_snapshot_url %} {% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" </a>
rel="noopener">
{% endif %} {% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span> </span>
<span class="separator">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove
</button>
{% if bookmark.unread %}
<span class="separator">|</span> <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 %} {% endif %}
{% else %} {% if bookmark_list.date_display == 'absolute' %}
{# Shared bookmark actions #} <span>
<span>Shared by {% 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 #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Remove
</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 %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a> <a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span> </span>
{% endif %} {% endif %}
{% if bookmark.notes and not request.user_profile.permanent_notes %} {% if bookmark.notes and not bookmark_list.show_notes %}
<span class="separator">|</span> <span class="separator">|</span>
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes"> <button class="btn btn-link btn-sm toggle-notes" title="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" class="icon icon-tabler icon-tabler-notes" width="16"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" height="16"
stroke-linejoin="round"> viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> stroke-linejoin="round">
<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 stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 7l6 0"></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 11l6 0"></path> <path d="M9 7l6 0"></path>
<path d="M9 15l4 0"></path> <path d="M9 11l6 0"></path>
</svg> <path d="M9 15l4 0"></path>
<span>Notes</span> </svg>
</button> <span>Notes</span>
{% endif %} </button>
</div> {% endif %}
</li> </div>
{% endfor %} </li>
</ul> {% endfor %}
</ul>
<div class="bookmark-pagination"> <div class="bookmark-pagination">
{% pagination bookmarks %} {% pagination bookmark_list.bookmarks_page %}
</div> </div>
{% endif %}

View file

@ -1,34 +1,34 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<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 class="form-checkbox bulk-edit-all-toggle"> <label ld-bulk-edit-checkbox all class="form-checkbox">
<input type="checkbox" style="display: none"> <input type="checkbox" style="display: none">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
{% if mode == 'archive' %} {% if mode == 'archive' %}
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation" <button ld-confirm-button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm"
title="Unarchive selected bookmarks">Unarchive title="Unarchive selected bookmarks">Unarchive
</button>
{% else %}
<button ld-confirm-button type="submit" name="bulk_archive" class="btn btn-link btn-sm"
title="Archive selected bookmarks">Archive
</button>
{% endif %}
<span class="text-sm text-gray-dark"></span>
<button ld-confirm-button type="submit" name="bulk_delete" class="btn btn-link btn-sm"
title="Delete selected bookmarks">Delete
</button> </button>
{% else %} <span class="text-sm text-gray-dark"></span>
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation" <span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
title="Archive selected bookmarks">Archive <input ld-tag-autocomplete variant="small"
name="bulk_tag_string" class="form-input input-sm" placeholder="&nbsp;">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
title="Add tags to selected bookmarks">Add
</button> </button>
{% endif %} <button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
<span class="text-sm text-gray-dark"></span> title="Remove tags from selected bookmarks">Remove
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation" </button>
title="Delete selected bookmarks">Delete </div>
</button>
<span class="text-sm text-gray-dark"></span>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
<input id="bulk-edit-tags-input" name="bulk_tag_string" class="form-input input-sm"
placeholder="&nbsp;">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
title="Add tags to selected bookmarks">Add
</button>
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
title="Remove tags from selected bookmarks">Remove
</button>
</div> </div>
</div>
{% endhtmlmin %} {% endhtmlmin %}

View file

@ -1 +0,0 @@
<input id="bulk-edit-mode" type="checkbox">

View file

@ -1,9 +1,7 @@
<label for="bulk-edit-mode" class="hide-sm"> <button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" title="Bulk edit">
<span class="btn" title="Bulk edit"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px" height="20px">
height="20px"> <path
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/> </svg>
</svg> </button>
</span>
</label>

View file

@ -21,7 +21,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} {{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint"> <div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
exist it will be exist it will be
@ -118,23 +118,6 @@
{# Replace tag input with auto-complete component #} {# Replace tag input with auto-complete component #}
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script type="application/javascript">
const wrapper = document.createElement('div');
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: '{{ form.tag_string.id_for_label }}',
name: '{{ form.tag_string.name }}',
value: tagInput.value,
apiClient: apiClient
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
</script>
<script type="application/javascript"> <script type="application/javascript">
/** /**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes * - Pre-fill title and description placeholders with metadata from website as soon as URL changes

View file

@ -4,43 +4,42 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns"
{% include 'bookmarks/bulk_edit/state.html' %} ld-bulk-edit
ld-bookmark-page
<div class="bookmarks-page columns"> bookmarks-url="{% url 'bookmarks:partials.bookmark_list.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 column col-8 col-md-12">
<div class="content-area-header"> <div class="content-area-header">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search filters tags %} {% bookmark_search bookmark_list.filters tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %} {% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
{% if empty %} <div class="bookmark-list-container">
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/bookmark_list.html' %}
{% else %} </div>
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form> </form>
</section> </section>
{# Tag list #} {# Tag cloud #}
<section class="content-area column col-4 hide-md"> <section class="content-area column col-4 hide-md">
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags selected_tags %} <div class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View file

@ -29,7 +29,7 @@
media="(prefers-color-scheme: light)"/> media="(prefers-color-scheme: light)"/>
{% endif %} {% endif %}
</head> </head>
<body> <body ld-global-shortcuts>
<header> <header>
{% if has_toasts %} {% if has_toasts %}
<div class="toasts container grid-lg"> <div class="toasts container grid-lg">

View file

@ -4,26 +4,26 @@
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-page columns"
<div class="bookmarks-page columns"> ld-bookmark-page
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.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 column col-8 col-md-12">
<div class="content-area-header"> <div class="content-area-header">
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search filters 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={{ return_url }}" <form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
{% if empty %} <div class="bookmark-list-container">
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/bookmark_list.html' %}
{% else %} </div>
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form> </form>
</section> </section>
@ -39,11 +39,11 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags selected_tags %} <div class="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>
</div> </div>
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View file

@ -1,9 +1,9 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<div class="tag-cloud"> <div class="tag-cloud">
{% if has_selected_tags %} {% if tag_cloud.has_selected_tags %}
<p class="selected-tags"> <p class="selected-tags">
{% for tag in selected_tags %} {% for tag in tag_cloud.selected_tags %}
<a href="?{% remove_tag_from_query tag.name %}" <a href="?{% remove_tag_from_query tag.name %}"
class="text-bold mr-2"> class="text-bold mr-2">
<span>-{{ tag.name }}</span> <span>-{{ tag.name }}</span>
@ -12,7 +12,7 @@
</p> </p>
{% endif %} {% endif %}
<div class="unselected-tags"> <div class="unselected-tags">
{% for group in groups %} {% for group in tag_cloud.groups %}
<p class="group"> <p class="group">
{% for tag in group.tags %} {% for tag in group.tags %}
{# Highlight first char of first tag in group #} {# Highlight first char of first tag in group #}

View file

@ -1,10 +1,8 @@
from typing import List, Set from typing import List
from django import template from django import template
from django.core.paginator import Page
from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User
from bookmarks.utils import unique
register = template.Library() register = template.Library()
@ -20,60 +18,6 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int
} }
class TagGroup:
def __init__(self, char):
self.tags = []
self.char = char
def create_tag_groups(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None
groups = []
# Group tags that start with a different character than the previous one
for tag in sorted_tags:
tag_char = tag.name[0].lower()
if not group or group.char != tag_char:
group = TagGroup(tag_char)
groups.append(group)
group.tags.append(tag)
return groups
@register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True)
def tag_cloud(context, tags: List[Tag], selected_tags: List[Tag]):
# Only display each tag name once, ignoring casing
# This covers cases where the tag cloud contains shared tags with duplicate names
# Also means that the cloud can not make assumptions that it will necessarily contain
# all tags of the current user
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = create_tag_groups(unselected_tags)
return {
'groups': groups,
'selected_tags': unique_selected_tags,
'has_selected_tags': has_selected_tags,
}
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
def bookmark_list(context, bookmarks: Page, return_url: str, link_target: str = '_blank'):
return {
'request': context['request'],
'bookmarks': bookmarks,
'return_url': return_url,
'link_target': link_target,
}
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True) @register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''): 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]

View file

@ -71,6 +71,44 @@ class BookmarkFactoryMixin:
bookmark.save() bookmark.save()
return bookmark return bookmark
def setup_numbered_bookmarks(self,
count: int,
prefix: str = '',
suffix: str = '',
tag_prefix: str = '',
archived: bool = False,
shared: bool = False,
with_tags: bool = False,
user: User = None):
user = user or self.get_or_create_test_user()
if not prefix:
if archived:
prefix = 'Archived Bookmark'
elif shared:
prefix = 'Shared Bookmark'
else:
prefix = 'Bookmark'
if not tag_prefix:
if archived:
tag_prefix = 'Archived Tag'
elif shared:
tag_prefix = 'Shared Tag'
else:
tag_prefix = 'Tag'
for i in range(1, count + 1):
title = f'{prefix} {i}{suffix}'
tags = []
if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)]
self.setup_bookmark(title=title, is_archived=archived, shared=shared, tags=tags, user=user)
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
def setup_tag(self, user: User = None, name: str = ''): def setup_tag(self, user: User = None, name: str = ''):
if user is None: if user is None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()

View file

@ -16,7 +16,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
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, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(

View file

@ -27,7 +27,7 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse('bookmarks:archived')) response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@ -39,4 +39,4 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto
# 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:archived')) response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)

View file

@ -89,7 +89,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag_string = build_tag_string(bookmark.tag_names, ' ') tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML(f''' self.assertInHTML(f'''
<input type="text" name="tag_string" value="{tag_string}" <input ld-tag-autocomplete type="text" name="tag_string" value="{tag_string}"
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string"> autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
''', html) ''', html)

View file

@ -17,7 +17,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
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, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
for bookmark in bookmarks: for bookmark in bookmarks:
self.assertInHTML( self.assertInHTML(

View file

@ -27,7 +27,7 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
number_of_queries = context.final_queries number_of_queries = context.final_queries
@ -39,4 +39,4 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# 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:index')) response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)

View file

@ -22,7 +22,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
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, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, '<li ld-bookmark-item>', 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)

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, 'data-is-bookmark-item', num_initial_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', 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, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks) self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)

View file

@ -1,18 +1,20 @@
from typing import Type
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.paginator import Paginator
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils import timezone, formats from django.utils import timezone, formats
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
from bookmarks.middlewares import UserProfileMiddleware from bookmarks.views.partials import contexts
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'): def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
unread = bookmark.unread unread = bookmark.unread
@ -60,7 +62,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
# Edit link # Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id]) edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url=/test">Edit</a> <a href="{edit_url}?return_url=%2Fbookmarks">Edit</a>
''', html, count=count) ''', html, count=count)
# Archive link # Archive link
self.assertInHTML(f''' self.assertInHTML(f'''
@ -69,8 +71,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
''', html, count=count) ''', html, count=count)
# Delete link # Delete link
self.assertInHTML(f''' self.assertInHTML(f'''
<button type="submit" name="remove" value="{bookmark.id}" <button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
class="btn btn-link btn-sm btn-confirmation">Remove</button> class="btn btn-link btn-sm">Remove</button>
''', html, count=count) ''', html, count=count)
def assertShareInfo(self, html: str, bookmark: Bookmark): def assertShareInfo(self, html: str, bookmark: Bookmark):
@ -138,36 +140,24 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
</button> </button>
''', html, count=count) ''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test', def render_template(self,
url='/bookmarks',
context_type: Type[contexts.BookmarkListContext] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None) -> str: user: User | AnonymousUser = None) -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = user or self.get_or_create_test_user() request.user = user or self.get_or_create_test_user()
middleware = UserProfileMiddleware(lambda r: HttpResponse()) middleware = UserProfileMiddleware(lambda r: HttpResponse())
middleware(request) middleware(request)
paginator = Paginator(bookmarks, 10)
page = paginator.page(1)
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'}) bookmark_list_context = context_type(request)
context = RequestContext(request, {'bookmark_list': bookmark_list_context})
template = Template(
"{% include 'bookmarks/bookmark_list.html' %}"
)
return template.render(context) return template.render(context)
def render_default_template(self, bookmarks: [Bookmark], url: str = '/test',
user: User | AnonymousUser = None) -> str:
template = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
return self.render_template(bookmarks, template, url, user)
def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str:
template = Template(
f'''
{{% load bookmarks %}}
{{% bookmark_list bookmarks return_url '{link_target}' %}}
'''
)
return self.render_template(bookmarks, template)
def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''): def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.date_added = timezone.now() - relativedelta(days=8)
@ -180,7 +170,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def test_should_respect_absolute_date_setting(self): def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE) bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_default_template([bookmark]) html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT') formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertDateLabel(html, formatted_date) self.assertDateLabel(html, formatted_date)
@ -188,45 +178,38 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def test_should_render_web_archive_link_with_absolute_date_setting(self): def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE, bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/') 'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark]) html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT') formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url) self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
def test_should_respect_relative_date_setting(self): def test_should_respect_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE) self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertDateLabel(html, '1 week ago') self.assertDateLabel(html, '1 week ago')
def test_should_render_web_archive_link_with_relative_date_setting(self): def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE, bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/') 'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url) self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
def test_bookmark_link_target_should_be_blank_by_default(self): def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_template()
html = self.render_default_template([bookmark])
self.assertBookmarksLink(html, bookmark, link_target='_blank') self.assertBookmarksLink(html, bookmark, link_target='_blank')
def test_bookmark_link_target_should_respect_link_target_parameter(self): def test_bookmark_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_template()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_bookmark_link_target_should_respect_unread_flag(self):
bookmark = self.setup_bookmark()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self')
bookmark = self.setup_bookmark(unread=True)
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self') self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_web_archive_link_target_should_be_blank_by_default(self): def test_web_archive_link_target_should_be_blank_by_default(self):
@ -235,39 +218,55 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
bookmark.web_archive_snapshot_url = 'https://example.com' bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save() bookmark.save()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
def test_web_archive_link_target_respect_link_target_parameter(self): def test_web_archive_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com' bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save() bookmark.save()
html = self.render_template_with_link_target([bookmark], '_self') html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self') self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
def test_should_respect_unread_flag(self):
bookmark = self.setup_bookmark(unread=True)
html = self.render_template()
self.assertBookmarksLink(html, bookmark)
def test_show_bookmark_actions_for_owned_bookmarks(self): def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkActions(html, bookmark) self.assertBookmarkActions(html, bookmark)
self.assertNoShareInfo(html, bookmark) self.assertNoShareInfo(html, bookmark)
def test_show_share_info_for_non_owned_bookmarks(self): def test_show_share_info_for_non_owned_bookmarks(self):
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) other_user.profile.enable_sharing = True
html = self.render_default_template([bookmark]) other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
self.assertNoBookmarkActions(html, bookmark) self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark) self.assertShareInfo(html, bookmark)
def test_share_info_user_link_keeps_query_params(self): def test_share_info_user_link_keeps_query_params(self):
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) other_user.profile.enable_sharing = True
html = self.render_default_template([bookmark], url='/test?q=foo') other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True, title='foo')
html = self.render_template(url='/bookmarks?q=foo', context_type=contexts.SharedBookmarkListContext)
self.assertInHTML(f''' self.assertInHTML(f'''
<span>Shared by <span>Shared by
@ -281,7 +280,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png') bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertFaviconVisible(html, bookmark) self.assertFaviconVisible(html, bookmark)
@ -291,7 +290,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file='') bookmark = self.setup_bookmark(favicon_file='')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertFaviconHidden(html, bookmark) self.assertFaviconHidden(html, bookmark)
@ -301,7 +300,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png') bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertFaviconHidden(html, bookmark) self.assertFaviconHidden(html, bookmark)
@ -310,7 +309,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkURLHidden(html, bookmark) self.assertBookmarkURLHidden(html, bookmark)
@ -320,7 +319,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkURLVisible(html, bookmark) self.assertBookmarkURLVisible(html, bookmark)
@ -330,68 +329,67 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.save() profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertBookmarkURLHidden(html, bookmark) self.assertBookmarkURLHidden(html, bookmark)
def test_without_notes(self): def test_without_notes(self):
bookmark = self.setup_bookmark() self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotes(html, '', 0) self.assertNotes(html, '', 0)
self.assertNotesToggle(html, 0) self.assertNotesToggle(html, 0)
def test_with_notes(self): def test_with_notes(self):
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
note_html = '<p>Test note</p>' note_html = '<p>Test note</p>'
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
def test_note_renders_markdown(self): def test_note_renders_markdown(self):
bookmark = self.setup_bookmark(notes='**Example:** `print("Hello world!")`') self.setup_bookmark(notes='**Example:** `print("Hello world!")`')
html = self.render_default_template([bookmark]) html = self.render_template()
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)
def test_note_cleans_html(self): def test_note_cleans_html(self):
bookmark = self.setup_bookmark(notes='<script>alert("test")</script>') self.setup_bookmark(notes='<script>alert("test")</script>')
html = self.render_default_template([bookmark]) html = self.render_template()
note_html = '&lt;script&gt;alert("test")&lt;/script&gt;' note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
self.assertNotes(html, note_html, 1) self.assertNotes(html, note_html, 1)
def test_notes_are_hidden_initially_by_default(self): def test_notes_are_hidden_initially_by_default(self):
html = self.render_default_template([]) self.setup_bookmark(notes='Test note')
html = self.render_template()
self.assertInHTML(""" self.assertIn('<ul class="bookmark-list">', html)
<ul class="bookmark-list"></ul>
""", 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
profile.permanent_notes = False profile.permanent_notes = False
profile.save() profile.save()
html = self.render_default_template([])
self.assertInHTML(""" self.setup_bookmark(notes='Test note')
<ul class="bookmark-list"></ul> html = self.render_template()
""", html)
self.assertIn('<ul class="bookmark-list">', 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
profile.permanent_notes = True profile.permanent_notes = True
profile.save() profile.save()
html = self.render_default_template([])
self.assertInHTML(""" self.setup_bookmark(notes='Test note')
<ul class="bookmark-list show-notes"></ul> html = self.render_template()
""", html)
self.assertIn('<ul class="bookmark-list show-notes">', html)
def test_toggle_notes_is_visible_by_default(self): def test_toggle_notes_is_visible_by_default(self):
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotesToggle(html, 1) self.assertNotesToggle(html, 1)
@ -400,8 +398,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = False profile.permanent_notes = False
profile.save() profile.save()
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotesToggle(html, 1) self.assertNotesToggle(html, 1)
@ -410,20 +408,26 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = True profile.permanent_notes = True
profile.save() profile.save()
bookmark = self.setup_bookmark(notes='Test note') self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark]) html = self.render_template()
self.assertNotesToggle(html, 0) self.assertNotesToggle(html, 0)
def test_with_anonymous_user(self): def test_with_anonymous_user(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.enable_public_sharing = True
profile.save()
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8) bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com' bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com'
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.save() bookmark.save()
html = self.render_default_template([bookmark], '/test', AnonymousUser()) html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
self.assertBookmarksLink(html, bookmark, link_target='_blank') self.assertBookmarksLink(html, bookmark, link_target='_blank')
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)
@ -431,3 +435,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
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)
def test_empty_state(self):
html = self.render_template()
self.assertInHTML('<p class="empty-title h5">You have no bookmarks yet</p>', html)

View file

@ -1,4 +1,4 @@
from typing import List from typing import List, Type
from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.models import User, AnonymousUser
from django.http import HttpResponse from django.http import HttpResponse
@ -6,29 +6,26 @@ from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.middlewares import UserProfileMiddleware from bookmarks.middlewares import UserProfileMiddleware
from bookmarks.models import Tag, UserProfile from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views.partials import contexts
class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template(self, tags: List[Tag], selected_tags: List[Tag] = None, url: str = '/test', def render_template(self,
context_type: Type[contexts.TagCloudContext] = contexts.ActiveTagCloudContext,
url: str = '/test',
user: User | AnonymousUser = None): user: User | AnonymousUser = None):
if not selected_tags:
selected_tags = []
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
request.user = user or self.get_or_create_test_user() request.user = user or self.get_or_create_test_user()
middleware = UserProfileMiddleware(lambda r: HttpResponse()) middleware = UserProfileMiddleware(lambda r: HttpResponse())
middleware(request) middleware(request)
context = RequestContext(request, {
'request': request, tag_cloud_context = context_type(request)
'tags': tags, context = RequestContext(request, {'tag_cloud': tag_cloud_context})
'selected_tags': selected_tags,
})
template_to_render = Template( template_to_render = Template(
'{% load bookmarks %}' "{% include 'bookmarks/tag_cloud.html' %}"
'{% tag_cloud tags selected_tags %}'
) )
return template_to_render.render(context) return template_to_render.render(context)
@ -54,7 +51,7 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(len(link_elements), count) self.assertEqual(len(link_elements), count)
def test_group_alphabetically(self): def test_group_alphabetically(self):
tags = [ tags = ([
self.setup_tag(name='Cockatoo'), self.setup_tag(name='Cockatoo'),
self.setup_tag(name='Badger'), self.setup_tag(name='Badger'),
self.setup_tag(name='Buffalo'), self.setup_tag(name='Buffalo'),
@ -64,9 +61,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='Aardvark'), self.setup_tag(name='Aardvark'),
self.setup_tag(name='Bumblebee'), self.setup_tag(name='Bumblebee'),
self.setup_tag(name='Armadillo'), self.setup_tag(name='Armadillo'),
] ])
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(tags) rendered_template = self.render_template()
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [
[ [
@ -88,12 +86,14 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_no_duplicate_tag_names(self): def test_no_duplicate_tag_names(self):
tags = [ tags = [
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
] ]
for tag in tags:
self.setup_bookmark(tags=[tag], user=tag.owner, shared=True)
rendered_template = self.render_template(tags) rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext)
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [
[ [
@ -106,8 +106,9 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'), self.setup_tag(name='tag2'),
] ]
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(tags, tags, url='/test?q=%23tag1 %23tag2') rendered_template = self.render_template(url='/test?q=%23tag1 %23tag2')
self.assertNumSelectedTags(rendered_template, 2) self.assertNumSelectedTags(rendered_template, 2)
@ -134,9 +135,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'), self.setup_tag(name='tag2'),
] ]
self.setup_bookmark(tags=tags)
# Filter by tag name without hash # Filter by tag name without hash
rendered_template = self.render_template(tags, tags, url='/test?q=tag1 %23tag2') rendered_template = self.render_template(url='/test?q=tag1 %23tag2')
self.assertNumSelectedTags(rendered_template, 2) self.assertNumSelectedTags(rendered_template, 2)
@ -159,8 +161,9 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
tags = [ tags = [
self.setup_tag(name='TEST'), self.setup_tag(name='TEST'),
] ]
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(tags, tags, url='/test?q=%23test') rendered_template = self.render_template(url='/test?q=%23test')
self.assertInHTML(''' self.assertInHTML('''
<a href="?q=" <a href="?q="
@ -171,12 +174,15 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_no_duplicate_selected_tags(self): def test_no_duplicate_selected_tags(self):
tags = [ tags = [
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
self.setup_tag(name='shared', user=self.setup_user()), self.setup_tag(name='shared', user=self.setup_user(enable_sharing=True)),
] ]
for tag in tags:
self.setup_bookmark(tags=[tag], shared=True, user=tag.owner)
rendered_template = self.render_template(tags, tags, url='/test?q=%23shared') rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext,
url='/test?q=%23shared')
self.assertInHTML(''' self.assertInHTML('''
<a href="?q=" <a href="?q="
@ -187,11 +193,12 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_selected_tag_url_keeps_other_search_terms(self): def test_selected_tag_url_keeps_other_search_terms(self):
tag = self.setup_tag(name='tag1') tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1', description='term2')
rendered_template = self.render_template([tag], [tag], url='/test?q=term1 %23tag1 term2 %21untagged') rendered_template = self.render_template(url='/test?q=term1 %23tag1 term2')
self.assertInHTML(''' self.assertInHTML('''
<a href="?q=term1+term2+%21untagged" <a href="?q=term1+term2"
class="text-bold mr-2"> class="text-bold mr-2">
<span>-tag1</span> <span>-tag1</span>
</a> </a>
@ -205,18 +212,20 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag4'), self.setup_tag(name='tag4'),
self.setup_tag(name='tag5'), self.setup_tag(name='tag5'),
] ]
selected_tags = [ self.setup_bookmark(tags=tags)
tags[0],
tags[1],
]
rendered_template = self.render_template(tags, selected_tags) rendered_template = self.render_template(url='/test?q=%23tag1 %23tag2')
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [
['tag3', 'tag4', 'tag5'] ['tag3', 'tag4', 'tag5']
]) ])
def test_with_anonymous_user(self): def test_with_anonymous_user(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.enable_public_sharing = True
profile.save()
tags = [ tags = [
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'), self.setup_tag(name='tag2'),
@ -224,12 +233,10 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(name='tag4'), self.setup_tag(name='tag4'),
self.setup_tag(name='tag5'), self.setup_tag(name='tag5'),
] ]
selected_tags = [ self.setup_bookmark(tags=tags, shared=True)
tags[0],
tags[1],
]
rendered_template = self.render_template(tags, selected_tags, url='/test?q=%23tag1 %23tag2', rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext,
url='/test?q=%23tag1 %23tag2',
user=AnonymousUser()) user=AnonymousUser())
self.assertTagGroups(rendered_template, [ self.assertTagGroups(rendered_template, [

View file

@ -1,10 +1,11 @@
from django.urls import re_path
from django.urls import path, include from django.urls import path, include
from django.urls import re_path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from bookmarks.api.routes import router
from bookmarks import views from bookmarks import views
from bookmarks.api.routes import router
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
from bookmarks.views import partials
app_name = 'bookmarks' app_name = 'bookmarks'
urlpatterns = [ urlpatterns = [
@ -18,6 +19,16 @@ urlpatterns = [
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'), path('bookmarks/action', views.bookmarks.action, name='action'),
# Partials
path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list,
name='partials.bookmark_list.active'),
path('bookmarks/partials/tag-cloud/active', partials.active_tag_cloud, name='partials.tag_cloud.active'),
path('bookmarks/partials/bookmark-list/archived', partials.archived_bookmark_list,
name='partials.bookmark_list.archived'),
path('bookmarks/partials/tag-cloud/archived', partials.archived_tag_cloud, name='partials.tag_cloud.archived'),
path('bookmarks/partials/bookmark-list/shared', partials.shared_bookmark_list,
name='partials.bookmark_list.shared'),
path('bookmarks/partials/tag-cloud/shared', partials.shared_tag_cloud, name='partials.tag_cloud.shared'),
# Settings # Settings
path('settings', views.settings.general, name='settings.index'), path('settings', views.settings.general, name='settings.index'),
path('settings/general', views.settings.general, name='settings.general'), path('settings/general', views.settings.general, name='settings.general'),

View file

@ -1,108 +1,49 @@
import urllib.parse
from typing import List
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db.models import QuerySet, Q, prefetch_related_objects
from django.http import HttpResponseRedirect, Http404 from django.http import HttpResponseRedirect, Http404
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, User, UserProfile, Tag, build_tag_string from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ 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
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts
_default_page_size = 30 _default_page_size = 30
@login_required @login_required
def index(request): def index(request):
filters = BookmarkFilters(request) bookmark_list = contexts.ActiveBookmarkListContext(request)
query_set = queries.query_bookmarks(request.user, request.user_profile, filters.query) tag_cloud = contexts.ActiveTagCloudContext(request)
tags = queries.query_bookmark_tags(request.user, request.user_profile, filters.query) return render(request, 'bookmarks/index.html', {
base_url = reverse('bookmarks:index') 'bookmark_list': bookmark_list,
context = get_bookmark_view_context(request, filters, query_set, tags, base_url) 'tag_cloud': tag_cloud,
return render(request, 'bookmarks/index.html', context) })
@login_required @login_required
def archived(request): def archived(request):
filters = BookmarkFilters(request) bookmark_list = contexts.ArchivedBookmarkListContext(request)
query_set = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query) tag_cloud = contexts.ArchivedTagCloudContext(request)
tags = queries.query_archived_bookmark_tags(request.user, request.user_profile, filters.query) return render(request, 'bookmarks/archive.html', {
base_url = reverse('bookmarks:archived') 'bookmark_list': bookmark_list,
context = get_bookmark_view_context(request, filters, query_set, tags, base_url) 'tag_cloud': tag_cloud,
return render(request, 'bookmarks/archive.html', context) })
def shared(request): def shared(request):
filters = BookmarkFilters(request) filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first() bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request)
public_only = not request.user.is_authenticated public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only)
tags = queries.query_shared_bookmark_tags(user, request.user_profile, filters.query, public_only)
users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only) users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only)
base_url = reverse('bookmarks:shared') return render(request, 'bookmarks/shared.html', {
context = get_bookmark_view_context(request, filters, query_set, tags, base_url) 'bookmark_list': bookmark_list,
context['users'] = users 'tag_cloud': tag_cloud,
return render(request, 'bookmarks/shared.html', context) 'users': users
})
def _get_selected_tags(tags: List[Tag], query_string: str, profile: UserProfile):
parsed_query = queries.parse_query_string(query_string)
tag_names = parsed_query['tag_names']
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query['search_terms']
tag_names = [tag_name.lower() for tag_name in tag_names]
return [tag for tag in tags if tag.name.lower() in tag_names]
def get_bookmark_view_context(request: WSGIRequest,
filters: BookmarkFilters,
query_set: QuerySet[Bookmark],
tags: QuerySet[Tag],
base_url: str):
page = request.GET.get('page')
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
tags = list(tags)
selected_tags = _get_selected_tags(tags, filters.query, request.user_profile)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
prefetch_related_objects(bookmarks.object_list, 'owner', 'tags')
return_url = generate_return_url(base_url, page, filters)
link_target = request.user_profile.bookmark_link_target
if request.GET.get('tag'):
mod = request.GET.copy()
mod.pop('tag')
request.GET = mod
return {
'bookmarks': bookmarks,
'tags': tags,
'selected_tags': selected_tags,
'filters': filters,
'empty': paginator.count == 0,
'return_url': return_url,
'link_target': link_target,
}
def generate_return_url(base_url: str, page: int, filters: BookmarkFilters):
url_query = {}
if filters.query:
url_query['q'] = filters.query
if filters.user:
url_query['user'] = filters.user
if page is not None:
url_query['page'] = page
url_params = urllib.parse.urlencode(url_query)
return_url = base_url if url_params == '' else base_url + '?' + url_params
return urllib.parse.quote_plus(return_url)
def convert_tag_string(tag_string: str): def convert_tag_string(tag_string: str):

View file

@ -0,0 +1,58 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from bookmarks.views.partials import contexts
@login_required
def active_bookmark_list(request):
bookmark_list_context = contexts.ActiveBookmarkListContext(request)
return render(request, 'bookmarks/bookmark_list.html', {
'bookmark_list': bookmark_list_context
})
@login_required
def active_tag_cloud(request):
tag_cloud_context = contexts.ActiveTagCloudContext(request)
return render(request, 'bookmarks/tag_cloud.html', {
'tag_cloud': tag_cloud_context
})
@login_required
def archived_bookmark_list(request):
bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
return render(request, 'bookmarks/bookmark_list.html', {
'bookmark_list': bookmark_list_context
})
@login_required
def archived_tag_cloud(request):
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
return render(request, 'bookmarks/tag_cloud.html', {
'tag_cloud': tag_cloud_context
})
@login_required
def shared_bookmark_list(request):
bookmark_list_context = contexts.SharedBookmarkListContext(request)
return render(request, 'bookmarks/bookmark_list.html', {
'bookmark_list': bookmark_list_context
})
@login_required
def shared_tag_cloud(request):
tag_cloud_context = contexts.SharedTagCloudContext(request)
return render(request, 'bookmarks/tag_cloud.html', {
'tag_cloud': tag_cloud_context
})

View file

@ -0,0 +1,168 @@
import urllib.parse
from typing import Set, List
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import models
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import BookmarkFilters, User, UserProfile, Tag
from bookmarks.utils import unique
DEFAULT_PAGE_SIZE = 30
class BookmarkListContext:
def __init__(self, request: WSGIRequest) -> None:
self.request = request
self.filters = BookmarkFilters(self.request)
query_set = self.get_bookmark_query_set()
page_number = request.GET.get('page')
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
bookmarks_page = paginator.get_page(page_number)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags')
self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page
self.return_url = self.generate_return_url(page_number)
self.link_target = request.user_profile.bookmark_link_target
self.date_display = request.user_profile.bookmark_date_display
self.show_url = request.user_profile.display_url
self.show_favicons = request.user_profile.enable_favicons
self.show_notes = request.user_profile.permanent_notes
def generate_return_url(self, page: int):
base_url = self.get_base_url()
url_query = {}
if self.filters.query:
url_query['q'] = self.filters.query
if self.filters.user:
url_query['user'] = self.filters.user
if page is not None:
url_query['page'] = page
url_params = urllib.parse.urlencode(url_query)
return_url = base_url if url_params == '' else base_url + '?' + url_params
return urllib.parse.quote_plus(return_url)
def get_base_url(self):
raise Exception(f'Must be implemented by subclass')
def get_bookmark_query_set(self):
raise Exception(f'Must be implemented by subclass')
class ActiveBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:index')
def get_bookmark_query_set(self):
return queries.query_bookmarks(self.request.user,
self.request.user_profile,
self.filters.query)
class ArchivedBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:archived')
def get_bookmark_query_set(self):
return queries.query_archived_bookmarks(self.request.user,
self.request.user_profile,
self.filters.query)
class SharedBookmarkListContext(BookmarkListContext):
def get_base_url(self):
return reverse('bookmarks:shared')
def get_bookmark_query_set(self):
user = User.objects.filter(username=self.filters.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(user,
self.request.user_profile,
self.filters.query,
public_only)
class TagGroup:
def __init__(self, char: str):
self.tags = []
self.char = char
@staticmethod
def create_tag_groups(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None
groups = []
# Group tags that start with a different character than the previous one
for tag in sorted_tags:
tag_char = tag.name[0].lower()
if not group or group.char != tag_char:
group = TagGroup(tag_char)
groups.append(group)
group.tags.append(tag)
return groups
class TagCloudContext:
def __init__(self, request: WSGIRequest) -> None:
self.request = request
self.filters = BookmarkFilters(self.request)
query_set = self.get_tag_query_set()
tags = list(query_set)
selected_tags = self.get_selected_tags(tags)
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(unselected_tags)
self.tags = unique_tags
self.groups = groups
self.selected_tags = unique_selected_tags
self.has_selected_tags = has_selected_tags
def get_tag_query_set(self):
raise Exception(f'Must be implemented by subclass')
def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.filters.query)
tag_names = parsed_query['tag_names']
if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query['search_terms']
tag_names = [tag_name.lower() for tag_name in tag_names]
return [tag for tag in tags if tag.name.lower() in tag_names]
class ActiveTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
return queries.query_bookmark_tags(self.request.user,
self.request.user_profile,
self.filters.query)
class ArchivedTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
return queries.query_archived_bookmark_tags(self.request.user,
self.request.user_profile,
self.filters.query)
class SharedTagCloudContext(TagCloudContext):
def get_tag_query_set(self):
user = User.objects.filter(username=self.filters.user).first()
public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(user,
self.request.user_profile,
self.filters.query,
public_only)

28
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.16.0", "version": "1.19.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "linkding", "name": "linkding",
"version": "1.16.0", "version": "1.19.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@rollup/plugin-commonjs": "^21.0.2", "@rollup/plugin-commonjs": "^21.0.2",
@ -16,6 +16,9 @@
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"spectre.css": "^0.5.8", "spectre.css": "^0.5.8",
"svelte": "^3.49.0" "svelte": "^3.49.0"
},
"devDependencies": {
"prettier": "^3.0.2"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -477,6 +480,21 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/prettier": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
"integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/randombytes": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -1025,6 +1043,12 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg=="
}, },
"prettier": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
"integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
"dev": true
},
"randombytes": { "randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",

View file

@ -26,5 +26,8 @@
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"spectre.css": "^0.5.8", "spectre.css": "^0.5.8",
"svelte": "^3.49.0" "svelte": "^3.49.0"
},
"devDependencies": {
"prettier": "^3.0.2"
} }
} }

View file

@ -6,7 +6,7 @@ import { terser } from 'rollup-plugin-terser';
const production = !process.env.ROLLUP_WATCH; const production = !process.env.ROLLUP_WATCH;
export default { export default {
input: 'bookmarks/components/index.js', input: 'bookmarks/frontend/index.js',
output: { output: {
sourcemap: true, sourcemap: true,
format: 'iife', format: 'iife',