mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-13 04:29:39 +02:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
dbe92b4b84 | ||
![]() |
90f62d3482 | ||
![]() |
847f9644f4 | ||
![]() |
bf84b3ddfd | ||
![]() |
2d19e97212 | ||
![]() |
c083997399 | ||
![]() |
36f134db9a | ||
![]() |
593d90d8e2 | ||
![]() |
7a68a4abed | ||
![]() |
8dd1575dc6 | ||
![]() |
d4d23daebc |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## v1.5.0 (28/03/2021)
|
||||||
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
|
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
|
||||||
---
|
---
|
||||||
|
@@ -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_LIGHT, 'Light'),
|
||||||
(THEME_DARK, 'Dark'),
|
(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)
|
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)
|
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 UserProfileForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
fields = ['theme']
|
fields = ['theme', 'bookmark_date_display']
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=get_user_model())
|
@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
|
tags__name__iexact=tag_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort by modification date
|
# Sort by date added
|
||||||
query_set = query_set.order_by('-date_modified')
|
query_set = query_set.order_by('-date_added')
|
||||||
|
|
||||||
return query_set
|
return query_set
|
||||||
|
|
||||||
|
@@ -50,16 +50,22 @@ ul.bookmark-list {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions > *:not(:last-child) {
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.actions .btn-link {
|
.actions .btn-link {
|
||||||
color: $gray-color;
|
color: $gray-color;
|
||||||
padding-left: 0;
|
padding: 0;
|
||||||
padding-right: 0;
|
height: auto;
|
||||||
|
vertical-align: unset;
|
||||||
|
border: none;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
&.active {
|
&.active {
|
||||||
color: darken($gray-color, 10%);
|
color: $gray-color-dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +97,41 @@ ul.bookmark-list {
|
|||||||
|
|
||||||
.bookmarks-form {
|
.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 {
|
.form-icon.loading {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input-hint.bookmark-exists {
|
.form-input-hint.bookmark-exists {
|
||||||
visibility: hidden;
|
display: none;
|
||||||
color: $warning-color;
|
color: $warning-color;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -157,6 +192,7 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
span.confirmation {
|
span.confirmation {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.confirmation button {
|
span.confirmation button {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -202,4 +238,4 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
|
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
|
||||||
max-height: 37px;
|
max-height: 37px;
|
||||||
border-bottom: solid 1px $border-color;
|
border-bottom: solid 1px $border-color;
|
||||||
}
|
}
|
||||||
|
@@ -14,4 +14,4 @@
|
|||||||
|
|
||||||
.text-gray-dark {
|
.text-gray-dark {
|
||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
}
|
}
|
||||||
|
@@ -26,6 +26,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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 }}"
|
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
||||||
class="btn btn-link btn-sm">Edit</a>
|
class="btn btn-link btn-sm">Edit</a>
|
||||||
{% if bookmark.is_archived %}
|
{% if bookmark.is_archived %}
|
||||||
@@ -44,4 +52,4 @@
|
|||||||
|
|
||||||
<div class="bookmark-pagination">
|
<div class="bookmark-pagination">
|
||||||
{% pagination bookmarks %}
|
{% pagination bookmarks %}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -14,12 +14,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-input-hint bookmark-exists">
|
<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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||||
{{ form.tag_string|add_class:"form-input" }}
|
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||||
exist it will be
|
exist it will be
|
||||||
@@ -30,8 +31,16 @@
|
|||||||
<div class="form-group has-icon-right">
|
<div class="form-group has-icon-right">
|
||||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||||
<div class="has-icon-right">
|
<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>
|
<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>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Optional, leave empty to use title from website.
|
Optional, leave empty to use title from website.
|
||||||
@@ -43,6 +52,14 @@
|
|||||||
<div class="has-icon-right">
|
<div class="has-icon-right">
|
||||||
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
|
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
|
||||||
<i class="form-icon loading"></i>
|
<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>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Optional, leave empty to use description from website.
|
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
|
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
|
||||||
* - Show hint if URL is already bookmarked
|
* - Show hint if URL is already bookmarked
|
||||||
|
* - Setup buttons that allow editing of scraped website values
|
||||||
*/
|
*/
|
||||||
(function init() {
|
(function init() {
|
||||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||||
const descriptionInput = document.getElementById('{{ form.description.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() {
|
function checkUrl() {
|
||||||
toggleIcon(titleInput, true);
|
toggleLoadingIcon(titleInput, true);
|
||||||
toggleIcon(descriptionInput, true);
|
toggleLoadingIcon(descriptionInput, true);
|
||||||
|
updatePlaceholder(titleInput, null);
|
||||||
|
updatePlaceholder(descriptionInput, null);
|
||||||
|
|
||||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||||
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||||
fetch(requestUrl)
|
fetch(requestUrl)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const metadata = data.metadata
|
const metadata = data.metadata;
|
||||||
titleInput.setAttribute('placeholder', metadata.title || '');
|
updatePlaceholder(titleInput, metadata.title);
|
||||||
descriptionInput.setAttribute('placeholder', metadata.description || '');
|
updatePlaceholder(descriptionInput, metadata.description);
|
||||||
toggleIcon(titleInput, false);
|
toggleLoadingIcon(titleInput, false);
|
||||||
toggleIcon(descriptionInput, false);
|
toggleLoadingIcon(descriptionInput, false);
|
||||||
|
|
||||||
// Display hint if URL is already bookmarked
|
// Display hint if URL is already bookmarked
|
||||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists')
|
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||||
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a')
|
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
|
||||||
|
|
||||||
if(data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
||||||
bookmarkExistsHint.style['visibility'] = 'visible'
|
bookmarkExistsHint.style['display'] = 'block';
|
||||||
editExistingBookmarkLink.href = data.bookmark.edit_url
|
editExistingBookmarkLink.href = data.bookmark.edit_url;
|
||||||
} else {
|
} else {
|
||||||
bookmarkExistsHint.style['visibility'] = 'hidden'
|
bookmarkExistsHint.style['display'] = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleIcon(input, show) {
|
function setupEditAutoValueButton(input) {
|
||||||
const icon = input.parentNode.querySelector('i.form-icon');
|
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
|
||||||
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
if (!editAutoValueButton) return;
|
||||||
|
editAutoValueButton.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
input.value = input.getAttribute('placeholder');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlInput.value) checkUrl();
|
if (urlInput.value) checkUrl();
|
||||||
|
urlInput.addEventListener('input', checkUrl);
|
||||||
|
setupEditAutoValueButton(titleInput);
|
||||||
|
setupEditAutoValueButton(descriptionInput);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -15,6 +15,10 @@
|
|||||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||||
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
|
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<input type="submit" value="Save" class="btn btn-primary mt-2">
|
<input type="submit" value="Save" class="btn btn-primary mt-2">
|
||||||
</div>
|
</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)
|
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
|
||||||
def bookmark_list(context, bookmarks: Page, return_url: str):
|
def bookmark_list(context, bookmarks: Page, return_url: str):
|
||||||
return {
|
return {
|
||||||
|
'request': context['request'],
|
||||||
'bookmarks': bookmarks,
|
'bookmarks': bookmarks,
|
||||||
'return_url': return_url
|
'return_url': return_url
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
|
from bookmarks import utils
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@@ -43,3 +45,17 @@ def first_char(text):
|
|||||||
@register.filter(name='remaining_chars')
|
@register.filter(name='remaining_chars')
|
||||||
def remaining_chars(text, index):
|
def remaining_chars(text, index):
|
||||||
return 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):
|
def unique(elements, key):
|
||||||
return list({key(element): element for element in elements}.values())
|
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()]
|
||||||
|
@@ -11,6 +11,7 @@ django-widget-tweaks==1.4.5
|
|||||||
djangorestframework==3.11.2
|
djangorestframework==3.11.2
|
||||||
idna==2.8
|
idna==2.8
|
||||||
pyparsing==2.4.7
|
pyparsing==2.4.7
|
||||||
|
python-dateutil==2.8.1
|
||||||
pytz==2019.1
|
pytz==2019.1
|
||||||
requests==2.22.0
|
requests==2.22.0
|
||||||
soupsieve==1.9.2
|
soupsieve==1.9.2
|
||||||
|
@@ -15,6 +15,7 @@ djangorestframework==3.11.2
|
|||||||
idna==2.8
|
idna==2.8
|
||||||
libsass==0.19.2
|
libsass==0.19.2
|
||||||
pyparsing==2.4.7
|
pyparsing==2.4.7
|
||||||
|
python-dateutil==2.8.1
|
||||||
pytz==2019.1
|
pytz==2019.1
|
||||||
rcssmin==1.0.6
|
rcssmin==1.0.6
|
||||||
requests==2.22.0
|
requests==2.22.0
|
||||||
|
@@ -52,6 +52,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'siteroot.urls'
|
ROOT_URLCONF = 'siteroot.urls'
|
||||||
|
@@ -1 +1 @@
|
|||||||
1.6.0
|
1.6.3
|
||||||
|
Reference in New Issue
Block a user