mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-30 05:46:47 +02:00
Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b25f3d5529 | ||
![]() |
24746deaae | ||
![]() |
e4a082231f | ||
![]() |
5a380212d9 | ||
![]() |
96068719cd | ||
![]() |
e42d562750 | ||
![]() |
ff456b10ee | ||
![]() |
3a05666680 | ||
![]() |
dbe92b4b84 | ||
![]() |
90f62d3482 | ||
![]() |
847f9644f4 | ||
![]() |
bf84b3ddfd | ||
![]() |
2d19e97212 | ||
![]() |
c083997399 | ||
![]() |
36f134db9a | ||
![]() |
593d90d8e2 | ||
![]() |
7a68a4abed | ||
![]() |
8dd1575dc6 | ||
![]() |
d4d23daebc |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v1.6.3 (07/04/2021)
|
||||
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
|
||||
|
||||
---
|
||||
|
||||
## v1.6.2 (04/04/2021)
|
||||
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
||||
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
|
||||
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
|
||||
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
|
||||
|
||||
---
|
||||
|
||||
## v1.6.1 (31/03/2021)
|
||||
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
---
|
||||
|
@@ -97,10 +97,18 @@ The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedo
|
||||
|
||||
Check the [options document](docs/Options.md) on how to configure your linkding installation.
|
||||
|
||||
## Administration
|
||||
|
||||
Check the [administration document](docs/Admin.md) on how to use the admin app that is bundled with linkding.
|
||||
|
||||
## Backups
|
||||
|
||||
Check the [backups document](docs/backup.md) for options on how to create backups.
|
||||
|
||||
## How To
|
||||
|
||||
Check the [how-to document](docs/how-to.md) for tips and tricks around using linkding.
|
||||
|
||||
## API
|
||||
|
||||
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information.
|
||||
|
@@ -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),
|
||||
),
|
||||
]
|
@@ -107,14 +107,24 @@ class UserProfile(models.Model):
|
||||
(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']
|
||||
fields = ['theme', 'bookmark_date_display']
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -50,16 +50,22 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +97,41 @@ ul.bookmark-list {
|
||||
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right > input, .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-input-hint.bookmark-exists {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
@@ -157,6 +192,7 @@ $bulk-edit-transition-duration: 400ms;
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
span.confirmation button {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -202,4 +238,4 @@ $bulk-edit-transition-duration: 400ms;
|
||||
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
|
||||
max-height: 37px;
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
}
|
||||
|
@@ -14,4 +14,4 @@
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
}
|
||||
|
@@ -26,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 %}
|
||||
@@ -44,4 +52,4 @@
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -14,12 +14,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-input-hint bookmark-exists">
|
||||
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark by saving this form.
|
||||
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark
|
||||
by saving this form.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input" }}
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
<div class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||
exist it will be
|
||||
@@ -30,8 +31,16 @@
|
||||
<div class="form-group has-icon-right">
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||
<div class="has-icon-right">
|
||||
{{ form.title|add_class:"form-input" }}
|
||||
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
<i class="form-icon loading"></i>
|
||||
<a class="btn btn-link form-icon" title="Edit title from website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-input-hint">
|
||||
Optional, leave empty to use title from website.
|
||||
@@ -43,6 +52,14 @@
|
||||
<div class="has-icon-right">
|
||||
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
|
||||
<i class="form-icon loading"></i>
|
||||
<a class="btn btn-link form-icon" title="Edit description from website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
||||
<path fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-input-hint">
|
||||
Optional, leave empty to use description from website.
|
||||
@@ -82,49 +99,72 @@
|
||||
/**
|
||||
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
|
||||
* - Show hint if URL is already bookmarked
|
||||
* - Setup buttons that allow editing of scraped website values
|
||||
*/
|
||||
(function init() {
|
||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||
const editedBookmarkId = {{ bookmark_id }}
|
||||
const editedBookmarkId = {{ bookmark_id }};
|
||||
|
||||
urlInput.addEventListener('input', checkUrl);
|
||||
function toggleLoadingIcon(input, show) {
|
||||
const icon = input.parentNode.querySelector('i.form-icon');
|
||||
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
||||
}
|
||||
|
||||
function updatePlaceholder(input, value) {
|
||||
if (value) {
|
||||
input.setAttribute('placeholder', value);
|
||||
} else {
|
||||
input.removeAttribute('placeholder');
|
||||
}
|
||||
}
|
||||
|
||||
function checkUrl() {
|
||||
toggleIcon(titleInput, true);
|
||||
toggleIcon(descriptionInput, true);
|
||||
toggleLoadingIcon(titleInput, true);
|
||||
toggleLoadingIcon(descriptionInput, true);
|
||||
updatePlaceholder(titleInput, null);
|
||||
updatePlaceholder(descriptionInput, null);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata
|
||||
titleInput.setAttribute('placeholder', metadata.title || '');
|
||||
descriptionInput.setAttribute('placeholder', metadata.description || '');
|
||||
toggleIcon(titleInput, false);
|
||||
toggleIcon(descriptionInput, false);
|
||||
const metadata = data.metadata;
|
||||
updatePlaceholder(titleInput, metadata.title);
|
||||
updatePlaceholder(descriptionInput, metadata.description);
|
||||
toggleLoadingIcon(titleInput, false);
|
||||
toggleLoadingIcon(descriptionInput, false);
|
||||
|
||||
// Display hint if URL is already bookmarked
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists')
|
||||
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a')
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
|
||||
|
||||
if(data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
||||
bookmarkExistsHint.style['visibility'] = 'visible'
|
||||
editExistingBookmarkLink.href = data.bookmark.edit_url
|
||||
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
||||
bookmarkExistsHint.style['display'] = 'block';
|
||||
editExistingBookmarkLink.href = data.bookmark.edit_url;
|
||||
} else {
|
||||
bookmarkExistsHint.style['visibility'] = 'hidden'
|
||||
bookmarkExistsHint.style['display'] = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIcon(input, show) {
|
||||
const icon = input.parentNode.querySelector('i.form-icon');
|
||||
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
||||
function setupEditAutoValueButton(input) {
|
||||
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
|
||||
if (!editAutoValueButton) return;
|
||||
editAutoValueButton.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
input.value = input.getAttribute('placeholder');
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
if (urlInput.value) checkUrl();
|
||||
urlInput.addEventListener('input', checkUrl);
|
||||
setupEditAutoValueButton(titleInput);
|
||||
setupEditAutoValueButton(descriptionInput);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
@@ -15,6 +15,10 @@
|
||||
<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>
|
||||
|
@@ -53,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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
51
bookmarks/tests/test_bookmarks_list_tag.py
Normal file
51
bookmarks/tests/test_bookmarks_list_tag.py
Normal 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)
|
65
bookmarks/tests/test_utils.py
Normal file
65
bookmarks/tests/test_utils.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
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_absolute_date_should_use_current_date_as_default(self):
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
|
||||
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 1)), 'Today')
|
||||
|
||||
# Regression: Test that subsequent calls use current date instead of cached date (#107)
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
|
||||
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 13)), 'Today')
|
||||
|
||||
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)
|
||||
|
||||
def test_humanize_relative_date_should_use_current_date_as_default(self):
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
|
||||
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 1)), 'Today')
|
||||
|
||||
# Regression: Test that subsequent calls use current date instead of cached date (#107)
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
|
||||
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 13)), 'Today')
|
@@ -1,2 +1,60 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
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: Optional[datetime] = None):
|
||||
if not now:
|
||||
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: Optional[datetime] = None):
|
||||
if not now:
|
||||
now = 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()]
|
||||
|
65
docs/Admin.md
Normal file
65
docs/Admin.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Administration
|
||||
|
||||
This document describes how to make use of the admin app that comes as part of each linkding installation. This is the default Django admin app with some linkding specific customizations.
|
||||
|
||||
The admin app provides several features that are not available in the linkding UI:
|
||||
- User management and user self-management
|
||||
- Bookmark and tag management, including bulk operations
|
||||
|
||||
## Linkding administration page
|
||||
|
||||
To open the Admin app, go the *Settings* view and click on the *Admin* tab. This should open a new window with the admin app.
|
||||
|
||||
Alternatively you can open the URL directly by adding `/admin` to the URL of your linkding installation.
|
||||
|
||||
## User management
|
||||
|
||||
Go to the linkding administration page and select *Users*.
|
||||
Here you can add and delete users, and change the password of a user.
|
||||
|
||||
Once you have added a user you can, if needed, give the user staff status, which means this user can also access the linkding administration page.
|
||||
|
||||
This page also allows you to change your own password if necessary.
|
||||
|
||||
## Bookmark management
|
||||
|
||||
While the linkding UI itself now has a bulk edit feature for bookmarks you can also use the admin app to manage bookmarks or to do bulk operations.
|
||||
|
||||
In the main linkding administration page, choose *Bookmarks*.
|
||||
|
||||
First select the bookmarks to operate on:
|
||||
|
||||
- Specify a filter to determine which bookmarks to operate on:
|
||||
- In the column *by username*, you can choose to filter for bookmarks for a specific user
|
||||
- In the column *by is archived*, you can choose to filter for bookmarks that are either archived or not
|
||||
- In the column *by tags*, you can choose to filter for specific tags
|
||||
- In the search box you can also add a text filter (note that this doesn't use the same search syntax as the linkding UI itself)
|
||||
|
||||
Now a list of bookmarks which match your filter is displayed, each bookmark on a separate line.
|
||||
Each line starts with a checkbox.
|
||||
Either choose the individual bookmarks you want to do a bulk operation on, or choose the top checkbox to select all shown bookmarks.
|
||||
|
||||
Open the "Action" select box to choose the desired bulk operation:
|
||||
|
||||
- Delete
|
||||
- Archive
|
||||
- Unarchive
|
||||
|
||||
Click the button next to the checkbox to execute the operation.
|
||||
|
||||
## Tag management
|
||||
|
||||
While linkding UI currently only allows to create or assign tags, you can use the admin app to manage your tags. This can be especially useful if you want to clean up your tag collection.
|
||||
|
||||
In the main linkding administration page, choose *Tags*.
|
||||
|
||||
Similar to bookmarks management described above you can now specify which tags to operate on by specifying a filter and then selecting the individual tags.
|
||||
|
||||
Open the "Action" select box to choose the desired bulk operation:
|
||||
|
||||
- Delete
|
||||
- Delete unused tags - this will only delete the selected tags that are currently not assigned to any bookmark
|
||||
|
||||
Click the button next to the checkbox to execute the operation.
|
||||
|
||||
Note that deleting a tag does not affect the bookmarks that are tagged with this tag, it only removes the tag from those bookmarks.
|
46
docs/how-to.md
Normal file
46
docs/how-to.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# How To
|
||||
|
||||
Collection of tips and tricks around using linkding.
|
||||
|
||||
## Using the bookmarklet on Android/Chrome
|
||||
|
||||
This how-to explains the usage of the standard linkding bookmarklet on Android / Chrome.
|
||||
|
||||
Chrome on Android does not permit running bookmarklets in the same way you can on a desktop system. There is however a workaround that is explained here.
|
||||
|
||||
**Note** that this only works with Chrome and not with other browsers on Android.
|
||||
|
||||
Create a bookmark of your linkding deployment by clicking the star icon which you find in the three dots menu in the top right. Next you have to edit the bookmark. Edit the URL and replace it it with the bookmarklet code of your instance and give it an easy to type name like `bm` for bookmark or `ld` for linkding:
|
||||
|
||||
```
|
||||
javascript: (function() { var bookmarkUrl = window.location; var applicationUrl = 'http://<YOUR_INSTANCE_HERE>/bookmarks/new'; applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '&auto_close'; window.open(applicationUrl);})();
|
||||
```
|
||||
|
||||
Now when you are browsing the web and you want to save the current page as a bookmark to your linkding instance simply type `bm` into the address bar and select it from the results. The bookmarklet code will trigger and you will be redirected so save the current page.
|
||||
|
||||
For more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-android/
|
||||
|
||||
## Create a share action on iOS for adding bookmarks to linkding
|
||||
|
||||
This how-to explains how to make use of the app shortcuts iOS app to create a share action that can be used in Safari for adding bookmarks to your linkding instance.
|
||||
|
||||
**In the shortcuts app:**
|
||||
- create new shortcut
|
||||
- go to shortcut details, enable to option to show the shortcut in share menu
|
||||
- from the available share input types only select "URL"
|
||||
- add Safari action "Display website in Safari" (paraphrasing, not sure how it's called in english)
|
||||
- for URL enter your linkding instance URL and specifically point to the new bookmark form, then add the shortcut input variable from the list of suggested variables after the URL parameter. Visually it should look something like this: `https://linkding.mydomain.com/bookmarks/new?url=[Shortcut input]`, where `[Shortcut input]` is a visual block that was inserted after selecting the shortcut input variable suggestion. This is basically a placeholder that will get replaced with the actual URL that you want to bookmark. See screenshot at the end for an example on how this looks.
|
||||
- save, give the shortcut a nice name + glyph
|
||||
|
||||
Example of how the shortcut configuration should look:
|
||||
|
||||

|
||||
|
||||
**Using the share action from Safari:**
|
||||
- browse to the website that you want to share
|
||||
- click the share button
|
||||
- your new app shortcut should now be available as share action
|
||||
- select the app shortcut
|
||||
- this should open a new Safari overlay showing the add bookmark form with the URL field prefilled
|
||||
- after saving the bookmark you can close the overlay and continue browsing
|
||||
|
BIN
docs/ios-app-shortcut-example.png
Normal file
BIN
docs/ios-app-shortcut-example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 107 KiB |
@@ -2,18 +2,19 @@ beautifulsoup4==4.7.1
|
||||
certifi==2019.6.16
|
||||
chardet==3.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==2.2.18
|
||||
Django==2.2.20
|
||||
django-generate-secret-key==1.0.2
|
||||
django-picklefield==2.0
|
||||
django-registration==3.0.1
|
||||
django-registration==3.1.2
|
||||
django-sass-processor==0.7.3
|
||||
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
|
||||
sqlparse==0.3.0
|
||||
urllib3==1.25.3
|
||||
urllib3==1.25.8
|
||||
uWSGI==2.0.18
|
||||
|
@@ -2,19 +2,20 @@ beautifulsoup4==4.7.1
|
||||
certifi==2019.6.16
|
||||
chardet==3.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==2.2.18
|
||||
Django==2.2.20
|
||||
django-appconf==1.0.3
|
||||
django-compressor==2.3
|
||||
django-debug-toolbar==3.2
|
||||
django-debug-toolbar==3.2.1
|
||||
django-generate-secret-key==1.0.2
|
||||
django-picklefield==2.0
|
||||
django-registration==3.0.1
|
||||
django-registration==3.1.2
|
||||
django-sass-processor==0.7.3
|
||||
django-widget-tweaks==1.4.5
|
||||
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
|
||||
@@ -22,4 +23,4 @@ rjsmin==1.1.0
|
||||
six==1.12.0
|
||||
soupsieve==1.9.2
|
||||
sqlparse==0.3.0
|
||||
urllib3==1.25.3
|
||||
urllib3==1.25.8
|
||||
|
@@ -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'
|
||||
|
@@ -1 +1 @@
|
||||
1.6.0
|
||||
1.6.4
|
||||
|
Reference in New Issue
Block a user