Compare commits

..

12 Commits

Author SHA1 Message Date
Sascha Ißbrücker
593d90d8e2 Bump version 2021-03-31 09:09:08 +02:00
Sascha Ißbrücker
7a68a4abed Display date_added in bookmark list (#85)
* Display date_added in bookmark list (#85)

* Allow switching between different types of date formats

* Improve date formatting

* Use pluralize

* Fix comment

* Fix styles

Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-03-31 09:08:19 +02:00
Sascha Ißbrücker
8dd1575dc6 Update CHANGELOG.md 2021-03-29 01:04:00 +02:00
Sascha Ißbrücker
d4d23daebc Update CHANGELOG.md 2021-03-29 00:59:56 +02:00
Sascha Ißbrücker
a73910d9c7 Bump version 2021-03-29 00:57:15 +02:00
Sascha Ißbrücker
0c1c21c8d1 Implement bulk edit (#101) 2021-03-29 00:43:50 +02:00
Sascha Ißbrücker
2e4f271490 Update CHANGELOG.md 2021-03-28 12:27:38 +02:00
Sascha Ißbrücker
61b13dc531 Update CHANGELOG.md 2021-03-28 12:26:09 +02:00
Sascha Ißbrücker
e976fd054c Bump version 2021-03-28 12:14:11 +02:00
Sascha Ißbrücker
119d8f7efb Implement dark theme (#49) 2021-03-28 12:11:56 +02:00
Sascha Ißbrücker
3e5e825032 Update CHANGELOG.md 2021-03-20 12:41:47 +01:00
Sascha Ißbrücker
dc1f6f9c44 Update CHANGELOG.md 2021-03-20 12:39:15 +01:00
56 changed files with 1463 additions and 174 deletions

View File

@@ -1,5 +1,19 @@
# Changelog
## v1.6.0 (29/03/2021)
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
---
## 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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>

View File

@@ -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="&nbsp;"
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>

View File

@@ -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())

View 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),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.18 on 2021-03-30 10:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0007_userprofile'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='bookmark_date_display',
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
),
]

View File

@@ -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,43 @@ 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'),
]
BOOKMARK_DATE_DISPLAY_RELATIVE = 'relative'
BOOKMARK_DATE_DISPLAY_ABSOLUTE = 'absolute'
BOOKMARK_DATE_DISPLAY_HIDDEN = 'hidden'
BOOKMARK_DATE_DISPLAY_CHOICES = [
(BOOKMARK_DATE_DISPLAY_RELATIVE, 'Relative'),
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
]
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)
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
default=BOOKMARK_DATE_DISPLAY_RELATIVE)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme', 'bookmark_date_display']
@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()

View File

@@ -55,8 +55,8 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
tags__name__iexact=tag_name
)
# Sort by modification date
query_set = query_set.order_by('-date_modified')
# Sort by date added
query_set = query_set.order_by('-date_added')
return query_set

View File

@@ -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]

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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()
})()

View File

@@ -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;
}
}

View File

@@ -30,6 +30,12 @@
}
}
.bookmarks-page .content-area-header {
span.btn {
margin-left: 8px;
}
}
ul.bookmark-list {
list-style: none;
@@ -39,29 +45,32 @@ ul.bookmark-list {
.description {
color: $gray-color-dark;
a {
a, a:visited:hover {
color: $alternative-color;
}
}
.actions > *:not(:last-child) {
margin-right: 0.1rem;
}
.actions .btn-link {
color: $gray-color;
padding-left: 0;
padding-right: 0;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
&:focus,
&:hover,
&:active,
&.active {
color: darken($gray-color, 10%);
color: $gray-color-dark;
}
}
.actions .btn-link.bm-remove-confirm {
color: $error-color;
&:hover {
text-decoration: underline;
}
.bulk-edit-toggle {
display: none;
}
}
@@ -71,7 +80,7 @@ ul.bookmark-list {
.tag-cloud {
a {
a, a:visited:hover {
color: $alternative-color;
}
@@ -103,3 +112,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;
}

View 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;
}

View File

@@ -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";

View File

@@ -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;
}
}

View 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";

View 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";

View File

@@ -7,3 +7,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
.text-sm {
font-size: 0.7rem;
}
.text-gray-dark {
color: $gray-color-dark;
}

View 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;

View File

@@ -0,0 +1,4 @@
$html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);

View File

@@ -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 %}

View File

@@ -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 %}
@@ -22,6 +26,14 @@
{% endif %}
</div>
<div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_relative_date }}</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_absolute_date }}</span>
<span class="text-gray text-sm">|</span>
{% endif %}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
@@ -32,7 +44,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 %}
@@ -41,38 +53,3 @@
<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>

View 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="&nbsp;">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
title="Add tags to selected bookmarks">Add
</button>
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
title="Remove tags from selected bookmarks">Remove
</button>
</div>
</div>

View File

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

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}
});

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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/>

View File

@@ -1,10 +1,30 @@
{% 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">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
</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>

View File

@@ -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 &#010; 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>

View File

@@ -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
}
@@ -58,6 +53,7 @@ def tag_cloud(context, tags: List[Tag]):
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
def bookmark_list(context, bookmarks: Page, return_url: str):
return {
'request': context['request'],
'bookmarks': bookmarks,
'return_url': return_url
}

View File

@@ -1,5 +1,7 @@
from django import template
from bookmarks import utils
register = template.Library()
@@ -43,3 +45,17 @@ def first_char(text):
@register.filter(name='remaining_chars')
def remaining_chars(text, index):
return text[index:]
@register.filter(name='humanize_absolute_date')
def humanize_absolute_date(value):
if value in (None, ''):
return ''
return utils.humanize_absolute_date(value)
@register.filter(name='humanize_relative_date')
def humanize_relative_date(value):
if value in (None, ''):
return ''
return utils.humanize_relative_date(value)

View File

@@ -0,0 +1,51 @@
from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.utils import timezone, formats
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, bookmarks) -> str:
rf = RequestFactory()
request = rf.get('/test')
request.user = self.get_or_create_test_user()
paginator = Paginator(bookmarks, 10)
page = paginator.page(1)
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
template_to_render = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
return template_to_render.render(context)
def setup_date_format_test(self, date_display_setting):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.save()
user = self.get_or_create_test_user()
user.profile.bookmark_date_display = date_display_setting
user.profile.save()
return bookmark
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_template([bookmark])
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertInHTML(f'''
<span class="text-gray text-sm">{formatted_date}</span>
''', html)
def test_should_respect_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_template([bookmark])
self.assertInHTML('''
<span class="text-gray text-sm">1 week ago</span>
''', html)

View File

@@ -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(), [])

View 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])

View 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)

View File

@@ -0,0 +1,47 @@
from django.test import TestCase
from django.utils import timezone
from bookmarks.utils import humanize_absolute_date, humanize_relative_date
class UtilsTestCase(TestCase):
def test_humanize_absolute_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 1, 1), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 2, 1), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 8), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7, 23, 59), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2, 23, 59), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), 'Today'),
]
for test_case in test_cases:
result = humanize_absolute_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_relative_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 1, 1), '1 year ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 12, 31), '1 year ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 1, 1), '2 years ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 12, 31), '2 years ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 12, 31), '11 months ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 2, 1), '1 month ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 31), '4 weeks ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 14), '1 week ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 8), '1 week ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7, 23, 59), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2, 23, 59), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), 'Today'),
]
for test_case in test_cases:
result = humanize_relative_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)

View File

@@ -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'),

View File

@@ -1,2 +1,55 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
def unique(elements, key):
return list({key(element): element for element in elements}.values())
weekday_names = {
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
7: 'Sunday',
}
def humanize_absolute_date(value: datetime, now=timezone.now()):
delta = relativedelta(now, value)
yesterday = now - relativedelta(days=1)
is_older_than_a_week = delta.years > 0 or delta.months > 0 or delta.weeks > 0
if is_older_than_a_week:
return formats.date_format(value, 'SHORT_DATE_FORMAT')
elif value.day == now.day:
return 'Today'
elif value.day == yesterday.day:
return 'Yesterday'
else:
return weekday_names[value.isoweekday()]
def humanize_relative_date(value: datetime, now: datetime = timezone.now()):
delta = relativedelta(now, value)
if delta.years > 0:
return f'{delta.years} year{pluralize(delta.years)} ago'
elif delta.months > 0:
return f'{delta.months} month{pluralize(delta.months)} ago'
elif delta.weeks > 0:
return f'{delta.weeks} week{pluralize(delta.weeks)} ago'
else:
yesterday = now - relativedelta(days=1)
if value.day == now.day:
return 'Today'
elif value.day == yesterday.day:
return 'Yesterday'
else:
return weekday_names[value.isoweekday()]

View File

@@ -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')

View File

@@ -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.'
})

View File

@@ -11,6 +11,7 @@ django-widget-tweaks==1.4.5
djangorestframework==3.11.2
idna==2.8
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
requests==2.22.0
soupsieve==1.9.2

View File

@@ -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
@@ -14,6 +15,7 @@ djangorestframework==3.11.2
idna==2.8
libsass==0.19.2
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
rcssmin==1.0.6
requests==2.22.0

View File

@@ -52,6 +52,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.locale.LocaleMiddleware',
]
ROOT_URLCONF = 'siteroot.urls'

View File

@@ -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,

View File

@@ -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')))

View File

@@ -1 +1 @@
1.4.1
1.6.1