mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a73910d9c7 | ||
![]() |
0c1c21c8d1 | ||
![]() |
2e4f271490 | ||
![]() |
61b13dc531 | ||
![]() |
e976fd054c | ||
![]() |
119d8f7efb | ||
![]() |
3e5e825032 | ||
![]() |
dc1f6f9c44 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## v1.5.0 (28/03/2021)
|
||||
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
|
||||
---
|
||||
|
||||
## v1.4.1 (20/03/2021)
|
||||
- Security patches
|
||||
- Documentation improvements
|
||||
|
||||
---
|
||||
|
||||
## v1.4.0 (24/02/2021)
|
||||
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
|
||||
|
||||
|
@@ -11,17 +11,19 @@ The name comes from:
|
||||
**Feature Overview:**
|
||||
- Tags for organizing bookmarks
|
||||
- Search by text or tags
|
||||
- Bulk editing
|
||||
- Bookmark archive
|
||||
- Automatically provides titles and descriptions from linked websites
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Bookmark archive
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
- Bookmarklet that should work in most browsers
|
||||
- Dark mode
|
||||
- Easy to set up using Docker
|
||||
- Uses SQLite as database
|
||||
- Works without Javascript
|
||||
- ...but has several UI enhancements when Javascript is enabled
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and bulk operations
|
||||
- Admin panel for user self-service and raw data access
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
||||
@@ -161,4 +163,4 @@ The frontend is now available under http://localhost:8000
|
||||
|
||||
## Community
|
||||
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
|
@@ -7,7 +7,7 @@ from django.utils.translation import ngettext, gettext
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -77,8 +77,24 @@ class AdminTag(admin.ModelAdmin):
|
||||
), messages.SUCCESS)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile'
|
||||
fk_name = 'user'
|
||||
|
||||
|
||||
class AdminCustomUser(UserAdmin):
|
||||
inlines = (AdminUserProfileInline,)
|
||||
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
if not obj:
|
||||
return list()
|
||||
return super(AdminCustomUser, self).get_inline_instances(request, obj)
|
||||
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(User, UserAdmin)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(Token, TokenAdmin)
|
||||
|
@@ -264,17 +264,4 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* TODO: Should be read from theme */
|
||||
.menu-item.selected > a {
|
||||
background: #f1f1fc;
|
||||
color: #5755d9;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: #999999;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
@@ -4,8 +4,10 @@
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let tags;
|
||||
export let apiClient;
|
||||
export let variant = 'default';
|
||||
|
||||
let tags = [];
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
@@ -13,6 +15,18 @@
|
||||
let suggestions = [];
|
||||
let selectedIndex = 0;
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
// For now we cache all tags on load as the template did before
|
||||
try {
|
||||
tags = await apiClient.getTags({limit: 1000, offset: 0});
|
||||
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
||||
} catch (e) {
|
||||
console.warn('TagAutocomplete: Error loading tag list');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
@@ -27,7 +41,9 @@
|
||||
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
|
||||
suggestions = word
|
||||
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
||||
: [];
|
||||
|
||||
if (word && suggestions.length > 0) {
|
||||
open();
|
||||
@@ -70,7 +86,7 @@
|
||||
function complete(suggestion) {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end);
|
||||
|
||||
close();
|
||||
}
|
||||
@@ -87,11 +103,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-autocomplete">
|
||||
<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 ||''}"
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||
class="form-input" type="text" autocomplete="off"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
@@ -105,7 +121,7 @@
|
||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{tag}
|
||||
{tag.name}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -125,9 +141,16 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* TODO: Should be read from theme */
|
||||
.menu-item.selected > a {
|
||||
background: #f1f1fc;
|
||||
color: #5755d9;
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
}
|
||||
</style>
|
||||
.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>
|
||||
|
@@ -5,7 +5,7 @@ export class ApiClient {
|
||||
|
||||
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||
const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
@@ -14,7 +14,15 @@ export class ApiClient {
|
||||
|
||||
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||
const url = `${this.baseUrl}bookmarks/archived/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => data.results)
|
||||
}
|
||||
|
||||
getTags(options = {limit: 100, offset: 0}) {
|
||||
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
|
43
bookmarks/migrations/0007_userprofile.py
Normal file
43
bookmarks/migrations/0007_userprofile.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 2.2.18 on 2021-03-26 22:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
User = apps.get_model('auth', 'User')
|
||||
UserProfile = apps.get_model('bookmarks', 'UserProfile')
|
||||
for user in User.objects.all():
|
||||
try:
|
||||
if user.profile:
|
||||
continue
|
||||
except UserProfile.DoesNotExist:
|
||||
profile = UserProfile(user=user)
|
||||
profile.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0006_bookmark_is_archived'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('theme',
|
||||
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
|
||||
max_length=10)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
|
||||
to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
@@ -2,7 +2,10 @@ from typing import List
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
@@ -93,3 +96,33 @@ class BookmarkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url']
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
THEME_AUTO = 'auto'
|
||||
THEME_LIGHT = 'light'
|
||||
THEME_DARK = 'dark'
|
||||
THEME_CHOICES = [
|
||||
(THEME_AUTO, 'Auto'),
|
||||
(THEME_LIGHT, 'Light'),
|
||||
(THEME_DARK, 'Dark'),
|
||||
]
|
||||
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
|
||||
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ['theme']
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
||||
|
@@ -1,3 +1,5 @@
|
||||
from typing import Union
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -46,6 +48,13 @@ def archive_bookmark(bookmark: Bookmark):
|
||||
return bookmark
|
||||
|
||||
|
||||
def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(is_archived=True, date_modified=timezone.now())
|
||||
|
||||
|
||||
def unarchive_bookmark(bookmark: Bookmark):
|
||||
bookmark.is_archived = False
|
||||
bookmark.date_modified = timezone.now()
|
||||
@@ -53,6 +62,46 @@ def unarchive_bookmark(bookmark: Bookmark):
|
||||
return bookmark
|
||||
|
||||
|
||||
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(is_archived=False, date_modified=timezone.now())
|
||||
|
||||
|
||||
def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.delete()
|
||||
|
||||
|
||||
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
tag_names = parse_tag_string(tag_string, ' ')
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags.add(*tags)
|
||||
bookmark.date_modified = timezone.now()
|
||||
|
||||
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||
|
||||
|
||||
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
tag_names = parse_tag_string(tag_string, ' ')
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags.remove(*tags)
|
||||
bookmark.date_modified = timezone.now()
|
||||
|
||||
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
@@ -68,3 +117,8 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string, ' ')
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
bookmark.tags.set(tags)
|
||||
|
||||
|
||||
def _sanitize_id_list(bookmark_ids: [Union[int, str]]) -> [int]:
|
||||
# Convert string ids to int if necessary
|
||||
return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids]
|
||||
|
86
bookmarks/static/bulk_edit.js
Normal file
86
bookmarks/static/bulk_edit.js
Normal file
@@ -0,0 +1,86 @@
|
||||
(function () {
|
||||
const bulkEditToggle = document.getElementById('bulk-edit-mode')
|
||||
const bulkEditBar = document.querySelector('.bulk-edit-bar')
|
||||
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
|
||||
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
|
||||
|
||||
function isAllSelected() {
|
||||
let result = true
|
||||
|
||||
singleToggles.forEach(function (toggle) {
|
||||
result = result && toggle.checked
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
singleToggles.forEach(function (toggle) {
|
||||
toggle.checked = true
|
||||
})
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
singleToggles.forEach(function (toggle) {
|
||||
toggle.checked = false
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle all
|
||||
allToggle.addEventListener('change', function (e) {
|
||||
if (e.target.checked) {
|
||||
selectAll()
|
||||
} else {
|
||||
deselectAll()
|
||||
}
|
||||
})
|
||||
|
||||
// Toggle single
|
||||
singleToggles.forEach(function (toggle) {
|
||||
toggle.addEventListener('change', function () {
|
||||
allToggle.checked = isAllSelected()
|
||||
})
|
||||
})
|
||||
|
||||
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
|
||||
let bulkEditToggleTimeout
|
||||
if (bulkEditToggle.checked) {
|
||||
bulkEditBar.style.overflow = 'visible';
|
||||
}
|
||||
bulkEditToggle.addEventListener('change', function (e) {
|
||||
if (bulkEditToggleTimeout) {
|
||||
clearTimeout(bulkEditToggleTimeout);
|
||||
bulkEditToggleTimeout = null;
|
||||
}
|
||||
if (e.target.checked) {
|
||||
bulkEditToggleTimeout = setTimeout(function () {
|
||||
bulkEditBar.style.overflow = 'visible';
|
||||
}, 500);
|
||||
} else {
|
||||
bulkEditBar.style.overflow = 'hidden';
|
||||
}
|
||||
});
|
||||
|
||||
// Init tag auto-complete
|
||||
function initTagAutoComplete() {
|
||||
const wrapper = document.createElement('div');
|
||||
const tagInput = document.getElementById('bulk-edit-tags-input');
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
|
||||
const apiClient = new linkding.ApiClient(apiBaseUrl)
|
||||
|
||||
new linkding.TagAutoComplete({
|
||||
target: wrapper,
|
||||
props: {
|
||||
id: 'bulk-edit-tags-input',
|
||||
name: tagInput.name,
|
||||
value: tagInput.value,
|
||||
apiClient: apiClient,
|
||||
variant: 'small'
|
||||
}
|
||||
});
|
||||
|
||||
tagInput.parentElement.replaceChild(wrapper, tagInput);
|
||||
}
|
||||
|
||||
initTagAutoComplete();
|
||||
})()
|
BIN
bookmarks/static/logo.png
Normal file
BIN
bookmarks/static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
45
bookmarks/static/shared.js
Normal file
45
bookmarks/static/shared.js
Normal file
@@ -0,0 +1,45 @@
|
||||
(function () {
|
||||
|
||||
function initConfirmationButtons() {
|
||||
const buttonEls = document.querySelectorAll('.btn-confirmation');
|
||||
|
||||
function showConfirmation(buttonEl) {
|
||||
const cancelEl = document.createElement(buttonEl.nodeName);
|
||||
cancelEl.innerText = 'Cancel';
|
||||
cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1';
|
||||
cancelEl.addEventListener('click', function () {
|
||||
container.remove();
|
||||
buttonEl.style = '';
|
||||
});
|
||||
|
||||
const confirmEl = document.createElement(buttonEl.nodeName);
|
||||
confirmEl.innerText = 'Confirm';
|
||||
confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action';
|
||||
|
||||
if (buttonEl.nodeName === 'BUTTON') {
|
||||
confirmEl.type = buttonEl.type;
|
||||
confirmEl.name = buttonEl.name;
|
||||
}
|
||||
if (buttonEl.nodeName === 'A') {
|
||||
confirmEl.href = buttonEl.href;
|
||||
}
|
||||
|
||||
const container = document.createElement('span');
|
||||
container.className = 'confirmation'
|
||||
container.appendChild(cancelEl);
|
||||
container.appendChild(confirmEl);
|
||||
buttonEl.parentElement.insertBefore(container, buttonEl);
|
||||
buttonEl.style = 'display: none';
|
||||
}
|
||||
|
||||
buttonEls.forEach(function (linkEl) {
|
||||
linkEl.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
showConfirmation(linkEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
initConfirmationButtons()
|
||||
})()
|
@@ -1,5 +1,10 @@
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: $size-sm) {
|
||||
// High horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -10,20 +15,22 @@ header {
|
||||
|
||||
.navbar-brand {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
background-color: $primary-color;
|
||||
color: $light-color;
|
||||
padding: 14px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
margin: 0 0 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +46,18 @@ h2 {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
// Button color should not change for anchor elements
|
||||
.btn:visited:not(.btn-primary) {
|
||||
color: $primary-color;
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
// Increase spacing between columns
|
||||
@@ -57,4 +73,20 @@ h2 {
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
@@ -30,6 +30,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-page .content-area-header {
|
||||
span.btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
ul.bookmark-list {
|
||||
|
||||
list-style: none;
|
||||
@@ -39,7 +45,7 @@ ul.bookmark-list {
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
|
||||
a {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
}
|
||||
@@ -57,11 +63,8 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
.actions .btn-link.bm-remove-confirm {
|
||||
color: $error-color;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bulk-edit-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ ul.bookmark-list {
|
||||
|
||||
.tag-cloud {
|
||||
|
||||
a {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
|
||||
@@ -103,3 +106,100 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bulk edit */
|
||||
$bulk-edit-toggle-width: 16px;
|
||||
$bulk-edit-toggle-offset: 8px;
|
||||
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
||||
$bulk-edit-transition-duration: 400ms;
|
||||
|
||||
.bulk-edit-form {
|
||||
|
||||
.bulk-edit-bar {
|
||||
margin-top: -17px;
|
||||
margin-bottom: 16px;
|
||||
margin-left: -$bulk-edit-bar-offset;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $bulk-edit-transition-duration;
|
||||
}
|
||||
|
||||
.bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
border-top: solid 1px $border-color;
|
||||
|
||||
button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
> label.form-checkbox {
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
> button {
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
> input, .form-autocomplete {
|
||||
width: auto;
|
||||
margin-left: 4px;
|
||||
max-width: 200px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
}
|
||||
span.confirmation button {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-edit-all-toggle {
|
||||
width: $bulk-edit-toggle-width;
|
||||
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul.bookmark-list li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ul.bookmark-list li .bulk-edit-toggle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: $bulk-edit-toggle-width;
|
||||
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
i {
|
||||
top: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#bulk-edit-mode {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
32
bookmarks/styles/dark.scss
Normal file
32
bookmarks/styles/dark.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
/* Dark theme overrides */
|
||||
|
||||
/* Buttons */
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring*/
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: $dt-primary-button-color;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
@@ -1,27 +0,0 @@
|
||||
// Font sizes
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
//$alternative-color: #c84e00;
|
||||
//$alternative-color: #FF84E8;
|
||||
//$alternative-color: #98C1D9;
|
||||
//$alternative-color: #7B287D;
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
// Import Spectre icons
|
||||
@import "../../node_modules/spectre.css/src/icons/icons-core";
|
||||
@import "../../node_modules/spectre.css/src/icons/icons-navigation";
|
||||
@import "../../node_modules/spectre.css/src/icons/icons-action";
|
||||
@import "../../node_modules/spectre.css/src/icons/icons-object";
|
||||
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "settings";
|
||||
@import "auth";
|
@@ -1,3 +1,4 @@
|
||||
// Content area component
|
||||
section.content-area {
|
||||
|
||||
.content-area-header {
|
||||
@@ -11,3 +12,11 @@ section.content-area {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm button component
|
||||
.btn-confirmation-action {
|
||||
color: $error-color !important;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
17
bookmarks/styles/theme-dark.scss
Normal file
17
bookmarks/styles/theme-dark.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
// Import custom variables
|
||||
@import "variables-dark";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "settings";
|
||||
@import "auth";
|
||||
|
||||
// Dark theme overrides
|
||||
@import "dark";
|
14
bookmarks/styles/theme-light.scss
Normal file
14
bookmarks/styles/theme-light.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
// Import custom variables
|
||||
@import "variables-light";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "settings";
|
||||
@import "auth";
|
@@ -7,3 +7,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
28
bookmarks/styles/variables-dark.scss
Normal file
28
bookmarks/styles/variables-dark.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
$body-bg: #161822 !default;
|
||||
$bg-color: lighten($body-bg, 5%) !default;
|
||||
$bg-color-light: lighten($body-bg, 5%) !default;
|
||||
|
||||
$border-color: #4C4E53 !default;
|
||||
$border-color-dark: $border-color !default;
|
||||
|
||||
$body-font-color: #b5bec8 !default;
|
||||
$light-color: #fafafa !default;
|
||||
|
||||
$gray-color: #7f879b !default;
|
||||
$gray-color-dark: lighten($gray-color, 20%) !default;
|
||||
|
||||
$primary-color: #a8b1ff !default;
|
||||
$primary-color-dark: saturate($primary-color, 5%) !default;
|
||||
$secondary-color: lighten($body-bg, 10%) !default;
|
||||
|
||||
$link-color: $primary-color !default;
|
||||
$link-color-dark: darken($link-color, 5%) !default;
|
||||
$link-color-light: $link-color !default;
|
||||
|
||||
$alternative-color: #59bdb9;
|
||||
$alternative-color-dark: #73f1eb;
|
||||
|
||||
/* Dark theme specific */
|
||||
$dt-primary-button-color: #5761cb !default;
|
4
bookmarks/styles/variables-light.scss
Normal file
4
bookmarks/styles/variables-light.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
@@ -4,6 +4,9 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||
|
||||
<div class="bookmarks-page columns">
|
||||
|
||||
{# Bookmark list #}
|
||||
@@ -12,13 +15,20 @@
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search query tags mode='archive' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
</div>
|
||||
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% endif %}
|
||||
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
|
||||
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Tag list #}
|
||||
@@ -31,4 +41,6 @@
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "shared.js" %}"></script>
|
||||
<script src="{% static "bulk_edit.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -4,16 +4,20 @@
|
||||
<ul class="bookmark-list">
|
||||
{% for bookmark in bookmarks %}
|
||||
<li>
|
||||
<label class="form-checkbox bulk-edit-toggle">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title truncate">
|
||||
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a>
|
||||
</div>
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
<span>
|
||||
{% for tag_name in bookmark.tag_names %}
|
||||
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% for tag_name in bookmark.tag_names %}
|
||||
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||
|
||||
@@ -32,7 +36,7 @@
|
||||
class="btn btn-link btn-sm">Archive</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm bm-remove">Remove</a>
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -40,39 +44,4 @@
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
|
||||
{# Enhance delete links to show inline confirmation #}
|
||||
<script type="application/javascript">
|
||||
window.addEventListener("load", function () {
|
||||
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
|
||||
|
||||
function showConfirmation(linkEl) {
|
||||
const cancelEl = document.createElement('span');
|
||||
cancelEl.innerText = 'Cancel';
|
||||
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
|
||||
cancelEl.addEventListener('click', function() {
|
||||
container.remove();
|
||||
linkEl.style = '';
|
||||
});
|
||||
|
||||
const confirmEl = document.createElement('a');
|
||||
confirmEl.innerText = 'Confirm';
|
||||
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
|
||||
confirmEl.href = linkEl.href;
|
||||
|
||||
const container = document.createElement('span');
|
||||
container.appendChild(cancelEl);
|
||||
container.appendChild(confirmEl);
|
||||
linkEl.parentElement.appendChild(container);
|
||||
linkEl.style = 'display: none';
|
||||
}
|
||||
|
||||
linkEls.forEach(function (linkEl) {
|
||||
linkEl.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
showConfirmation(linkEl);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
31
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
31
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<div class="bulk-edit-bar">
|
||||
<div class="bulk-edit-actions bg-gray">
|
||||
<label class="form-checkbox bulk-edit-all-toggle">
|
||||
<input type="checkbox" style="display: none">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if mode == 'archive' %}
|
||||
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation"
|
||||
title="Unarchive selected bookmarks">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation"
|
||||
title="Archive selected bookmarks">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-dark">•</span>
|
||||
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation"
|
||||
title="Delete selected bookmarks">Delete
|
||||
</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>
|
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
@@ -0,0 +1 @@
|
||||
<input id="bulk-edit-mode" type="checkbox">
|
8
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
8
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<label for="bulk-edit-mode" class="hide-sm">
|
||||
<span class="btn" title="Bulk edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
||||
height="20px">
|
||||
<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"/>
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
@@ -8,7 +8,7 @@
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form all_tags return_url bookmark_id %}
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'bookmarks:settings.data' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -7,7 +7,7 @@
|
||||
{{ form.return_url|attr:"type:hidden" }}
|
||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
|
||||
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
|
||||
{% if form.url.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.url.errors }}
|
||||
@@ -64,8 +64,7 @@
|
||||
<script type="application/javascript">
|
||||
const wrapper = document.createElement('div');
|
||||
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||
const allTagsString = '{{ all_tags }}';
|
||||
const allTags = allTagsString.split(' ');
|
||||
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
||||
|
||||
new linkding.TagAutoComplete({
|
||||
target: wrapper,
|
||||
@@ -73,7 +72,7 @@
|
||||
id: '{{ form.tag_string.id_for_label }}',
|
||||
name: '{{ form.tag_string.name }}',
|
||||
value: tagInput.value,
|
||||
tags: allTags
|
||||
apiClient: apiClient
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -4,6 +4,9 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||
|
||||
<div class="bookmarks-page columns">
|
||||
|
||||
{# Bookmark list #}
|
||||
@@ -12,13 +15,20 @@
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search query tags %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
</div>
|
||||
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% endif %}
|
||||
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
|
||||
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Tag list #}
|
||||
@@ -31,4 +41,6 @@
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "shared.js" %}"></script>
|
||||
<script src="{% static "bulk_edit.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -2,7 +2,8 @@
|
||||
{% load sass_tags %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{# Use data attributes as storage for access in static scripts #}
|
||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
||||
@@ -12,14 +13,24 @@
|
||||
<meta name="author" content="Sascha Ißbrücker">
|
||||
<title>linkding</title>
|
||||
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
||||
<link href="{% sass_src 'index.scss' %}" rel="stylesheet" type="text/css"/>
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user.profile.theme == 'light' %}
|
||||
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
|
||||
{% elif request.user.profile.theme == 'dark' %}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
|
||||
{% else %}
|
||||
{# Use auto theme as fallback #}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: dark)"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: light)"/>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar container grid-lg">
|
||||
<section class="navbar-section">
|
||||
<a href="/" class="navbar-brand text-bold">
|
||||
<i class="logo icon icon-link s-circle"></i>
|
||||
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>linkding</h1>
|
||||
</a>
|
||||
</section>
|
||||
|
@@ -7,12 +7,16 @@
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
|
||||
<i class="icon icon-plus"></i>
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="dropdown dropdown-right">
|
||||
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<i class="icon icon-menu icon-2x"></i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
@@ -46,4 +50,4 @@
|
||||
mobileNavMenuTrigger.addEventListener('blur', function () {
|
||||
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form all_tags return_url auto_close=auto_close %}
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
@@ -20,11 +20,11 @@
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input' }}
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||
{{ form.password|add_class:'form-input' }}
|
||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
@@ -1,10 +1,26 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
{# Profile section #}
|
||||
<section class="content-area">
|
||||
<h2>Profile</h2>
|
||||
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Save" class="btn btn-primary mt-2">
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Import section #}
|
||||
<section class="content-area">
|
||||
<h2>Import</h2>
|
@@ -1,11 +1,11 @@
|
||||
{% url 'bookmarks:settings.index' as index_url %}
|
||||
{% url 'bookmarks:settings.data' as data_url %}
|
||||
{% url 'bookmarks:settings.general' as general_url %}
|
||||
{% url 'bookmarks:settings.integrations' as integrations_url %}
|
||||
{% url 'bookmarks:settings.api' as api_url %}
|
||||
|
||||
<ul class="tab tab-block">
|
||||
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == data_url%}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.data' %}">Data</a>
|
||||
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == general_url%}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.general' %}">General</a>
|
||||
</li>
|
||||
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
||||
@@ -16,8 +16,11 @@
|
||||
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features 
 such as user management and bulk operations.">
|
||||
<a href="{% url 'admin:index' %}" target="_blank">
|
||||
<span>Admin</span>
|
||||
<i class="icon icon-share ml-1" style="font-size: 12px"></i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<br>
|
||||
<br>
|
||||
|
@@ -9,15 +9,10 @@ register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
|
||||
def bookmark_form(form: BookmarkForm, all_tags: List[Tag], cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
|
||||
|
||||
all_tag_names = [tag.name for tag in all_tags]
|
||||
all_tags_string = build_tag_string(all_tag_names, ' ')
|
||||
|
||||
def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
|
||||
return {
|
||||
'form': form,
|
||||
'auto_close': auto_close,
|
||||
'all_tags': all_tags_string,
|
||||
'bookmark_id': bookmark_id,
|
||||
'cancel_url': cancel_url
|
||||
}
|
||||
|
@@ -2,18 +2,20 @@ from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \
|
||||
delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BookmarkServiceTestCase(TestCase):
|
||||
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def test_archive(self):
|
||||
def test_archive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
date_added=timezone.now(),
|
||||
@@ -30,7 +32,7 @@ class BookmarkServiceTestCase(TestCase):
|
||||
|
||||
self.assertTrue(updated_bookmark.is_archived)
|
||||
|
||||
def test_unarchive(self):
|
||||
def test_unarchive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
date_added=timezone.now(),
|
||||
@@ -45,3 +47,297 @@ class BookmarkServiceTestCase(TestCase):
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
|
||||
self.assertFalse(updated_bookmark.is_archived)
|
||||
|
||||
def test_archive_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
archive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_archive_bookmarks_should_only_archive_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
archive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_archive_bookmarks_should_only_archive_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
archive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)
|
||||
|
||||
def test_archive_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
archive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_unarchive_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
unarchive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_unarchive_bookmarks_should_only_unarchive_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
unarchive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_unarchive_bookmarks_should_only_unarchive_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
inaccessible_bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
unarchive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)
|
||||
|
||||
def test_unarchive_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
unarchive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_delete_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_delete_bookmarks_should_only_delete_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
delete_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_delete_bookmarks_should_only_delete_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
delete_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=inaccessible_bookmark.id).first())
|
||||
|
||||
def test_delete_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_tag_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_tag_bookmarks_should_create_tags(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertEqual(2, Tag.objects.count())
|
||||
|
||||
tag1 = Tag.objects.filter(name='tag1').first()
|
||||
tag2 = Tag.objects.filter(name='tag2').first()
|
||||
|
||||
self.assertIsNotNone(tag1)
|
||||
self.assertIsNotNone(tag2)
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_tag_bookmarks_should_only_tag_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
inaccessible_bookmark.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(inaccessible_bookmark.tags.all(), [])
|
||||
|
||||
def test_tag_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_untag_bookmarks(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_untag_bookmarks_should_only_tag_specified_bookmarks(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_untag_bookmarks_should_only_tag_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
inaccessible_bookmark.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(inaccessible_bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_untag_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
132
bookmarks/tests/test_bulk_edit_integration.py
Normal file
132
bookmarks/tests/test_bulk_edit_integration.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from django.forms import model_to_dict
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
|
||||
self.assertEqual(len(bookmarks), Bookmark.objects.count())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
|
||||
|
||||
def test_bulk_archive(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_unarchive(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_unarchive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_delete(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_delete': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertFalse(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertFalse(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_bulk_tag(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_tag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_bulk_untag(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_untag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_bulk_edit_handles_empty_bookmark_id(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_archive': [''],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
def test_empty_action_does_not_modify_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
12
bookmarks/tests/test_user_profile_model.py
Normal file
12
bookmarks/tests/test_user_profile_model.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
|
||||
class UserProfileTestCase(TestCase):
|
||||
|
||||
def test_create_user_should_init_profile(self):
|
||||
user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
profile = UserProfile.objects.all().filter(user_id=user.id).first()
|
||||
self.assertIsNotNone(profile)
|
@@ -18,9 +18,10 @@ urlpatterns = [
|
||||
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
|
||||
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
||||
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
||||
path('bookmarks/bulkedit', views.bookmarks.bulk_edit, name='bulk_edit'),
|
||||
# Settings
|
||||
path('settings', views.settings.data, name='settings.index'),
|
||||
path('settings/data', views.settings.data, name='settings.data'),
|
||||
path('settings', views.settings.general, name='settings.index'),
|
||||
path('settings/general', views.settings.general, name='settings.general'),
|
||||
path('settings/integrations', views.settings.integrations, name='settings.integrations'),
|
||||
path('settings/api', views.settings.api, name='settings.api'),
|
||||
path('settings/import', views.settings.bookmark_import, name='settings.import'),
|
||||
|
@@ -8,7 +8,8 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
|
||||
_default_page_size = 30
|
||||
|
||||
@@ -87,11 +88,9 @@ def new(request):
|
||||
if initial_auto_close:
|
||||
form.initial['auto_close'] = 'true'
|
||||
|
||||
all_tags = queries.get_user_tags(request.user)
|
||||
context = {
|
||||
'form': form,
|
||||
'auto_close': initial_auto_close,
|
||||
'all_tags': all_tags,
|
||||
'return_url': reverse('bookmarks:index')
|
||||
}
|
||||
|
||||
@@ -116,12 +115,10 @@ def edit(request, bookmark_id: int):
|
||||
|
||||
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
|
||||
form.initial['return_url'] = return_url
|
||||
all_tags = queries.get_user_tags(request.user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'bookmark_id': bookmark_id,
|
||||
'all_tags': all_tags,
|
||||
'return_url': return_url
|
||||
}
|
||||
|
||||
@@ -155,6 +152,29 @@ def unarchive(request, bookmark_id: int):
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def bulk_edit(request):
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
|
||||
# Determine action
|
||||
if 'bulk_archive' in request.POST:
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unarchive' in request.POST:
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_delete' in request.POST:
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_tag' in request.POST:
|
||||
tag_string = request.POST['bulk_tag_string']
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_untag' in request.POST:
|
||||
tag_string = request.POST['bulk_tag_string']
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def close(request):
|
||||
return render(request, 'bookmarks/close.html')
|
||||
|
@@ -7,6 +7,7 @@ from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import UserProfileForm
|
||||
from bookmarks.queries import query_bookmarks
|
||||
from bookmarks.services.exporter import export_netscape_html
|
||||
from bookmarks.services.importer import import_netscape_html
|
||||
@@ -15,10 +16,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def data(request):
|
||||
def general(request):
|
||||
if request.method == 'POST':
|
||||
form = UserProfileForm(request.POST, instance=request.user.profile)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
else:
|
||||
form = UserProfileForm(instance=request.user.profile)
|
||||
|
||||
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
|
||||
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
|
||||
return render(request, 'settings/data.html', {
|
||||
return render(request, 'settings/general.html', {
|
||||
'form': form,
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
})
|
||||
@@ -61,7 +70,7 @@ def bookmark_import(request):
|
||||
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
|
||||
pass
|
||||
|
||||
return HttpResponseRedirect(reverse('bookmarks:settings.data'))
|
||||
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -77,7 +86,7 @@ def bookmark_export(request):
|
||||
|
||||
return response
|
||||
except:
|
||||
return render(request, 'settings/data.html', {
|
||||
return render(request, 'settings/general.html', {
|
||||
'export_error': 'An error occurred during bookmark export.'
|
||||
})
|
||||
|
||||
|
@@ -5,6 +5,7 @@ confusable-homoglyphs==3.2.0
|
||||
Django==2.2.18
|
||||
django-appconf==1.0.3
|
||||
django-compressor==2.3
|
||||
django-debug-toolbar==3.2
|
||||
django-generate-secret-key==1.0.2
|
||||
django-picklefield==2.0
|
||||
django-registration==3.0.1
|
||||
|
@@ -11,6 +11,14 @@ DEBUG = True
|
||||
# Turn on SASS compilation
|
||||
SASS_PROCESSOR_ENABLED = True
|
||||
|
||||
# Enable debug toolbar
|
||||
INSTALLED_APPS.append('debug_toolbar')
|
||||
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
|
||||
|
||||
INTERNAL_IPS = [
|
||||
'127.0.0.1',
|
||||
]
|
||||
|
||||
# Enable debug logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
|
@@ -17,7 +17,7 @@ from django.contrib.auth import views as auth_views
|
||||
from django.urls import path, include
|
||||
|
||||
from bookmarks.admin import linkding_admin_site
|
||||
from .settings import ALLOW_REGISTRATION
|
||||
from .settings import ALLOW_REGISTRATION, DEBUG
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', linkding_admin_site.urls),
|
||||
@@ -28,5 +28,9 @@ urlpatterns = [
|
||||
path('', include('bookmarks.urls')),
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
import debug_toolbar
|
||||
urlpatterns.append(path('__debug__/', include(debug_toolbar.urls)))
|
||||
|
||||
if ALLOW_REGISTRATION:
|
||||
urlpatterns.append(path('', include('django_registration.backends.one_step.urls')))
|
||||
|
@@ -1 +1 @@
|
||||
1.4.1
|
||||
1.6.0
|
||||
|
Reference in New Issue
Block a user