mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-07 02:13:12 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9496e2fe0 | ||
|
|
62c40d1b7b | ||
|
|
e076747f85 | ||
|
|
f071423f1e | ||
|
|
be789ea9e6 | ||
|
|
8206705876 | ||
|
|
5d9e487ec1 | ||
|
|
ea240eefd9 |
3
.github/workflows/main.yaml
vendored
3
.github/workflows/main.yaml
vendored
@@ -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
|
||||||
|
|||||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.20.0 (22/08/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add option to share bookmarks publicly by @sissbruecker in https://github.com/sissbruecker/linkding/pull/503
|
||||||
|
* Various improvements to favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/504
|
||||||
|
* Add support for PRIVATE flag in import and export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/505
|
||||||
|
* Avoid page reload when triggering actions in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/506
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.1...v1.20.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.19.1 (29/07/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add Postman Collection to Community section of README by @gingerbeardman in https://github.com/sissbruecker/linkding/pull/476
|
||||||
|
* Added Dev Container support by @acbgbca in https://github.com/sissbruecker/linkding/pull/474
|
||||||
|
* Added Apple web-app meta tag #358 by @acbgbca in https://github.com/sissbruecker/linkding/pull/359
|
||||||
|
* Bump requests from 2.28.1 to 2.31.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/478
|
||||||
|
* Allow passing title and description to new bookmark form by @acbgbca in https://github.com/sissbruecker/linkding/pull/479
|
||||||
|
* Enable WAL to avoid locked database lock errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/480
|
||||||
|
* Fix website loader content encoding detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/482
|
||||||
|
* Bump certifi from 2022.12.7 to 2023.7.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/497
|
||||||
|
* Bump django from 4.1.9 to 4.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/494
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @gingerbeardman made their first contribution in https://github.com/sissbruecker/linkding/pull/476
|
||||||
|
* @acbgbca made their first contribution in https://github.com/sissbruecker/linkding/pull/474
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.0...v1.19.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.19.0 (20/05/2023)
|
## v1.19.0 (20/05/2023)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import viewsets, mixins, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
@@ -18,6 +19,17 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
|||||||
mixins.DestroyModelMixin):
|
mixins.DestroyModelMixin):
|
||||||
serializer_class = BookmarkSerializer
|
serializer_class = BookmarkSerializer
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
# Allow unauthenticated access to shared bookmarks.
|
||||||
|
# The shared action should still filter bookmarks so that
|
||||||
|
# unauthenticated users only see bookmarks from users that have public
|
||||||
|
# sharing explicitly enabled
|
||||||
|
if self.action == 'shared':
|
||||||
|
return [AllowAny()]
|
||||||
|
|
||||||
|
# Otherwise use default permissions which should require authentication
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
# For list action, use query set that applies search and tag projections
|
# For list action, use query set that applies search and tag projections
|
||||||
@@ -45,7 +57,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
|||||||
def shared(self, request):
|
def shared(self, request):
|
||||||
filters = BookmarkFilters(request)
|
filters = BookmarkFilters(request)
|
||||||
user = User.objects.filter(username=filters.user).first()
|
user = User.objects.filter(username=filters.user).first()
|
||||||
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
public_only = not request.user.is_authenticated
|
||||||
|
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only)
|
||||||
page = self.paginate_queryset(query_set)
|
page = self.paginate_queryset(query_set)
|
||||||
serializer = self.get_serializer_class()
|
serializer = self.get_serializer_class()
|
||||||
data = serializer(page, many=True).data
|
data = serializer(page, many=True).data
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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=" "
|
|
||||||
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>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import TagAutoComplete from './TagAutocomplete.svelte'
|
|
||||||
import SearchAutoComplete from './SearchAutoComplete.svelte'
|
|
||||||
import {ApiClient} from './api'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
ApiClient,
|
|
||||||
TagAutoComplete,
|
|
||||||
SearchAutoComplete
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
|
from bookmarks import queries
|
||||||
from bookmarks.models import Toast
|
from bookmarks.models import Toast
|
||||||
|
from bookmarks import utils
|
||||||
|
|
||||||
|
|
||||||
def toasts(request):
|
def toasts(request):
|
||||||
user = request.user if hasattr(request, 'user') else None
|
user = request.user
|
||||||
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
|
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user.is_authenticated else []
|
||||||
has_toasts = len(toast_messages) > 0
|
has_toasts = len(toast_messages) > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'has_toasts': has_toasts,
|
'has_toasts': has_toasts,
|
||||||
'toast_messages': toast_messages,
|
'toast_messages': toast_messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def public_shares(request):
|
||||||
|
# Only check for public shares for anonymous users
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
query_set = queries.query_shared_bookmarks(None, request.user_profile, '', True)
|
||||||
|
has_public_shares = query_set.count() > 0
|
||||||
|
return {
|
||||||
|
'has_public_shares': has_public_shares,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def app_version(request):
|
||||||
|
return {
|
||||||
|
'app_version': utils.app_version
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
252
bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
Normal file
252
bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
Normal 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)
|
||||||
39
bookmarks/e2e/e2e_test_settings_general.py
Normal file
39
bookmarks/e2e/e2e_test_settings_general.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse('bookmarks:settings.general'))
|
||||||
|
|
||||||
|
enable_sharing = page.get_by_label('Enable bookmark sharing')
|
||||||
|
enable_sharing_label = page.get_by_text('Enable bookmark sharing')
|
||||||
|
enable_public_sharing = page.get_by_label('Enable public bookmark sharing')
|
||||||
|
enable_public_sharing_label = page.get_by_text('Enable public bookmark sharing')
|
||||||
|
|
||||||
|
# Public sharing is disabled by default
|
||||||
|
expect(enable_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_disabled()
|
||||||
|
|
||||||
|
# Enable sharing
|
||||||
|
enable_sharing_label.click()
|
||||||
|
expect(enable_sharing).to_be_checked()
|
||||||
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_enabled()
|
||||||
|
|
||||||
|
# Enable public sharing
|
||||||
|
enable_public_sharing_label.click()
|
||||||
|
expect(enable_public_sharing).to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_enabled()
|
||||||
|
|
||||||
|
# Disable sharing
|
||||||
|
enable_sharing_label.click()
|
||||||
|
expect(enable_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_disabled()
|
||||||
@@ -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
29
bookmarks/frontend/api.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
bookmarks/frontend/behaviors/bookmark-page.js
Normal file
65
bookmarks/frontend/behaviors/bookmark-page.js
Normal 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);
|
||||||
100
bookmarks/frontend/behaviors/bulk-edit.js
Normal file
100
bookmarks/frontend/behaviors/bulk-edit.js
Normal 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);
|
||||||
50
bookmarks/frontend/behaviors/confirm-button.js
Normal file
50
bookmarks/frontend/behaviors/confirm-button.js
Normal 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);
|
||||||
73
bookmarks/frontend/behaviors/global-shortcuts.js
Normal file
73
bookmarks/frontend/behaviors/global-shortcuts.js
Normal 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);
|
||||||
36
bookmarks/frontend/behaviors/index.js
Normal file
36
bookmarks/frontend/behaviors/index.js
Normal 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);
|
||||||
|
}
|
||||||
26
bookmarks/frontend/behaviors/tag-autocomplete.js
Normal file
26
bookmarks/frontend/behaviors/tag-autocomplete.js
Normal 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);
|
||||||
272
bookmarks/frontend/components/SearchAutoComplete.svelte
Normal file
272
bookmarks/frontend/components/SearchAutoComplete.svelte
Normal 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>
|
||||||
52
bookmarks/frontend/components/SearchHistory.js
Normal file
52
bookmarks/frontend/components/SearchHistory.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
bookmarks/frontend/components/TagAutocomplete.svelte
Normal file
170
bookmarks/frontend/components/TagAutocomplete.svelte
Normal 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=" "
|
||||||
|
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>
|
||||||
14
bookmarks/frontend/index.js
Normal file
14
bookmarks/frontend/index.js
Normal 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,
|
||||||
|
};
|
||||||
37
bookmarks/frontend/util.js
Normal file
37
bookmarks/frontend/util.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
|
||||||
|
from bookmarks.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
request.user_profile = request.user.profile
|
||||||
|
else:
|
||||||
|
request.user_profile = UserProfile()
|
||||||
|
request.user_profile.enable_favicons = True
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-08-14 07:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0023_userprofile_permanent_notes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='enable_public_sharing',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -176,6 +176,7 @@ class UserProfile(models.Model):
|
|||||||
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
|
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
|
||||||
default=TAG_SEARCH_STRICT)
|
default=TAG_SEARCH_STRICT)
|
||||||
enable_sharing = models.BooleanField(default=False, null=False)
|
enable_sharing = models.BooleanField(default=False, null=False)
|
||||||
|
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||||
enable_favicons = models.BooleanField(default=False, null=False)
|
enable_favicons = models.BooleanField(default=False, null=False)
|
||||||
display_url = models.BooleanField(default=False, null=False)
|
display_url = models.BooleanField(default=False, null=False)
|
||||||
permanent_notes = models.BooleanField(default=False, null=False)
|
permanent_notes = models.BooleanField(default=False, null=False)
|
||||||
@@ -185,7 +186,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
|
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
|
||||||
'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
|
'enable_sharing', 'enable_public_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=get_user_model())
|
@receiver(post_save, sender=get_user_model())
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str
|
|||||||
.filter(is_archived=True)
|
.filter(is_archived=True)
|
||||||
|
|
||||||
|
|
||||||
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str,
|
||||||
return _base_bookmarks_query(user, profile, query_string) \
|
public_only: bool) -> QuerySet:
|
||||||
.filter(shared=True) \
|
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
|
||||||
.filter(owner__profile__enable_sharing=True)
|
if public_only:
|
||||||
|
conditions = conditions & Q(owner__profile__enable_public_sharing=True)
|
||||||
|
|
||||||
|
return _base_bookmarks_query(user, profile, query_string).filter(conditions)
|
||||||
|
|
||||||
|
|
||||||
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
@@ -85,16 +88,17 @@ def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string:
|
|||||||
return query_set.distinct()
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str,
|
||||||
bookmarks_query = query_shared_bookmarks(user, profile, query_string)
|
public_only: bool) -> QuerySet:
|
||||||
|
bookmarks_query = query_shared_bookmarks(user, profile, query_string, public_only)
|
||||||
|
|
||||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
return query_set.distinct()
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet:
|
def query_shared_bookmark_users(profile: UserProfile, query_string: str, public_only: bool) -> QuerySet:
|
||||||
bookmarks_query = query_shared_bookmarks(None, profile, query_string)
|
bookmarks_query = query_shared_bookmarks(None, profile, query_string, public_only)
|
||||||
|
|
||||||
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
|||||||
desc = html.escape(bookmark.resolved_description or '')
|
desc = html.escape(bookmark.resolved_description or '')
|
||||||
tags = ','.join(bookmark.tag_names)
|
tags = ','.join(bookmark.tag_names)
|
||||||
toread = '1' if bookmark.unread else '0'
|
toread = '1' if bookmark.unread else '0'
|
||||||
|
private = '0' if bookmark.shared else '1'
|
||||||
added = int(bookmark.date_added.timestamp())
|
added = int(bookmark.date_added.timestamp())
|
||||||
|
|
||||||
doc.append(f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="0" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
|
doc.append(f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
|
||||||
|
|
||||||
if desc:
|
if desc:
|
||||||
doc.append(f'<DD>{desc}')
|
doc.append(f'<DD>{desc}')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -10,25 +11,46 @@ from django.conf import settings
|
|||||||
|
|
||||||
max_file_age = 60 * 60 * 24 # 1 day
|
max_file_age = 60 * 60 * 24 # 1 day
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# register mime type for .ico files, which is not included in the default
|
||||||
|
# mimetypes of the Docker image
|
||||||
|
mimetypes.add_type('image/x-icon', '.ico')
|
||||||
|
|
||||||
|
|
||||||
def _ensure_favicon_folder():
|
def _ensure_favicon_folder():
|
||||||
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _url_to_filename(url: str) -> str:
|
def _url_to_filename(url: str) -> str:
|
||||||
name = re.sub(r'\W+', '_', url)
|
return re.sub(r'\W+', '_', url)
|
||||||
return f'{name}.png'
|
|
||||||
|
|
||||||
|
|
||||||
def _get_base_url(url: str) -> str:
|
def _get_url_parameters(url: str) -> dict:
|
||||||
parsed_uri = urlparse(url)
|
parsed_uri = urlparse(url)
|
||||||
return f'{parsed_uri.scheme}://{parsed_uri.hostname}'
|
return {
|
||||||
|
# https://example.com/foo?bar -> https://example.com
|
||||||
|
'url': f'{parsed_uri.scheme}://{parsed_uri.hostname}',
|
||||||
|
# https://example.com/foo?bar -> example.com
|
||||||
|
'domain': parsed_uri.hostname,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_favicon_path(favicon_file: str) -> Path:
|
def _get_favicon_path(favicon_file: str) -> Path:
|
||||||
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
|
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_existing_favicon(favicon_name: str):
|
||||||
|
# return existing file if a file with the same name, ignoring extension,
|
||||||
|
# exists and is not stale
|
||||||
|
for filename in os.listdir(settings.LD_FAVICON_FOLDER):
|
||||||
|
file_base_name, _ = os.path.splitext(filename)
|
||||||
|
if file_base_name == favicon_name:
|
||||||
|
favicon_path = _get_favicon_path(filename)
|
||||||
|
return filename if not _is_stale(favicon_path) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_stale(path: Path) -> bool:
|
def _is_stale(path: Path) -> bool:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
file_age = time.time() - stat.st_mtime
|
file_age = time.time() - stat.st_mtime
|
||||||
@@ -36,22 +58,26 @@ def _is_stale(path: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def load_favicon(url: str) -> str:
|
def load_favicon(url: str) -> str:
|
||||||
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host
|
url_parameters = _get_url_parameters(url)
|
||||||
base_url = _get_base_url(url)
|
|
||||||
favicon_name = _url_to_filename(base_url)
|
|
||||||
favicon_path = _get_favicon_path(favicon_name)
|
|
||||||
|
|
||||||
# Load icon if it doesn't exist yet or has become stale
|
# Create favicon folder if not exists
|
||||||
if not favicon_path.exists() or _is_stale(favicon_path):
|
_ensure_favicon_folder()
|
||||||
# Create favicon folder if not exists
|
# Use scheme+hostname as favicon filename to reuse icon for all pages on the same domain
|
||||||
_ensure_favicon_folder()
|
favicon_name = _url_to_filename(url_parameters['url'])
|
||||||
|
favicon_file = _check_existing_favicon(favicon_name)
|
||||||
|
|
||||||
|
if not favicon_file:
|
||||||
# Load favicon from provider, save to file
|
# Load favicon from provider, save to file
|
||||||
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url)
|
favicon_url = settings.LD_FAVICON_PROVIDER.format(**url_parameters)
|
||||||
response = requests.get(favicon_url, stream=True)
|
logger.debug(f'Loading favicon from: {favicon_url}')
|
||||||
|
with requests.get(favicon_url, stream=True) as response:
|
||||||
|
content_type = response.headers['Content-Type']
|
||||||
|
file_extension = mimetypes.guess_extension(content_type)
|
||||||
|
favicon_file = f'{favicon_name}{file_extension}'
|
||||||
|
favicon_path = _get_favicon_path(favicon_file)
|
||||||
|
with open(favicon_path, 'wb') as file:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
file.write(chunk)
|
||||||
|
logger.debug(f'Saved favicon as: {favicon_path}')
|
||||||
|
|
||||||
with open(favicon_path, 'wb') as file:
|
return favicon_file
|
||||||
shutil.copyfileobj(response.raw, file)
|
|
||||||
|
|
||||||
del response
|
|
||||||
|
|
||||||
return favicon_name
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class ImportResult:
|
|||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportOptions:
|
||||||
|
map_private_flag: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TagCache:
|
class TagCache:
|
||||||
def __init__(self, user: User):
|
def __init__(self, user: User):
|
||||||
self.user = user
|
self.user = user
|
||||||
@@ -50,7 +55,7 @@ class TagCache:
|
|||||||
self.cache[tag.name.lower()] = tag
|
self.cache[tag.name.lower()] = tag
|
||||||
|
|
||||||
|
|
||||||
def import_netscape_html(html: str, user: User):
|
def import_netscape_html(html: str, user: User, options: ImportOptions = ImportOptions()) -> ImportResult:
|
||||||
result = ImportResult()
|
result = ImportResult()
|
||||||
import_start = timezone.now()
|
import_start = timezone.now()
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@ def import_netscape_html(html: str, user: User):
|
|||||||
# Split bookmarks to import into batches, to keep memory usage for bulk operations manageable
|
# Split bookmarks to import into batches, to keep memory usage for bulk operations manageable
|
||||||
batches = _get_batches(netscape_bookmarks, 200)
|
batches = _get_batches(netscape_bookmarks, 200)
|
||||||
for batch in batches:
|
for batch in batches:
|
||||||
_import_batch(batch, user, tag_cache, result)
|
_import_batch(batch, user, options, tag_cache, result)
|
||||||
|
|
||||||
# Create snapshots for newly imported bookmarks
|
# Create snapshots for newly imported bookmarks
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
tasks.schedule_bookmarks_without_snapshots(user)
|
||||||
@@ -114,7 +119,11 @@ def _get_batches(items: List, batch_size: int):
|
|||||||
return batches
|
return batches
|
||||||
|
|
||||||
|
|
||||||
def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_cache: TagCache, result: ImportResult):
|
def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||||
|
user: User,
|
||||||
|
options: ImportOptions,
|
||||||
|
tag_cache: TagCache,
|
||||||
|
result: ImportResult):
|
||||||
# Query existing bookmarks
|
# Query existing bookmarks
|
||||||
batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
|
batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
|
||||||
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
||||||
@@ -135,7 +144,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
|
|||||||
else:
|
else:
|
||||||
is_update = True
|
is_update = True
|
||||||
# Copy data from parsed bookmark
|
# Copy data from parsed bookmark
|
||||||
_copy_bookmark_data(netscape_bookmark, bookmark)
|
_copy_bookmark_data(netscape_bookmark, bookmark, options)
|
||||||
# Validate bookmark fields, exclude owner to prevent n+1 database query,
|
# Validate bookmark fields, exclude owner to prevent n+1 database query,
|
||||||
# also there is no specific validation on owner
|
# also there is no specific validation on owner
|
||||||
bookmark.clean_fields(exclude=['owner'])
|
bookmark.clean_fields(exclude=['owner'])
|
||||||
@@ -152,8 +161,14 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
|
|||||||
result.failed = result.failed + 1
|
result.failed = result.failed + 1
|
||||||
|
|
||||||
# Bulk update bookmarks in DB
|
# Bulk update bookmarks in DB
|
||||||
Bookmark.objects.bulk_update(bookmarks_to_update,
|
Bookmark.objects.bulk_update(bookmarks_to_update, ['url',
|
||||||
['url', 'date_added', 'date_modified', 'unread', 'title', 'description', 'owner'])
|
'date_added',
|
||||||
|
'date_modified',
|
||||||
|
'unread',
|
||||||
|
'shared',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'owner'])
|
||||||
# Bulk insert new bookmarks into DB
|
# Bulk insert new bookmarks into DB
|
||||||
Bookmark.objects.bulk_create(bookmarks_to_create)
|
Bookmark.objects.bulk_create(bookmarks_to_create)
|
||||||
|
|
||||||
@@ -187,7 +202,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
|
|||||||
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
||||||
|
|
||||||
|
|
||||||
def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark):
|
def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions):
|
||||||
bookmark.url = netscape_bookmark.href
|
bookmark.url = netscape_bookmark.href
|
||||||
if netscape_bookmark.date_added:
|
if netscape_bookmark.date_added:
|
||||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||||
@@ -199,3 +214,5 @@ def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark)
|
|||||||
bookmark.title = netscape_bookmark.title
|
bookmark.title = netscape_bookmark.title
|
||||||
if netscape_bookmark.description:
|
if netscape_bookmark.description:
|
||||||
bookmark.description = netscape_bookmark.description
|
bookmark.description = netscape_bookmark.description
|
||||||
|
if options.map_private_flag and not netscape_bookmark.private:
|
||||||
|
bookmark.shared = True
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class NetscapeBookmark:
|
|||||||
date_added: str
|
date_added: str
|
||||||
tag_string: str
|
tag_string: str
|
||||||
to_read: bool
|
to_read: bool
|
||||||
|
private: bool
|
||||||
|
|
||||||
|
|
||||||
class BookmarkParser(HTMLParser):
|
class BookmarkParser(HTMLParser):
|
||||||
@@ -26,6 +27,7 @@ class BookmarkParser(HTMLParser):
|
|||||||
self.title = ''
|
self.title = ''
|
||||||
self.description = ''
|
self.description = ''
|
||||||
self.toread = ''
|
self.toread = ''
|
||||||
|
self.private = ''
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: list):
|
def handle_starttag(self, tag: str, attrs: list):
|
||||||
name = 'handle_start_' + tag.lower()
|
name = 'handle_start_' + tag.lower()
|
||||||
@@ -58,7 +60,9 @@ class BookmarkParser(HTMLParser):
|
|||||||
description='',
|
description='',
|
||||||
date_added=self.add_date,
|
date_added=self.add_date,
|
||||||
tag_string=self.tags,
|
tag_string=self.tags,
|
||||||
to_read=self.toread == '1'
|
to_read=self.toread == '1',
|
||||||
|
# Mark as private by default, also when attribute is not specified
|
||||||
|
private=self.private != '0',
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_a_data(self, data):
|
def handle_a_data(self, data):
|
||||||
@@ -79,6 +83,7 @@ class BookmarkParser(HTMLParser):
|
|||||||
self.title = ''
|
self.title = ''
|
||||||
self.description = ''
|
self.description = ''
|
||||||
self.toread = ''
|
self.toread = ''
|
||||||
|
self.private = ''
|
||||||
|
|
||||||
|
|
||||||
def parse(html: str) -> List[NetscapeBookmark]:
|
def parse(html: str) -> List[NetscapeBookmark]:
|
||||||
|
|||||||
@@ -130,12 +130,12 @@ def _load_favicon_task(bookmark_id: int):
|
|||||||
|
|
||||||
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
|
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
|
||||||
|
|
||||||
new_favicon = favicon_loader.load_favicon(bookmark.url)
|
new_favicon_file = favicon_loader.load_favicon(bookmark.url)
|
||||||
|
|
||||||
if new_favicon != bookmark.favicon_file:
|
if new_favicon_file != bookmark.favicon_file:
|
||||||
bookmark.favicon_file = new_favicon
|
bookmark.favicon_file = new_favicon_file
|
||||||
bookmark.save(update_fields=['favicon_file'])
|
bookmark.save(update_fields=['favicon_file'])
|
||||||
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}')
|
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon_file}')
|
||||||
|
|
||||||
|
|
||||||
def schedule_bookmarks_without_favicons(user: User):
|
def schedule_bookmarks_without_favicons(user: User):
|
||||||
|
|||||||
@@ -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();
|
|
||||||
})()
|
|
||||||
@@ -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();
|
|
||||||
})()
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" %}?v={{ app_version }}"></script>
|
||||||
<script src="{% static "shared.js" %}"></script>
|
|
||||||
<script src="{% static "bookmark_list.js" %}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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=" ">
|
||||||
|
<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=" ">
|
|
||||||
<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 %}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<input id="bulk-edit-mode" type="checkbox">
|
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
|
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if request.user.profile.enable_sharing %}
|
{% if request.user_profile.enable_sharing %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
||||||
{{ form.shared }}
|
{{ form.shared }}
|
||||||
@@ -98,7 +98,11 @@
|
|||||||
<span>Share</span>
|
<span>Share</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Share this bookmark with other users.
|
{% if request.user_profile.enable_public_sharing %}
|
||||||
|
Share this bookmark with other registered users and anonymous users.
|
||||||
|
{% else %}
|
||||||
|
Share this bookmark with other registered users.
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -112,25 +116,7 @@
|
|||||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Replace tag input with auto-complete component #}
|
<script src="{% static "bundle.js" %}?v={{ app_version }}"></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
|
||||||
|
|||||||
@@ -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" %}?v={{ app_version }}"></script>
|
||||||
<script src="{% static "shared.js" %}"></script>
|
|
||||||
<script src="{% static "bookmark_list.js" %}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -17,19 +17,19 @@
|
|||||||
<title>linkding</title>
|
<title>linkding</title>
|
||||||
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
||||||
{# Include specific theme variant based on user profile setting #}
|
{# Include specific theme variant based on user profile setting #}
|
||||||
{% if request.user.profile.theme == 'light' %}
|
{% if request.user_profile.theme == 'light' %}
|
||||||
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
|
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
{% elif request.user.profile.theme == 'dark' %}
|
{% elif request.user_profile.theme == 'dark' %}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
|
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Use auto theme as fallback #}
|
{# Use auto theme as fallback #}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
|
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: dark)"/>
|
media="(prefers-color-scheme: dark)"/>
|
||||||
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
|
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
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">
|
||||||
@@ -51,11 +51,16 @@
|
|||||||
<h1>linkding</h1>
|
<h1>linkding</h1>
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
{# Only show nav items menu when logged in #}
|
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
|
{# Only show nav items menu when logged in #}
|
||||||
<section class="navbar-section">
|
<section class="navbar-section">
|
||||||
{% include 'bookmarks/nav_menu.html' %}
|
{% include 'bookmarks/nav_menu.html' %}
|
||||||
</section>
|
</section>
|
||||||
|
{% elif has_public_shares %}
|
||||||
|
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
|
||||||
|
<section class="navbar-section">
|
||||||
|
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
||||||
</li>
|
</li>
|
||||||
{% if request.user.profile.enable_sharing %}
|
{% if request.user_profile.enable_sharing %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<li style="padding-left: 1rem">
|
<li style="padding-left: 1rem">
|
||||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
||||||
</li>
|
</li>
|
||||||
{% if request.user.profile.enable_sharing %}
|
{% if request.user_profile.enable_sharing %}
|
||||||
<li style="padding-left: 1rem">
|
<li style="padding-left: 1rem">
|
||||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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" %}?v={{ app_version }}"></script>
|
||||||
<script src="{% static "shared.js" %}"></script>
|
|
||||||
<script src="{% static "bookmark_list.js" %}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 #}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
In strict mode, tags must be prefixed with a hash character (#).
|
In strict mode, tags must be prefixed with a hash character (#).
|
||||||
In lax mode, tags can also be searched without the hash character.
|
In lax mode, tags can also be searched without the hash character.
|
||||||
Note that tags without the hash character are indistinguishable from search terms, which means the search result will also include bookmarks where a search term matches otherwise.
|
Note that tags without the hash character are indistinguishable from search terms, which means the search
|
||||||
|
result will also include bookmarks where a search term matches otherwise.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -73,11 +74,11 @@
|
|||||||
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||||
By default, this feature uses a <b>Google service</b> to download favicons.
|
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||||
If you don't want to use this service, check the <a
|
If you don't want to use this service, check the <a
|
||||||
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md" target="_blank">options
|
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
|
||||||
documentation</a> on how to configure a custom favicon provider.
|
target="_blank">options documentation</a> on how to configure a custom favicon provider.
|
||||||
Icons are downloaded in the background, and it may take a while for them to show up.
|
Icons are downloaded in the background, and it may take a while for them to show up.
|
||||||
</div>
|
</div>
|
||||||
{% if request.user.profile.enable_favicons and enable_refresh_favicons %}
|
{% if request.user_profile.enable_favicons and enable_refresh_favicons %}
|
||||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if refresh_favicons_success_message %}
|
{% if refresh_favicons_success_message %}
|
||||||
@@ -112,6 +113,17 @@
|
|||||||
Disabling this feature will hide all previously shared bookmarks from other users.
|
Disabling this feature will hide all previously shared bookmarks from other users.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.enable_public_sharing.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.enable_public_sharing }}
|
||||||
|
<i class="form-icon"></i> Enable public bookmark sharing
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Makes shared bookmarks publicly accessible, without requiring a login.
|
||||||
|
That means that anyone with a link to this instance can view shared bookmarks via the <a
|
||||||
|
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||||
{% if update_profile_success_message %}
|
{% if update_profile_success_message %}
|
||||||
@@ -132,6 +144,16 @@
|
|||||||
added and existing ones are updated.</p>
|
added and existing ones are updated.</p>
|
||||||
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
|
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="import_map_private_flag" class="form-checkbox">
|
||||||
|
<input type="checkbox" id="import_map_private_flag" name="map_private_flag">
|
||||||
|
<i class="form-icon"></i> Import public bookmarks as shared
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
|
||||||
|
Otherwise, all bookmarks will be imported as private bookmarks.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="input-group col-8 col-md-12">
|
<div class="input-group col-8 col-md-12">
|
||||||
<input class="form-input" type="file" name="import_file">
|
<input class="form-input" type="file" name="import_file">
|
||||||
@@ -159,6 +181,10 @@
|
|||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Export</h2>
|
<h2>Export</h2>
|
||||||
<p>Export all bookmarks in Netscape HTML format.</p>
|
<p>Export all bookmarks in Netscape HTML format.</p>
|
||||||
|
<p>
|
||||||
|
Note that exporting bookmark notes is currently not supported due to limitations of the format.
|
||||||
|
For proper backups please use a database backup as described in the documentation.
|
||||||
|
</p>
|
||||||
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||||
{% if export_error %}
|
{% if export_error %}
|
||||||
<div class="has-error">
|
<div class="has-error">
|
||||||
@@ -196,4 +222,22 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||||
|
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||||
|
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||||
|
|
||||||
|
function updatePublicSharing() {
|
||||||
|
if (enableSharing.checked) {
|
||||||
|
enablePublicSharing.disabled = false;
|
||||||
|
} else {
|
||||||
|
enablePublicSharing.disabled = true;
|
||||||
|
enablePublicSharing.checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePublicSharing();
|
||||||
|
enableSharing.addEventListener("change", updatePublicSharing);
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def remove_tag_from_query(context, tag_name: str):
|
|||||||
tag_name_with_hash = '#' + tag_name
|
tag_name_with_hash = '#' + tag_name
|
||||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
|
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
|
||||||
# When using lax tag search, also remove tag without hash
|
# When using lax tag search, also remove tag without hash
|
||||||
profile = context.request.user.profile
|
profile = context.request.user_profile
|
||||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
|
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
|
||||||
# Rebuild query string
|
# Rebuild query string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
|
import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -35,6 +36,7 @@ class BookmarkFactoryMixin:
|
|||||||
website_description: str = '',
|
website_description: str = '',
|
||||||
web_archive_snapshot_url: str = '',
|
web_archive_snapshot_url: str = '',
|
||||||
favicon_file: str = '',
|
favicon_file: str = '',
|
||||||
|
added: datetime = None,
|
||||||
):
|
):
|
||||||
if not title:
|
if not title:
|
||||||
title = get_random_string(length=32)
|
title = get_random_string(length=32)
|
||||||
@@ -45,6 +47,8 @@ class BookmarkFactoryMixin:
|
|||||||
if not url:
|
if not url:
|
||||||
unique_id = get_random_string(length=32)
|
unique_id = get_random_string(length=32)
|
||||||
url = 'https://example.com/' + unique_id
|
url = 'https://example.com/' + unique_id
|
||||||
|
if added is None:
|
||||||
|
added = timezone.now()
|
||||||
bookmark = Bookmark(
|
bookmark = Bookmark(
|
||||||
url=url,
|
url=url,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -52,7 +56,7 @@ class BookmarkFactoryMixin:
|
|||||||
notes=notes,
|
notes=notes,
|
||||||
website_title=website_title,
|
website_title=website_title,
|
||||||
website_description=website_description,
|
website_description=website_description,
|
||||||
date_added=timezone.now(),
|
date_added=added,
|
||||||
date_modified=timezone.now(),
|
date_modified=timezone.now(),
|
||||||
owner=user,
|
owner=user,
|
||||||
is_archived=is_archived,
|
is_archived=is_archived,
|
||||||
@@ -67,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()
|
||||||
@@ -76,11 +118,12 @@ class BookmarkFactoryMixin:
|
|||||||
tag.save()
|
tag.save()
|
||||||
return tag
|
return tag
|
||||||
|
|
||||||
def setup_user(self, name: str = None, enable_sharing: bool = False):
|
def setup_user(self, name: str = None, enable_sharing: bool = False, enable_public_sharing: bool = False):
|
||||||
if not name:
|
if not name:
|
||||||
name = get_random_string(length=32)
|
name = get_random_string(length=32)
|
||||||
user = User.objects.create_user(name, 'user@example.com', 'password123')
|
user = User.objects.create_user(name, 'user@example.com', 'password123')
|
||||||
user.profile.enable_sharing = enable_sharing
|
user.profile.enable_sharing = enable_sharing
|
||||||
|
user.profile.enable_public_sharing = enable_public_sharing
|
||||||
user.profile.save()
|
user.profile.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -124,13 +167,15 @@ class BookmarkHtmlTag:
|
|||||||
description: str = '',
|
description: str = '',
|
||||||
add_date: str = '',
|
add_date: str = '',
|
||||||
tags: str = '',
|
tags: str = '',
|
||||||
to_read: bool = False):
|
to_read: bool = False,
|
||||||
|
private: bool = True):
|
||||||
self.href = href
|
self.href = href
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.add_date = add_date
|
self.add_date = add_date
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.to_read = to_read
|
self.to_read = to_read
|
||||||
|
self.private = private
|
||||||
|
|
||||||
|
|
||||||
class ImportTestMixin:
|
class ImportTestMixin:
|
||||||
@@ -140,7 +185,8 @@ class ImportTestMixin:
|
|||||||
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
||||||
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
||||||
{f'TAGS="{tag.tags}"' if tag.tags else ''}
|
{f'TAGS="{tag.tags}"' if tag.tags else ''}
|
||||||
TOREAD="{1 if tag.to_read else 0}">
|
TOREAD="{1 if tag.to_read else 0}"
|
||||||
|
PRIVATE="{1 if tag.private else 0}">
|
||||||
{tag.title if tag.title else ''}
|
{tag.title if tag.title else ''}
|
||||||
</A>
|
</A>
|
||||||
{f'<DD>{tag.description}' if tag.description else ''}
|
{f'<DD>{tag.description}' if tag.description else ''}
|
||||||
|
|||||||
26
bookmarks/tests/test_anonymous_view.py
Normal file
26
bookmarks/tests/test_anonymous_view.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def assertSharedBookmarksLinkCount(self, response, count):
|
||||||
|
url = reverse('bookmarks:shared')
|
||||||
|
self.assertContains(response, f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
|
||||||
|
count=count)
|
||||||
|
|
||||||
|
def test_publicly_shared_bookmarks_link(self):
|
||||||
|
# should not render link if no public shares exist
|
||||||
|
user = self.setup_user(enable_sharing=True)
|
||||||
|
self.setup_bookmark(user=user, shared=True)
|
||||||
|
|
||||||
|
response = self.client.get(reverse('login'))
|
||||||
|
self.assertSharedBookmarksLinkCount(response, 0)
|
||||||
|
|
||||||
|
# should render link if public shares exist
|
||||||
|
user.profile.enable_public_sharing = True
|
||||||
|
user.profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('login'))
|
||||||
|
self.assertSharedBookmarksLinkCount(response, 1)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
'placeholder=" " autofocus class="form-input" required '
|
'placeholder=" " autofocus class="form-input" required '
|
||||||
'id="id_url">',
|
'id="id_url">',
|
||||||
html)
|
html)
|
||||||
|
|
||||||
def test_should_prefill_title_from_url_parameter(self):
|
def test_should_prefill_title_from_url_parameter(self):
|
||||||
response = self.client.get(reverse('bookmarks:new') + '?title=Example%20Title')
|
response = self.client.get(reverse('bookmarks:new') + '?title=Example%20Title')
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
@@ -85,7 +85,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
'class="form-input" maxlength="512" autocomplete="off" '
|
'class="form-input" maxlength="512" autocomplete="off" '
|
||||||
'id="id_title">',
|
'id="id_title">',
|
||||||
html)
|
html)
|
||||||
|
|
||||||
def test_should_prefill_description_from_url_parameter(self):
|
def test_should_prefill_description_from_url_parameter(self):
|
||||||
response = self.client.get(reverse('bookmarks:new') + '?description=Example%20Site%20Description')
|
response = self.client.get(reverse('bookmarks:new') + '?description=Example%20Site%20Description')
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
@@ -160,8 +160,32 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
</label>
|
</label>
|
||||||
''', html, count=1)
|
''', html, count=1)
|
||||||
|
|
||||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
def test_should_show_respective_share_hint(self):
|
||||||
bookmark = self.setup_bookmark()
|
self.user.profile.enable_sharing = True
|
||||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
self.user.profile.save()
|
||||||
|
|
||||||
self.assertContains(response, '<details class="notes">', count=1)
|
response = self.client.get(reverse('bookmarks:new'))
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertInHTML('''
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Share this bookmark with other registered users.
|
||||||
|
</div>
|
||||||
|
''', html)
|
||||||
|
|
||||||
|
self.user.profile.enable_public_sharing = True
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:new'))
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertInHTML('''
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Share this bookmark with other registered users and anonymous users.
|
||||||
|
</div>
|
||||||
|
''', html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||||
|
|
||||||
|
self.assertContains(response, '<details class="notes">', count=1)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
|
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
|
request.user = self.get_or_create_test_user()
|
||||||
|
request.user_profile = self.get_or_create_test_user().profile
|
||||||
filters = BookmarkFilters(request)
|
filters = BookmarkFilters(request)
|
||||||
context = RequestContext(request, {
|
context = RequestContext(request, {
|
||||||
'request': request,
|
'request': request,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|||||||
|
|
||||||
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def authenticate(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -65,6 +65,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
''', html, count=0)
|
''', html, count=0)
|
||||||
|
|
||||||
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
|
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
|
||||||
|
self.authenticate()
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
user3 = self.setup_user(enable_sharing=True)
|
user3 = self.setup_user(enable_sharing=True)
|
||||||
@@ -89,6 +90,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
def test_should_list_shared_bookmarks_from_selected_user(self):
|
def test_should_list_shared_bookmarks_from_selected_user(self):
|
||||||
|
self.authenticate()
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
user3 = self.setup_user(enable_sharing=True)
|
user3 = self.setup_user(enable_sharing=True)
|
||||||
@@ -108,6 +110,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
def test_should_list_bookmarks_matching_query(self):
|
def test_should_list_bookmarks_matching_query(self):
|
||||||
|
self.authenticate()
|
||||||
user = self.setup_user(enable_sharing=True)
|
user = self.setup_user(enable_sharing=True)
|
||||||
visible_bookmarks = [
|
visible_bookmarks = [
|
||||||
self.setup_bookmark(shared=True, title='searchvalue', user=user),
|
self.setup_bookmark(shared=True, title='searchvalue', user=user),
|
||||||
@@ -126,7 +129,29 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
|
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
visible_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True, user=user1),
|
||||||
|
self.setup_bookmark(shared=True, user=user1),
|
||||||
|
self.setup_bookmark(shared=True, user=user1),
|
||||||
|
]
|
||||||
|
invisible_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True, user=user2),
|
||||||
|
self.setup_bookmark(shared=True, user=user2),
|
||||||
|
self.setup_bookmark(shared=True, user=user2),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:shared'))
|
||||||
|
|
||||||
|
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||||
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
|
def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
|
||||||
|
self.authenticate()
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
user3 = self.setup_user(enable_sharing=True)
|
user3 = self.setup_user(enable_sharing=True)
|
||||||
@@ -158,6 +183,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInvisibleTags(response, invisible_tags)
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
def test_should_list_tags_for_shared_bookmarks_from_selected_user(self):
|
def test_should_list_tags_for_shared_bookmarks_from_selected_user(self):
|
||||||
|
self.authenticate()
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
user3 = self.setup_user(enable_sharing=True)
|
user3 = self.setup_user(enable_sharing=True)
|
||||||
@@ -180,6 +206,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInvisibleTags(response, invisible_tags)
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
def test_should_list_tags_for_bookmarks_matching_query(self):
|
def test_should_list_tags_for_bookmarks_matching_query(self):
|
||||||
|
self.authenticate()
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
user3 = self.setup_user(enable_sharing=True)
|
user3 = self.setup_user(enable_sharing=True)
|
||||||
@@ -207,7 +234,32 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertVisibleTags(response, visible_tags)
|
self.assertVisibleTags(response, visible_tags)
|
||||||
self.assertInvisibleTags(response, invisible_tags)
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
|
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
visible_tags = [
|
||||||
|
self.setup_tag(user=user1),
|
||||||
|
self.setup_tag(user=user1),
|
||||||
|
]
|
||||||
|
invisible_tags = [
|
||||||
|
self.setup_tag(user=user2),
|
||||||
|
self.setup_tag(user=user2),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])
|
||||||
|
self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[1]])
|
||||||
|
|
||||||
|
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
|
||||||
|
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:shared'))
|
||||||
|
|
||||||
|
self.assertVisibleTags(response, visible_tags)
|
||||||
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
|
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
|
||||||
|
self.authenticate()
|
||||||
expected_visible_users = [
|
expected_visible_users = [
|
||||||
self.setup_user(enable_sharing=True),
|
self.setup_user(enable_sharing=True),
|
||||||
self.setup_user(enable_sharing=True),
|
self.setup_user(enable_sharing=True),
|
||||||
@@ -226,30 +278,53 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertVisibleUserOptions(response, expected_visible_users)
|
self.assertVisibleUserOptions(response, expected_visible_users)
|
||||||
self.assertInvisibleUserOptions(response, expected_invisible_users)
|
self.assertInvisibleUserOptions(response, expected_invisible_users)
|
||||||
|
|
||||||
|
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
|
||||||
|
expected_visible_users = [
|
||||||
|
self.setup_user(enable_sharing=True, enable_public_sharing=True),
|
||||||
|
self.setup_user(enable_sharing=True, enable_public_sharing=True),
|
||||||
|
]
|
||||||
|
self.setup_bookmark(shared=True, user=expected_visible_users[0])
|
||||||
|
self.setup_bookmark(shared=True, user=expected_visible_users[1])
|
||||||
|
|
||||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
expected_invisible_users = [
|
||||||
visible_bookmarks = [
|
self.setup_user(enable_sharing=True),
|
||||||
self.setup_bookmark(shared=True),
|
self.setup_user(enable_sharing=True),
|
||||||
self.setup_bookmark(shared=True),
|
]
|
||||||
self.setup_bookmark(shared=True)
|
self.setup_bookmark(shared=True, user=expected_invisible_users[0])
|
||||||
]
|
self.setup_bookmark(shared=True, user=expected_invisible_users[1])
|
||||||
|
|
||||||
response = self.client.get(reverse('bookmarks:shared'))
|
response = self.client.get(reverse('bookmarks:shared'))
|
||||||
|
self.assertVisibleUserOptions(response, expected_visible_users)
|
||||||
|
self.assertInvisibleUserOptions(response, expected_invisible_users)
|
||||||
|
|
||||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
|
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||||
|
self.authenticate()
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
user.profile.enable_sharing = True
|
||||||
|
user.profile.save()
|
||||||
|
visible_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True),
|
||||||
|
self.setup_bookmark(shared=True),
|
||||||
|
self.setup_bookmark(shared=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:shared'))
|
||||||
|
|
||||||
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
|
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
|
||||||
user = self.get_or_create_test_user()
|
|
||||||
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
|
||||||
user.profile.save()
|
|
||||||
|
|
||||||
visible_bookmarks = [
|
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
|
||||||
self.setup_bookmark(shared=True),
|
self.authenticate()
|
||||||
self.setup_bookmark(shared=True),
|
user = self.get_or_create_test_user()
|
||||||
self.setup_bookmark(shared=True)
|
user.profile.enable_sharing = True
|
||||||
]
|
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
|
user.profile.save()
|
||||||
|
|
||||||
response = self.client.get(reverse('bookmarks:shared'))
|
visible_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True),
|
||||||
|
self.setup_bookmark(shared=True),
|
||||||
|
self.setup_bookmark(shared=True)
|
||||||
|
]
|
||||||
|
|
||||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
response = self.client.get(reverse('bookmarks:shared'))
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
|||||||
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
|
|
||||||
self.tag1 = self.setup_tag()
|
self.tag1 = self.setup_tag()
|
||||||
self.tag2 = self.setup_tag()
|
self.tag2 = self.setup_tag()
|
||||||
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
|
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
|
||||||
@@ -26,6 +24,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
|
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
|
||||||
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
|
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
|
||||||
|
|
||||||
def assertBookmarkListEqual(self, data_list, bookmarks):
|
def assertBookmarkListEqual(self, data_list, bookmarks):
|
||||||
expectations = []
|
expectations = []
|
||||||
for bookmark in bookmarks:
|
for bookmark in bookmarks:
|
||||||
@@ -53,24 +55,34 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertCountEqual(data_list, expectations)
|
self.assertCountEqual(data_list, expectations)
|
||||||
|
|
||||||
def test_list_bookmarks(self):
|
def test_list_bookmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
|
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
|
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
|
||||||
|
|
||||||
def test_list_bookmarks_should_filter_by_query(self):
|
def test_list_bookmarks_should_filter_by_query(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
|
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
|
||||||
expected_status_code=status.HTTP_200_OK)
|
expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
|
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
|
||||||
|
|
||||||
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
|
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
|
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
|
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
|
||||||
|
|
||||||
def test_list_archived_bookmarks_should_filter_by_query(self):
|
def test_list_archived_bookmarks_should_filter_by_query(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
|
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
|
||||||
expected_status_code=status.HTTP_200_OK)
|
expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
|
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
|
||||||
|
|
||||||
def test_list_shared_bookmarks(self):
|
def test_list_shared_bookmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
user3 = self.setup_user(enable_sharing=True)
|
user3 = self.setup_user(enable_sharing=True)
|
||||||
@@ -89,7 +101,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
|
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
|
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
|
||||||
|
|
||||||
|
def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
shared_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True, user=user1),
|
||||||
|
self.setup_bookmark(shared=True, user=user1)
|
||||||
|
]
|
||||||
|
self.setup_bookmark(shared=True, user=user2)
|
||||||
|
self.setup_bookmark(shared=True, user=user2)
|
||||||
|
|
||||||
|
response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
|
||||||
|
|
||||||
def test_list_shared_bookmarks_should_filter_by_query_and_user(self):
|
def test_list_shared_bookmarks_should_filter_by_query_and_user(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
# Search by query
|
# Search by query
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
@@ -131,6 +159,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
|
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
|
||||||
|
|
||||||
def test_create_bookmark(self):
|
def test_create_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'url': 'https://example.com/',
|
'url': 'https://example.com/',
|
||||||
'title': 'Test title',
|
'title': 'Test title',
|
||||||
@@ -155,6 +185,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||||
|
|
||||||
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
|
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
original_bookmark = self.setup_bookmark()
|
original_bookmark = self.setup_bookmark()
|
||||||
data = {
|
data = {
|
||||||
'url': original_bookmark.url,
|
'url': original_bookmark.url,
|
||||||
@@ -182,6 +214,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||||
|
|
||||||
def test_create_bookmark_replaces_whitespace_in_tag_names(self):
|
def test_create_bookmark_replaces_whitespace_in_tag_names(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'url': 'https://example.com/',
|
'url': 'https://example.com/',
|
||||||
'title': 'Test title',
|
'title': 'Test title',
|
||||||
@@ -194,10 +228,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
|
self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
|
||||||
|
|
||||||
def test_create_bookmark_minimal_payload(self):
|
def test_create_bookmark_minimal_payload(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/'}
|
data = {'url': 'https://example.com/'}
|
||||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def test_create_archived_bookmark(self):
|
def test_create_archived_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'url': 'https://example.com/',
|
'url': 'https://example.com/',
|
||||||
'title': 'Test title',
|
'title': 'Test title',
|
||||||
@@ -216,41 +254,55 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||||
|
|
||||||
def test_create_bookmark_is_not_archived_by_default(self):
|
def test_create_bookmark_is_not_archived_by_default(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/'}
|
data = {'url': 'https://example.com/'}
|
||||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
bookmark = Bookmark.objects.get(url=data['url'])
|
bookmark = Bookmark.objects.get(url=data['url'])
|
||||||
self.assertFalse(bookmark.is_archived)
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
|
||||||
def test_create_unread_bookmark(self):
|
def test_create_unread_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/', 'unread': True}
|
data = {'url': 'https://example.com/', 'unread': True}
|
||||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
bookmark = Bookmark.objects.get(url=data['url'])
|
bookmark = Bookmark.objects.get(url=data['url'])
|
||||||
self.assertTrue(bookmark.unread)
|
self.assertTrue(bookmark.unread)
|
||||||
|
|
||||||
def test_create_bookmark_is_not_unread_by_default(self):
|
def test_create_bookmark_is_not_unread_by_default(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/'}
|
data = {'url': 'https://example.com/'}
|
||||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
bookmark = Bookmark.objects.get(url=data['url'])
|
bookmark = Bookmark.objects.get(url=data['url'])
|
||||||
self.assertFalse(bookmark.unread)
|
self.assertFalse(bookmark.unread)
|
||||||
|
|
||||||
def test_create_shared_bookmark(self):
|
def test_create_shared_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/', 'shared': True}
|
data = {'url': 'https://example.com/', 'shared': True}
|
||||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
bookmark = Bookmark.objects.get(url=data['url'])
|
bookmark = Bookmark.objects.get(url=data['url'])
|
||||||
self.assertTrue(bookmark.shared)
|
self.assertTrue(bookmark.shared)
|
||||||
|
|
||||||
def test_create_bookmark_is_not_shared_by_default(self):
|
def test_create_bookmark_is_not_shared_by_default(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/'}
|
data = {'url': 'https://example.com/'}
|
||||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
bookmark = Bookmark.objects.get(url=data['url'])
|
bookmark = Bookmark.objects.get(url=data['url'])
|
||||||
self.assertFalse(bookmark.shared)
|
self.assertFalse(bookmark.shared)
|
||||||
|
|
||||||
def test_get_bookmark(self):
|
def test_get_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual([response.data], [self.bookmark1])
|
self.assertBookmarkListEqual([response.data], [self.bookmark1])
|
||||||
|
|
||||||
def test_update_bookmark(self):
|
def test_update_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/'}
|
data = {'url': 'https://example.com/'}
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -258,11 +310,15 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(updated_bookmark.url, data['url'])
|
self.assertEqual(updated_bookmark.url, data['url'])
|
||||||
|
|
||||||
def test_update_bookmark_fails_without_required_fields(self):
|
def test_update_bookmark_fails_without_required_fields(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'title': 'https://example.com/'}
|
data = {'title': 'https://example.com/'}
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
|
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/'}
|
data = {'url': 'https://example.com/'}
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -274,6 +330,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(updated_bookmark.tag_names, [])
|
self.assertEqual(updated_bookmark.tag_names, [])
|
||||||
|
|
||||||
def test_update_bookmark_unread_flag(self):
|
def test_update_bookmark_unread_flag(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/', 'unread': True}
|
data = {'url': 'https://example.com/', 'unread': True}
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -281,6 +339,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(updated_bookmark.unread, True)
|
self.assertEqual(updated_bookmark.unread, True)
|
||||||
|
|
||||||
def test_update_bookmark_shared_flag(self):
|
def test_update_bookmark_shared_flag(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com/', 'shared': True}
|
data = {'url': 'https://example.com/', 'shared': True}
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -288,6 +348,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(updated_bookmark.shared, True)
|
self.assertEqual(updated_bookmark.shared, True)
|
||||||
|
|
||||||
def test_patch_bookmark(self):
|
def test_patch_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
data = {'url': 'https://example.com'}
|
data = {'url': 'https://example.com'}
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -344,6 +406,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
|
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
|
||||||
|
|
||||||
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
|
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
|
||||||
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||||
@@ -353,23 +417,31 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
|
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
|
||||||
|
|
||||||
def test_delete_bookmark(self):
|
def test_delete_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
|
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
|
||||||
|
|
||||||
def test_archive(self):
|
def test_archive(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
|
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
|
||||||
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||||
self.assertTrue(bookmark.is_archived)
|
self.assertTrue(bookmark.is_archived)
|
||||||
|
|
||||||
def test_unarchive(self):
|
def test_unarchive(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
|
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
|
||||||
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
|
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
|
||||||
self.assertFalse(bookmark.is_archived)
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
|
||||||
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
|
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
url = reverse('bookmarks:bookmark-check')
|
url = reverse('bookmarks:bookmark-check')
|
||||||
check_url = urllib.parse.quote_plus('https://example.com')
|
check_url = urllib.parse.quote_plus('https://example.com')
|
||||||
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -378,6 +450,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertIsNone(bookmark_data)
|
self.assertIsNone(bookmark_data)
|
||||||
|
|
||||||
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
|
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||||
expected_metadata = WebsiteMetadata(
|
expected_metadata = WebsiteMetadata(
|
||||||
'https://example.com',
|
'https://example.com',
|
||||||
@@ -397,6 +471,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertIsNotNone(expected_metadata.description, metadata['description'])
|
self.assertIsNotNone(expected_metadata.description, metadata['description'])
|
||||||
|
|
||||||
def test_check_returns_bookmark_if_url_is_bookmarked(self):
|
def test_check_returns_bookmark_if_url_is_bookmarked(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
bookmark = self.setup_bookmark(url='https://example.com',
|
bookmark = self.setup_bookmark(url='https://example.com',
|
||||||
title='Example title',
|
title='Example title',
|
||||||
description='Example description')
|
description='Example description')
|
||||||
@@ -413,6 +489,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(bookmark.description, bookmark_data['description'])
|
self.assertEqual(bookmark.description, bookmark_data['description'])
|
||||||
|
|
||||||
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
|
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
bookmark = self.setup_bookmark(url='https://example.com',
|
bookmark = self.setup_bookmark(url='https://example.com',
|
||||||
website_title='Existing title',
|
website_title='Existing title',
|
||||||
website_description='Existing description')
|
website_description='Existing description')
|
||||||
@@ -430,6 +508,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertIsNotNone(bookmark.website_description, metadata['description'])
|
self.assertIsNotNone(bookmark.website_description, metadata['description'])
|
||||||
|
|
||||||
def test_can_only_access_own_bookmarks(self):
|
def test_can_only_access_own_bookmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||||
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
|
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|||||||
113
bookmarks/tests/test_bookmarks_api_permissions.py
Normal file
113
bookmarks/tests/test_bookmarks_api_permissions.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||||
|
def authenticate(self) -> None:
|
||||||
|
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
|
||||||
|
|
||||||
|
def test_list_bookmarks_requires_authentication(self):
|
||||||
|
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_list_archived_bookmarks_requires_authentication(self):
|
||||||
|
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_list_shared_bookmarks_does_not_require_authentication(self):
|
||||||
|
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_create_bookmark_requires_authentication(self):
|
||||||
|
data = {
|
||||||
|
'url': 'https://example.com/',
|
||||||
|
'title': 'Test title',
|
||||||
|
'description': 'Test description',
|
||||||
|
'notes': 'Test notes',
|
||||||
|
'is_archived': False,
|
||||||
|
'unread': False,
|
||||||
|
'shared': False,
|
||||||
|
'tag_names': ['tag1', 'tag2']
|
||||||
|
}
|
||||||
|
|
||||||
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_get_bookmark_requires_authentication(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
|
||||||
|
|
||||||
|
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_update_bookmark_requires_authentication(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
data = {'url': 'https://example.com/'}
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
|
||||||
|
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_patch_bookmark_requires_authentication(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
data = {'url': 'https://example.com'}
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
|
||||||
|
|
||||||
|
self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_delete_bookmark_requires_authentication(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
|
||||||
|
|
||||||
|
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def test_archive_requires_authentication(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
url = reverse('bookmarks:bookmark-archive', args=[bookmark.id])
|
||||||
|
|
||||||
|
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def test_unarchive_requires_authentication(self):
|
||||||
|
bookmark = self.setup_bookmark(is_archived=True)
|
||||||
|
url = reverse('bookmarks:bookmark-unarchive', args=[bookmark.id])
|
||||||
|
|
||||||
|
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def test_check_requires_authentication(self):
|
||||||
|
url = reverse('bookmarks:bookmark-check')
|
||||||
|
check_url = urllib.parse.quote_plus('https://example.com')
|
||||||
|
|
||||||
|
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
|
from typing import Type
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.core.paginator import Paginator
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
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.views.partials import contexts
|
||||||
|
|
||||||
|
|
||||||
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank', unread: bool = False):
|
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
|
||||||
|
unread = bookmark.unread
|
||||||
|
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f'''
|
f'''
|
||||||
<a href="{bookmark.url}"
|
<a href="{bookmark.url}"
|
||||||
target="{link_target}"
|
target="{link_target}"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="{'text-italic' if unread else ''}">{bookmark.resolved_title}</a>
|
class="{'text-italic' if unread else ''}">
|
||||||
|
{favicon_img}
|
||||||
|
{bookmark.resolved_title}
|
||||||
|
</a>
|
||||||
''',
|
''',
|
||||||
html
|
html
|
||||||
)
|
)
|
||||||
@@ -52,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'''
|
||||||
@@ -61,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):
|
||||||
@@ -130,32 +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') -> str:
|
def render_template(self,
|
||||||
|
url='/bookmarks',
|
||||||
|
context_type: Type[contexts.BookmarkListContext] = contexts.ActiveBookmarkListContext,
|
||||||
|
user: User | AnonymousUser = None) -> str:
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
request.user = self.get_or_create_test_user()
|
request.user = user or self.get_or_create_test_user()
|
||||||
paginator = Paginator(bookmarks, 10)
|
middleware = UserProfileMiddleware(lambda r: HttpResponse())
|
||||||
page = paginator.page(1)
|
middleware(request)
|
||||||
|
|
||||||
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') -> str:
|
|
||||||
template = Template(
|
|
||||||
'{% load bookmarks %}'
|
|
||||||
'{% bookmark_list bookmarks return_url %}'
|
|
||||||
)
|
|
||||||
return self.render_template(bookmarks, template, url)
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -168,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)
|
||||||
@@ -176,86 +178,95 @@ 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):
|
||||||
bookmark = self.setup_bookmark()
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
|
profile.save()
|
||||||
|
|
||||||
html = self.render_template_with_link_target([bookmark], '_self')
|
bookmark = self.setup_bookmark()
|
||||||
|
html = self.render_template()
|
||||||
|
|
||||||
self.assertBookmarksLink(html, bookmark, link_target='_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', unread=False)
|
|
||||||
|
|
||||||
bookmark = self.setup_bookmark(unread=True)
|
|
||||||
html = self.render_template_with_link_target([bookmark], '_self')
|
|
||||||
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=True)
|
|
||||||
|
|
||||||
def test_web_archive_link_target_should_be_blank_by_default(self):
|
def test_web_archive_link_target_should_be_blank_by_default(self):
|
||||||
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_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
|
||||||
@@ -269,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)
|
||||||
|
|
||||||
@@ -279,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)
|
||||||
|
|
||||||
@@ -289,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)
|
||||||
|
|
||||||
@@ -298,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)
|
||||||
|
|
||||||
@@ -308,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)
|
||||||
|
|
||||||
@@ -318,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 = '<script>alert("test")</script>'
|
note_html = '<script>alert("test")</script>'
|
||||||
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)
|
||||||
|
|
||||||
@@ -388,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)
|
||||||
|
|
||||||
@@ -398,7 +408,35 @@ 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):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
bookmark.date_added = timezone.now() - relativedelta(days=8)
|
||||||
|
bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com'
|
||||||
|
bookmark.notes = '**Example:** `print("Hello world!")`'
|
||||||
|
bookmark.favicon_file = 'https_example_com.png'
|
||||||
|
bookmark.shared = True
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
|
||||||
|
self.assertBookmarksLink(html, bookmark, link_target='_blank')
|
||||||
|
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
|
||||||
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
|
self.assertShareInfo(html, bookmark)
|
||||||
|
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
|
||||||
|
self.assertNotes(html, note_html, 1)
|
||||||
|
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)
|
||||||
@@ -1,10 +1,36 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.services import exporter
|
from bookmarks.services import exporter
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def test_export_bookmarks(self):
|
||||||
|
added = timezone.now()
|
||||||
|
timestamp = int(added.timestamp())
|
||||||
|
|
||||||
|
bookmarks = [
|
||||||
|
self.setup_bookmark(url='https://example.com/1', title='Title 1', added=added,
|
||||||
|
description='Example description'),
|
||||||
|
self.setup_bookmark(url='https://example.com/2', title='Title 2', added=added,
|
||||||
|
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2'),
|
||||||
|
self.setup_tag(name='tag3')]),
|
||||||
|
self.setup_bookmark(url='https://example.com/3', title='Title 3', added=added, unread=True),
|
||||||
|
self.setup_bookmark(url='https://example.com/4', title='Title 4', added=added, shared=True),
|
||||||
|
|
||||||
|
]
|
||||||
|
html = exporter.export_netscape_html(bookmarks)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
|
||||||
|
'<DD>Example description',
|
||||||
|
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
|
||||||
|
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
|
||||||
|
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
|
||||||
|
]
|
||||||
|
self.assertIn('\n\r'.join(lines), html)
|
||||||
|
|
||||||
def test_escape_html_in_title_and_description(self):
|
def test_escape_html_in_title_and_description(self):
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
title='<style>: The Style Information element',
|
title='<style>: The Style Information element',
|
||||||
|
|||||||
@@ -2,25 +2,40 @@ import io
|
|||||||
import os.path
|
import os.path
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock, skip
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
from bookmarks.services import favicon_loader
|
from bookmarks.services import favicon_loader
|
||||||
|
|
||||||
mock_icon_data = b'mock_icon'
|
mock_icon_data = b'mock_icon'
|
||||||
|
|
||||||
|
|
||||||
|
class MockStreamingResponse:
|
||||||
|
def __init__(self, data=mock_icon_data, content_type='image/png'):
|
||||||
|
self.chunks = [data]
|
||||||
|
self.headers = {'Content-Type': content_type}
|
||||||
|
|
||||||
|
def iter_content(self, **kwargs):
|
||||||
|
return self.chunks
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FaviconLoaderTestCase(TestCase):
|
class FaviconLoaderTestCase(TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.ensure_favicon_folder()
|
self.ensure_favicon_folder()
|
||||||
self.clear_favicon_folder()
|
self.clear_favicon_folder()
|
||||||
|
|
||||||
def create_mock_response(self, icon_data=mock_icon_data):
|
def create_mock_response(self, icon_data=mock_icon_data, content_type='image/png'):
|
||||||
mock_response = mock.Mock()
|
mock_response = mock.Mock()
|
||||||
mock_response.raw = io.BytesIO(icon_data)
|
mock_response.raw = io.BytesIO(icon_data)
|
||||||
return mock_response
|
return MockStreamingResponse(icon_data, content_type)
|
||||||
|
|
||||||
def ensure_favicon_folder(self):
|
def ensure_favicon_folder(self):
|
||||||
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -93,12 +108,14 @@ class FaviconLoaderTestCase(TestCase):
|
|||||||
with mock.patch('requests.get') as mock_get:
|
with mock.patch('requests.get') as mock_get:
|
||||||
mock_get.return_value = self.create_mock_response()
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
|
||||||
favicon_loader.load_favicon('https://example.com')
|
favicon_file = favicon_loader.load_favicon('https://example.com')
|
||||||
mock_get.assert_called()
|
mock_get.assert_called()
|
||||||
|
self.assertEqual(favicon_file, 'https_example_com.png')
|
||||||
|
|
||||||
mock_get.reset_mock()
|
mock_get.reset_mock()
|
||||||
favicon_loader.load_favicon('https://example.com')
|
updated_favicon_file = favicon_loader.load_favicon('https://example.com')
|
||||||
mock_get.assert_not_called()
|
mock_get.assert_not_called()
|
||||||
|
self.assertEqual(favicon_file, updated_favicon_file)
|
||||||
|
|
||||||
def test_load_favicon_updates_stale_icon(self):
|
def test_load_favicon_updates_stale_icon(self):
|
||||||
with mock.patch('requests.get') as mock_get:
|
with mock.patch('requests.get') as mock_get:
|
||||||
@@ -125,3 +142,35 @@ class FaviconLoaderTestCase(TestCase):
|
|||||||
favicon_loader.load_favicon('https://example.com')
|
favicon_loader.load_favicon('https://example.com')
|
||||||
mock_get.assert_called()
|
mock_get.assert_called()
|
||||||
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))
|
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))
|
||||||
|
|
||||||
|
@override_settings(LD_FAVICON_PROVIDER='https://custom.icons.com/?url={url}')
|
||||||
|
def test_custom_provider_with_url_param(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
|
||||||
|
favicon_loader.load_favicon('https://example.com/foo?bar=baz')
|
||||||
|
mock_get.assert_called_with('https://custom.icons.com/?url=https://example.com', stream=True)
|
||||||
|
|
||||||
|
@override_settings(LD_FAVICON_PROVIDER='https://custom.icons.com/?url={domain}')
|
||||||
|
def test_custom_provider_with_domain_param(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
|
||||||
|
favicon_loader.load_favicon('https://example.com/foo?bar=baz')
|
||||||
|
mock_get.assert_called_with('https://custom.icons.com/?url=example.com', stream=True)
|
||||||
|
|
||||||
|
def test_guess_file_extension(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response(content_type='image/png')
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
|
||||||
|
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||||
|
|
||||||
|
self.clear_favicon_folder()
|
||||||
|
self.ensure_favicon_folder()
|
||||||
|
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response(content_type='image/x-icon')
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
|
||||||
|
self.assertTrue(self.icon_exists('https_example_com.ico'))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag, parse_tag_string
|
from bookmarks.models import Bookmark, Tag, parse_tag_string
|
||||||
from bookmarks.services import tasks
|
from bookmarks.services import tasks
|
||||||
from bookmarks.services.importer import import_netscape_html
|
from bookmarks.services.importer import import_netscape_html, ImportOptions
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, ImportTestMixin, BookmarkHtmlTag, disable_logging
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, ImportTestMixin, BookmarkHtmlTag, disable_logging
|
||||||
from bookmarks.utils import parse_timestamp
|
from bookmarks.utils import parse_timestamp
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
self.assertEqual(bookmark.description, html_tag.description)
|
self.assertEqual(bookmark.description, html_tag.description)
|
||||||
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
|
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
|
||||||
self.assertEqual(bookmark.unread, html_tag.to_read)
|
self.assertEqual(bookmark.unread, html_tag.to_read)
|
||||||
|
self.assertEqual(bookmark.shared, not html_tag.private)
|
||||||
|
|
||||||
tag_names = parse_tag_string(html_tag.tags)
|
tag_names = parse_tag_string(html_tag.tags)
|
||||||
|
|
||||||
@@ -66,35 +67,46 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
add_date='3', tags='bar-tag, other-tag'),
|
add_date='3', tags='bar-tag, other-tag'),
|
||||||
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
|
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
|
||||||
add_date='3', to_read=True),
|
add_date='3', to_read=True),
|
||||||
|
BookmarkHtmlTag(href='https://example.com/private', title='Private title', description='Private description',
|
||||||
|
add_date='4', private=True),
|
||||||
]
|
]
|
||||||
import_html = self.render_html(tags=html_tags)
|
import_html = self.render_html(tags=html_tags)
|
||||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||||
|
|
||||||
|
# Check bookmarks
|
||||||
|
bookmarks = Bookmark.objects.all()
|
||||||
|
self.assertEqual(len(bookmarks), 5)
|
||||||
|
self.assertBookmarksImported(html_tags)
|
||||||
|
|
||||||
# Change data, add some new data
|
# Change data, add some new data
|
||||||
html_tags = [
|
html_tags = [
|
||||||
BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
|
BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
|
||||||
description='Updated Example description', add_date='111', tags='updated-example-tag'),
|
description='Updated Example description', add_date='111', tags='updated-example-tag'),
|
||||||
BookmarkHtmlTag(href='https://example.com/foo', title='Updated Foo title', description='Updated Foo description',
|
BookmarkHtmlTag(href='https://example.com/foo', title='Updated Foo title',
|
||||||
|
description='Updated Foo description',
|
||||||
add_date='222', tags='new-tag'),
|
add_date='222', tags='new-tag'),
|
||||||
BookmarkHtmlTag(href='https://example.com/bar', title='Updated Bar title', description='Updated Bar description',
|
BookmarkHtmlTag(href='https://example.com/bar', title='Updated Bar title',
|
||||||
|
description='Updated Bar description',
|
||||||
add_date='333', tags='updated-bar-tag, updated-other-tag'),
|
add_date='333', tags='updated-bar-tag, updated-other-tag'),
|
||||||
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
|
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
|
||||||
add_date='3', to_read=False),
|
add_date='3', to_read=False),
|
||||||
|
BookmarkHtmlTag(href='https://example.com/private', title='Private title', description='Private description',
|
||||||
|
add_date='4', private=False),
|
||||||
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
|
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
|
||||||
]
|
]
|
||||||
|
|
||||||
# Import updated data
|
# Import updated data
|
||||||
import_html = self.render_html(tags=html_tags)
|
import_html = self.render_html(tags=html_tags)
|
||||||
result = import_netscape_html(import_html, self.get_or_create_test_user())
|
result = import_netscape_html(import_html, self.get_or_create_test_user(), ImportOptions(map_private_flag=True))
|
||||||
|
|
||||||
# Check result
|
# Check result
|
||||||
self.assertEqual(result.total, 5)
|
self.assertEqual(result.total, 6)
|
||||||
self.assertEqual(result.success, 5)
|
self.assertEqual(result.success, 6)
|
||||||
self.assertEqual(result.failed, 0)
|
self.assertEqual(result.failed, 0)
|
||||||
|
|
||||||
# Check bookmarks
|
# Check bookmarks
|
||||||
bookmarks = Bookmark.objects.all()
|
bookmarks = Bookmark.objects.all()
|
||||||
self.assertEqual(len(bookmarks), 5)
|
self.assertEqual(len(bookmarks), 6)
|
||||||
self.assertBookmarksImported(html_tags)
|
self.assertBookmarksImported(html_tags)
|
||||||
|
|
||||||
def test_import_with_some_invalid_bookmarks(self):
|
def test_import_with_some_invalid_bookmarks(self):
|
||||||
@@ -254,6 +266,33 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
self.assertEqual(import_result.success, 0)
|
self.assertEqual(import_result.success, 0)
|
||||||
self.assertEqual(import_result.failed, 2)
|
self.assertEqual(import_result.failed, 2)
|
||||||
|
|
||||||
|
def test_private_flag(self):
|
||||||
|
# does not map private flag if not enabled in options
|
||||||
|
test_html = self.render_html(tags_html='''
|
||||||
|
<DT><A HREF="https://example.com/1" ADD_DATE="1">Example title 1</A>
|
||||||
|
<DD>Example description 1</DD>
|
||||||
|
<DT><A HREF="https://example.com/2" ADD_DATE="1" PRIVATE="1">Example title 2</A>
|
||||||
|
<DD>Example description 2</DD>
|
||||||
|
<DT><A HREF="https://example.com/3" ADD_DATE="1" PRIVATE="0">Example title 3</A>
|
||||||
|
<DD>Example description 3</DD>
|
||||||
|
''')
|
||||||
|
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions())
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 3)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[0].shared, False)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[1].shared, False)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[2].shared, False)
|
||||||
|
|
||||||
|
# does map private flag if enabled in options
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
import_netscape_html(test_html, self.get_or_create_test_user(), ImportOptions(map_private_flag=True))
|
||||||
|
bookmark1 = Bookmark.objects.get(url='https://example.com/1')
|
||||||
|
bookmark2 = Bookmark.objects.get(url='https://example.com/2')
|
||||||
|
bookmark3 = Bookmark.objects.get(url='https://example.com/3')
|
||||||
|
self.assertEqual(bookmark1.shared, False)
|
||||||
|
self.assertEqual(bookmark2.shared, False)
|
||||||
|
self.assertEqual(bookmark3.shared, True)
|
||||||
|
|
||||||
def test_schedule_snapshot_creation(self):
|
def test_schedule_snapshot_creation(self):
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
test_html = self.render_html(tags_html='')
|
test_html = self.render_html(tags_html='')
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.template import Template, RequestContext
|
from django.template import Template, RequestContext
|
||||||
from django.test import SimpleTestCase, RequestFactory
|
from django.test import TestCase, RequestFactory
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
class PaginationTagTest(SimpleTestCase):
|
class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def render_template(self, num_items: int, page_size: int, current_page: int, url: str = '/test') -> str:
|
def render_template(self, num_items: int, page_size: int, current_page: int, url: str = '/test') -> str:
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
|
request.user = self.get_or_create_test_user()
|
||||||
|
request.user_profile = self.get_or_create_test_user().profile
|
||||||
paginator = Paginator(range(0, num_items), page_size)
|
paginator = Paginator(range(0, num_items), page_size)
|
||||||
page = paginator.page(current_page)
|
page = paginator.page(current_page)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
self.assertEqual(bookmark.description, html_tag.description)
|
self.assertEqual(bookmark.description, html_tag.description)
|
||||||
self.assertEqual(bookmark.tag_string, html_tag.tags)
|
self.assertEqual(bookmark.tag_string, html_tag.tags)
|
||||||
self.assertEqual(bookmark.to_read, html_tag.to_read)
|
self.assertEqual(bookmark.to_read, html_tag.to_read)
|
||||||
|
self.assertEqual(bookmark.private, html_tag.private)
|
||||||
|
|
||||||
def test_parse_bookmarks(self):
|
def test_parse_bookmarks(self):
|
||||||
html_tags = [
|
html_tags = [
|
||||||
@@ -123,3 +124,28 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
bookmarks = parse(html)
|
bookmarks = parse(html)
|
||||||
|
|
||||||
self.assertTagsEqual(bookmarks, html_tags)
|
self.assertTagsEqual(bookmarks, html_tags)
|
||||||
|
|
||||||
|
def test_private_flag(self):
|
||||||
|
# is private by default
|
||||||
|
html = self.render_html(tags_html='''
|
||||||
|
<DT><A HREF="https://example.com" ADD_DATE="1">Example title</A>
|
||||||
|
<DD>Example description</DD>
|
||||||
|
''')
|
||||||
|
bookmarks = parse(html)
|
||||||
|
self.assertEqual(bookmarks[0].private, True)
|
||||||
|
|
||||||
|
# explicitly marked as private
|
||||||
|
html = self.render_html(tags_html='''
|
||||||
|
<DT><A HREF="https://example.com" ADD_DATE="1" PRIVATE="1">Example title</A>
|
||||||
|
<DD>Example description</DD>
|
||||||
|
''')
|
||||||
|
bookmarks = parse(html)
|
||||||
|
self.assertEqual(bookmarks[0].private, True)
|
||||||
|
|
||||||
|
# explicitly marked as public
|
||||||
|
html = self.render_html(tags_html='''
|
||||||
|
<DT><A HREF="https://example.com" ADD_DATE="1" PRIVATE="0">Example title</A>
|
||||||
|
<DD>Example description</DD>
|
||||||
|
''')
|
||||||
|
bookmarks = parse(html)
|
||||||
|
self.assertEqual(bookmarks[0].private, False)
|
||||||
|
|||||||
@@ -679,16 +679,26 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.setup_bookmark(user=user4, shared=True, tags=[tag]),
|
self.setup_bookmark(user=user4, shared=True, tags=[tag]),
|
||||||
|
|
||||||
# Should return shared bookmarks from all users
|
# Should return shared bookmarks from all users
|
||||||
query_set = queries.query_shared_bookmarks(None, self.profile, '')
|
query_set = queries.query_shared_bookmarks(None, self.profile, '', False)
|
||||||
self.assertQueryResult(query_set, [shared_bookmarks])
|
self.assertQueryResult(query_set, [shared_bookmarks])
|
||||||
|
|
||||||
# Should respect search query
|
# Should respect search query
|
||||||
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title')
|
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title', False)
|
||||||
self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
|
self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
|
||||||
|
|
||||||
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name)
|
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name, False)
|
||||||
self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
|
self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
|
||||||
|
|
||||||
|
def test_query_publicly_shared_bookmarks(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
bookmark1 = self.setup_bookmark(user=user1, shared=True)
|
||||||
|
self.setup_bookmark(user=user2, shared=True)
|
||||||
|
|
||||||
|
query_set = queries.query_shared_bookmarks(None, self.profile, '', True)
|
||||||
|
self.assertQueryResult(query_set, [[bookmark1]])
|
||||||
|
|
||||||
def test_query_shared_bookmark_tags(self):
|
def test_query_shared_bookmark_tags(self):
|
||||||
user1 = self.setup_user(enable_sharing=True)
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
@@ -710,10 +720,24 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
|
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
|
||||||
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
|
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
|
||||||
|
|
||||||
query_set = queries.query_shared_bookmark_tags(None, self.profile, '')
|
query_set = queries.query_shared_bookmark_tags(None, self.profile, '', False)
|
||||||
|
|
||||||
self.assertQueryResult(query_set, [shared_tags])
|
self.assertQueryResult(query_set, [shared_tags])
|
||||||
|
|
||||||
|
def test_query_publicly_shared_bookmark_tags(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
tag1 = self.setup_tag(user=user1)
|
||||||
|
tag2 = self.setup_tag(user=user2)
|
||||||
|
|
||||||
|
self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
|
||||||
|
self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
|
||||||
|
|
||||||
|
query_set = queries.query_shared_bookmark_tags(None, self.profile, '', True)
|
||||||
|
|
||||||
|
self.assertQueryResult(query_set, [[tag1]])
|
||||||
|
|
||||||
def test_query_shared_bookmark_users(self):
|
def test_query_shared_bookmark_users(self):
|
||||||
users_with_shared_bookmarks = [
|
users_with_shared_bookmarks = [
|
||||||
self.setup_user(enable_sharing=True),
|
self.setup_user(enable_sharing=True),
|
||||||
@@ -735,9 +759,19 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
|
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
|
||||||
|
|
||||||
# Should return users with shared bookmarks
|
# Should return users with shared bookmarks
|
||||||
query_set = queries.query_shared_bookmark_users(self.profile, '')
|
query_set = queries.query_shared_bookmark_users(self.profile, '', False)
|
||||||
self.assertQueryResult(query_set, [users_with_shared_bookmarks])
|
self.assertQueryResult(query_set, [users_with_shared_bookmarks])
|
||||||
|
|
||||||
# Should respect search query
|
# Should respect search query
|
||||||
query_set = queries.query_shared_bookmark_users(self.profile, 'test title')
|
query_set = queries.query_shared_bookmark_users(self.profile, 'test title', False)
|
||||||
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
|
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
|
||||||
|
|
||||||
|
def test_query_publicly_shared_bookmark_users(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
self.setup_bookmark(user=user1, shared=True)
|
||||||
|
self.setup_bookmark(user=user2, shared=True)
|
||||||
|
|
||||||
|
query_set = queries.query_shared_bookmark_users(self.profile, '', True)
|
||||||
|
self.assertQueryResult(query_set, [[user1]])
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
|
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
|
||||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||||
'enable_sharing': False,
|
'enable_sharing': False,
|
||||||
|
'enable_public_sharing': False,
|
||||||
'enable_favicons': False,
|
'enable_favicons': False,
|
||||||
'tag_search': UserProfile.TAG_SEARCH_STRICT,
|
'tag_search': UserProfile.TAG_SEARCH_STRICT,
|
||||||
'display_url': False,
|
'display_url': False,
|
||||||
@@ -54,6 +55,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
||||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||||
'enable_sharing': True,
|
'enable_sharing': True,
|
||||||
|
'enable_public_sharing': True,
|
||||||
'enable_favicons': True,
|
'enable_favicons': True,
|
||||||
'tag_search': UserProfile.TAG_SEARCH_LAX,
|
'tag_search': UserProfile.TAG_SEARCH_LAX,
|
||||||
'display_url': True,
|
'display_url': True,
|
||||||
@@ -70,6 +72,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
|
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
|
||||||
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
|
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
|
||||||
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
|
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
|
||||||
|
self.assertEqual(self.user.profile.enable_public_sharing, form_data['enable_public_sharing'])
|
||||||
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
|
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
|
||||||
self.assertEqual(self.user.profile.tag_search, form_data['tag_search'])
|
self.assertEqual(self.user.profile.tag_search, form_data['tag_search'])
|
||||||
self.assertEqual(self.user.profile.display_url, form_data['display_url'])
|
self.assertEqual(self.user.profile.display_url, form_data['display_url'])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -77,3 +78,30 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertRedirects(response, reverse('bookmarks:settings.general'))
|
self.assertRedirects(response, reverse('bookmarks:settings.general'))
|
||||||
self.assertFormSuccessHint(response, '2 bookmarks were successfully imported')
|
self.assertFormSuccessHint(response, '2 bookmarks were successfully imported')
|
||||||
self.assertFormErrorHint(response, '1 bookmarks could not be imported')
|
self.assertFormErrorHint(response, '1 bookmarks could not be imported')
|
||||||
|
|
||||||
|
def test_should_respect_map_private_flag_option(self):
|
||||||
|
with open('bookmarks/tests/resources/simple_valid_import_file.html') as import_file:
|
||||||
|
self.client.post(
|
||||||
|
reverse('bookmarks:settings.import'),
|
||||||
|
{'import_file': import_file},
|
||||||
|
follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 3)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[0].shared, False)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[1].shared, False)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[2].shared, False)
|
||||||
|
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
|
||||||
|
with open('bookmarks/tests/resources/simple_valid_import_file.html') as import_file:
|
||||||
|
self.client.post(
|
||||||
|
reverse('bookmarks:settings.import'),
|
||||||
|
{'import_file': import_file, 'map_private_flag': 'on'},
|
||||||
|
follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 3)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[0].shared, True)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[1].shared, True)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[2].shared, True)
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
from typing import List
|
from typing import List, Type
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User, AnonymousUser
|
||||||
|
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 bookmarks.models import Tag, UserProfile
|
from bookmarks.middlewares import UserProfileMiddleware
|
||||||
|
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,
|
||||||
if not selected_tags:
|
context_type: Type[contexts.TagCloudContext] = contexts.ActiveTagCloudContext,
|
||||||
selected_tags = []
|
url: str = '/test',
|
||||||
|
user: User | AnonymousUser = None):
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
request.user = self.get_or_create_test_user()
|
request.user = user or self.get_or_create_test_user()
|
||||||
context = RequestContext(request, {
|
middleware = UserProfileMiddleware(lambda r: HttpResponse())
|
||||||
'request': request,
|
middleware(request)
|
||||||
'tags': tags,
|
|
||||||
'selected_tags': selected_tags,
|
tag_cloud_context = context_type(request)
|
||||||
})
|
context = RequestContext(request, {'tag_cloud': tag_cloud_context})
|
||||||
template_to_render = Template(
|
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)
|
||||||
|
|
||||||
@@ -48,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'),
|
||||||
@@ -58,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, [
|
||||||
[
|
[
|
||||||
@@ -82,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, [
|
||||||
[
|
[
|
||||||
@@ -100,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)
|
||||||
|
|
||||||
@@ -128,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)
|
||||||
|
|
||||||
@@ -153,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="
|
||||||
@@ -165,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="
|
||||||
@@ -181,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>
|
||||||
@@ -199,13 +212,47 @@ 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):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
tags = [
|
||||||
|
self.setup_tag(name='tag1'),
|
||||||
|
self.setup_tag(name='tag2'),
|
||||||
|
self.setup_tag(name='tag3'),
|
||||||
|
self.setup_tag(name='tag4'),
|
||||||
|
self.setup_tag(name='tag5'),
|
||||||
|
]
|
||||||
|
self.setup_bookmark(tags=tags, shared=True)
|
||||||
|
|
||||||
|
rendered_template = self.render_template(context_type=contexts.SharedTagCloudContext,
|
||||||
|
url='/test?q=%23tag1 %23tag2',
|
||||||
|
user=AnonymousUser())
|
||||||
|
|
||||||
|
self.assertTagGroups(rendered_template, [
|
||||||
|
['tag3', 'tag4', 'tag5']
|
||||||
|
])
|
||||||
|
self.assertNumSelectedTags(rendered_template, 2)
|
||||||
|
self.assertInHTML('''
|
||||||
|
<a href="?q=%23tag2"
|
||||||
|
class="text-bold mr-2">
|
||||||
|
<span>-tag1</span>
|
||||||
|
</a>
|
||||||
|
''', rendered_template)
|
||||||
|
|
||||||
|
self.assertInHTML('''
|
||||||
|
<a href="?q=%23tag1"
|
||||||
|
class="text-bold mr-2">
|
||||||
|
<span>-tag2</span>
|
||||||
|
</a>
|
||||||
|
''', rendered_template)
|
||||||
@@ -10,6 +10,8 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
def render_template(self, url: str, users: QuerySet[User] = User.objects.all()):
|
def render_template(self, url: str, users: QuerySet[User] = User.objects.all()):
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
|
request.user = self.get_or_create_test_user()
|
||||||
|
request.user_profile = self.get_or_create_test_user().profile
|
||||||
filters = BookmarkFilters(request)
|
filters = BookmarkFilters(request)
|
||||||
context = RequestContext(request, {
|
context = RequestContext(request, {
|
||||||
'request': request,
|
'request': request,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -6,6 +7,13 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.template.defaultfilters import pluralize
|
from django.template.defaultfilters import pluralize
|
||||||
from django.utils import timezone, formats
|
from django.utils import timezone, formats
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("version.txt", "r") as f:
|
||||||
|
app_version = f.read().strip("\n")
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception(exc)
|
||||||
|
app_version = ''
|
||||||
|
|
||||||
|
|
||||||
def unique(elements, key):
|
def unique(elements, key):
|
||||||
return list({key(element): element for element in elements}.values())
|
return list({key(element): element for element in elements}.values())
|
||||||
|
|||||||
@@ -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)
|
})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
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)
|
||||||
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
tag_cloud = contexts.SharedTagCloudContext(request)
|
||||||
tags = queries.query_shared_bookmark_tags(user, request.user.profile, filters.query)
|
public_only = not request.user.is_authenticated
|
||||||
users = queries.query_shared_bookmark_users(request.user.profile, filters.query)
|
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):
|
||||||
|
|||||||
58
bookmarks/views/partials/__init__.py
Normal file
58
bookmarks/views/partials/__init__.py
Normal 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
|
||||||
|
})
|
||||||
168
bookmarks/views/partials/contexts.py
Normal file
168
bookmarks/views/partials/contexts.py
Normal 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)
|
||||||
@@ -16,16 +16,10 @@ from bookmarks.models import UserProfileForm, FeedToken
|
|||||||
from bookmarks.queries import query_bookmarks
|
from bookmarks.queries import query_bookmarks
|
||||||
from bookmarks.services import exporter, tasks
|
from bookmarks.services import exporter, tasks
|
||||||
from bookmarks.services import importer
|
from bookmarks.services import importer
|
||||||
|
from bookmarks.utils import app_version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
|
||||||
with open("version.txt", "r") as f:
|
|
||||||
app_version = f.read().strip("\n")
|
|
||||||
except Exception as exc:
|
|
||||||
logging.exception(exc)
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def general(request):
|
def general(request):
|
||||||
@@ -46,7 +40,7 @@ def general(request):
|
|||||||
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
|
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
|
||||||
|
|
||||||
if not profile_form:
|
if not profile_form:
|
||||||
profile_form = UserProfileForm(instance=request.user.profile)
|
profile_form = UserProfileForm(instance=request.user_profile)
|
||||||
|
|
||||||
return render(request, 'settings/general.html', {
|
return render(request, 'settings/general.html', {
|
||||||
'form': profile_form,
|
'form': profile_form,
|
||||||
@@ -116,6 +110,7 @@ def integrations(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def bookmark_import(request):
|
def bookmark_import(request):
|
||||||
import_file = request.FILES.get('import_file')
|
import_file = request.FILES.get('import_file')
|
||||||
|
import_options = importer.ImportOptions(map_private_flag=request.POST.get('map_private_flag') == 'on')
|
||||||
|
|
||||||
if import_file is None:
|
if import_file is None:
|
||||||
messages.error(request, 'Please select a file to import.', 'bookmark_import_errors')
|
messages.error(request, 'Please select a file to import.', 'bookmark_import_errors')
|
||||||
@@ -123,7 +118,7 @@ def bookmark_import(request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
content = import_file.read().decode()
|
content = import_file.read().decode()
|
||||||
result = importer.import_netscape_html(content, request.user)
|
result = importer.import_netscape_html(content, request.user, import_options)
|
||||||
success_msg = str(result.success) + ' bookmarks were successfully imported.'
|
success_msg = str(result.success) + ' bookmarks were successfully imported.'
|
||||||
messages.success(request, success_msg, 'bookmark_import_success')
|
messages.success(request, success_msg, 'bookmark_import_success')
|
||||||
if result.failed > 0:
|
if result.failed > 0:
|
||||||
@@ -141,7 +136,7 @@ def bookmark_import(request):
|
|||||||
def bookmark_export(request):
|
def bookmark_export(request):
|
||||||
# noinspection PyBroadException
|
# noinspection PyBroadException
|
||||||
try:
|
try:
|
||||||
bookmarks = list(query_bookmarks(request.user, request.user.profile, ''))
|
bookmarks = list(query_bookmarks(request.user, request.user_profile, ''))
|
||||||
# Prefetch tags to prevent n+1 queries
|
# Prefetch tags to prevent n+1 queries
|
||||||
prefetch_related_objects(bookmarks, 'tags')
|
prefetch_related_objects(bookmarks, 'tags')
|
||||||
file_content = exporter.export_netscape_html(bookmarks)
|
file_content = exporter.export_netscape_html(bookmarks)
|
||||||
|
|||||||
@@ -164,12 +164,20 @@ A json string with additional options for the database. Passed directly to OPTIO
|
|||||||
|
|
||||||
### `LD_FAVICON_PROVIDER`
|
### `LD_FAVICON_PROVIDER`
|
||||||
|
|
||||||
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
|
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32`
|
||||||
|
|
||||||
The favicon provider used for downloading icons if they are enabled in the user profile settings.
|
The favicon provider used for downloading icons if they are enabled in the user profile settings.
|
||||||
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
|
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
|
||||||
|
|
||||||
This setting allows to configure a custom provider in form of a URL.
|
This setting allows to configure a custom provider in form of a URL.
|
||||||
When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
|
When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
|
||||||
The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon.
|
The configured favicon provider URL must contain a placeholder that will be replaced with the URL of the website for which to download the favicon.
|
||||||
See the default URL for an example.
|
The available placeholders are:
|
||||||
|
- `{url}` - Includes the scheme and hostname of the website, for example `https://example.com`
|
||||||
|
- `{domain}` - Includes only the hostname of the website, for example `example.com`
|
||||||
|
|
||||||
|
Which placeholder you need to use depends on the respective favicon provider, please check their documentation or usage examples.
|
||||||
|
See the default URL for how to insert the placeholder to the favicon provider URL.
|
||||||
|
|
||||||
|
Alternative favicon providers:
|
||||||
|
- DuckDuckGo: `https://icons.duckduckgo.com/ip3/{domain}.ico`
|
||||||
|
|||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.19.1",
|
"version": "1.20.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'bookmarks.middlewares.UserProfileMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
@@ -71,6 +72,8 @@ TEMPLATES = [
|
|||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'bookmarks.context_processors.toasts',
|
'bookmarks.context_processors.toasts',
|
||||||
|
'bookmarks.context_processors.public_shares',
|
||||||
|
'bookmarks.context_processors.app_version',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -229,7 +232,7 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Favicons
|
# Favicons
|
||||||
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON'
|
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32'
|
||||||
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
|
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
|
||||||
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
|
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
|
||||||
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')
|
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.19.1
|
1.20.1
|
||||||
|
|||||||
Reference in New Issue
Block a user