Add read it later functionality (#304)

* Allow marking bookmarks as unread

* Restructure navigation to include preset filters

* Add mark as read action

* Improve description

* Highlight unread bookmarks visually

* Mark bookmarks as read by default

* Add tests

* Implement toread flag in importer

* Implement admin actions

* Add query tests

* Remove untagged link

* Update api docs

* Reduce height of description textarea

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
This commit is contained in:
Sascha Ißbrücker 2022-07-23 22:17:20 +02:00 committed by GitHub
parent 48e4958218
commit 13ff9ac4f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 345 additions and 74 deletions

View file

@ -21,9 +21,9 @@ class LinkdingAdminSite(AdminSite):
class AdminBookmark(admin.ModelAdmin):
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
list_filter = ('owner__username', 'is_archived', 'tags',)
list_filter = ('owner__username', 'is_archived', 'unread', 'tags',)
ordering = ('-date_added',)
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks']
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks', 'mark_as_read', 'mark_as_unread']
def archive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
@ -45,6 +45,24 @@ class AdminBookmark(admin.ModelAdmin):
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
def mark_as_read(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
queryset.update(unread=False)
self.message_user(request, ngettext(
'%d bookmark marked as read.',
'%d bookmarks marked as read.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
def mark_as_unread(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
queryset.update(unread=True)
self.message_user(request, ngettext(
'%d bookmark marked as unread.',
'%d bookmarks marked as unread.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
class AdminTag(admin.ModelAdmin):
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')

View file

@ -20,6 +20,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'website_title',
'website_description',
'is_archived',
'unread',
'tag_names',
'date_added',
'date_modified'
@ -35,6 +36,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
@ -44,12 +46,13 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread']
tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
for key in ['url', 'title', 'description']:
for key in ['url', 'title', 'description', 'unread']:
if key in validated_data:
setattr(instance, key, validated_data[key])

View file

@ -0,0 +1,27 @@
# Generated by Django 3.2.13 on 2022-07-23 12:30
from django.db import migrations, models
def forwards(apps, schema_editor):
Bookmark = apps.get_model('bookmarks', 'Bookmark')
Bookmark.objects.update(unread=False)
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0013_web_archive_optin_toast'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='unread',
field=models.BooleanField(default=False),
),
migrations.RunPython(forwards, reverse),
]

View file

@ -50,7 +50,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
unread = models.BooleanField(default=True)
unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
date_added = models.DateTimeField()
date_modified = models.DateTimeField()
@ -97,12 +97,13 @@ class BookmarkForm(forms.ModelForm):
required=False)
description = forms.CharField(required=False,
widget=forms.Textarea())
unread = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'auto_close']
fields = ['url', 'tag_string', 'title', 'description', 'unread', 'auto_close']
class UserProfile(models.Model):

View file

@ -60,6 +60,11 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
query_set = query_set.filter(
tags=None
)
# Unread bookmarks
if query['unread']:
query_set = query_set.filter(
unread=True
)
# Sort by date added
query_set = query_set.order_by('-date_added')
@ -102,9 +107,11 @@ def _parse_query_string(query_string):
# Special search commands
untagged = '!untagged' in keywords
unread = '!unread' in keywords
return {
'search_terms': search_terms,
'tag_names': tag_names,
'untagged': untagged,
'unread': unread,
}

View file

@ -192,7 +192,7 @@ def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
bookmark.unread = False
bookmark.unread = netscape_bookmark.to_read
if netscape_bookmark.title:
bookmark.title = netscape_bookmark.title
if netscape_bookmark.description:

View file

@ -10,6 +10,7 @@ class NetscapeBookmark:
description: str
date_added: str
tag_string: str
to_read: bool
class BookmarkParser(HTMLParser):
@ -24,6 +25,7 @@ class BookmarkParser(HTMLParser):
self.tags = ''
self.title = ''
self.description = ''
self.toread = ''
def handle_starttag(self, tag: str, attrs: list):
name = 'handle_start_' + tag.lower()
@ -56,6 +58,7 @@ class BookmarkParser(HTMLParser):
description='',
date_added=self.add_date,
tag_string=self.tags,
to_read=self.toread == '1'
)
def handle_a_data(self, data):
@ -75,6 +78,7 @@ class BookmarkParser(HTMLParser):
self.tags = ''
self.title = ''
self.description = ''
self.toread = ''
def parse(html: str) -> List[NetscapeBookmark]:

View file

@ -33,10 +33,8 @@
{# Tag list #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header align-baseline">
<div class="content-area-header">
<h2>Tags</h2>
<div class="spacer"></div>
<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>
</div>
{% tag_cloud tags %}
</section>

View file

@ -9,7 +9,7 @@
<i class="form-icon"></i>
</label>
<div class="title truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener">{{ bookmark.resolved_title }}</a>
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" class="{% if bookmark.unread %}text-italic{% endif %}">{{ bookmark.resolved_title }}</a>
</div>
<div class="description truncate">
{% if bookmark.tag_names %}
@ -65,6 +65,11 @@
{% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
{% if bookmark.unread %}
<span class="text-gray text-sm">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read</button>
{% endif %}
</div>
</li>
{% endfor %}

View file

@ -49,7 +49,7 @@
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
<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">
@ -65,6 +65,16 @@
</div>
{{ form.description.errors }}
</div>
<div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }}
<i class="form-icon"></i>
<span>Mark as unread</span>
</label>
<div class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div>
</div>
<br/>
<div class="form-group">
{% if auto_close %}

View file

@ -33,10 +33,8 @@
{# Tag list #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header align-baseline">
<div class="content-area-header">
<h2>Tags</h2>
<div class="spacer"></div>
<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>
</div>
{% tag_cloud tags %}
</section>

View file

@ -1,7 +1,28 @@
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
<div class="dropdown">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
Bookmarks
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" style="height:1rem;width:1rem;vertical-align: text-bottom;">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a>
<ul class="menu">
<li>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
</li>
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li>
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div>
@ -21,7 +42,16 @@
<!-- menu component -->
<ul class="menu">
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li>
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>

View file

@ -23,6 +23,7 @@ class BookmarkFactoryMixin:
def setup_bookmark(self,
is_archived: bool = False,
unread: bool = False,
tags=None,
user: User = None,
url: str = '',
@ -49,6 +50,7 @@ class BookmarkFactoryMixin:
date_modified=timezone.now(),
owner=user,
is_archived=is_archived,
unread=unread,
web_archive_snapshot_url=web_archive_snapshot_url,
)
bookmark.save()
@ -95,12 +97,19 @@ class LinkdingApiTestCase(APITestCase):
class BookmarkHtmlTag:
def __init__(self, href: str = '', title: str = '', description: str = '', add_date: str = '', tags: str = ''):
def __init__(self,
href: str = '',
title: str = '',
description: str = '',
add_date: str = '',
tags: str = '',
to_read: bool = False):
self.href = href
self.title = title
self.description = description
self.add_date = add_date
self.tags = tags
self.to_read = to_read
class ImportTestMixin:
@ -109,7 +118,8 @@ class ImportTestMixin:
<DT>
<A {f'HREF="{tag.href}"' if tag.href else ''}
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
{f'TAGS="{tag.tags}"' if tag.tags else ''}>
{f'TAGS="{tag.tags}"' if tag.tags else ''}
TOREAD="{1 if tag.to_read else 0}">
{tag.title if tag.title else ''}
</A>
{f'<DD>{tag.description}' if tag.description else ''}

View file

@ -84,6 +84,16 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 404)
self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())
def test_mark_as_read(self):
bookmark = self.setup_bookmark(unread=True)
self.client.post(reverse('bookmarks:action'), {
'mark_as_read': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertFalse(bookmark.unread)
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()

View file

@ -18,7 +18,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html
)
@ -27,7 +27,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html,
count=0
)

View file

@ -20,6 +20,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'unread': False,
}
return {**form_data, **overrides}
@ -35,10 +36,21 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
def test_should_mark_bookmark_as_unread(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id, 'unread': True})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertTrue(bookmark.unread)
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
@ -65,7 +77,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'value="{0}" id="id_title">'.format(bookmark.title),
html)
self.assertInHTML('<textarea name="description" cols="40" rows="4" class="form-input" id="id_description">{0}'
self.assertInHTML('<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">{0}'
'</textarea>'.format(bookmark.description),
html)

View file

@ -18,7 +18,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html
)
@ -27,7 +27,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
html,
count=0
)
@ -156,8 +156,3 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_should_show_link_for_untagged_bookmarks(self):
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>')

View file

@ -19,6 +19,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'unread': False,
'auto_close': '',
}
return {**form_data, **overrides}
@ -35,10 +36,21 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({'unread': True})
self.client.post(reverse('bookmarks:new'), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
self.assertTrue(bookmark.unread)
def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode()
@ -64,7 +76,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">',html)
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">', html)
def test_should_redirect_to_index_view(self):
form_data = self.create_form_data()

View file

@ -35,6 +35,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
expectation['unread'] = bookmark.unread
expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
@ -51,6 +52,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'title': 'Test title',
'description': 'Test description',
'is_archived': False,
'unread': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
@ -59,6 +61,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
@ -113,6 +116,31 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_is_not_archived_by_default(self):
data = {
'url': 'https://example.com/',
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived)
def test_create_unread_bookmark(self):
data = {
'url': 'https://example.com/',
'unread': True,
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.unread)
def test_create_bookmark_is_not_unread_by_default(self):
data = {
'url': 'https://example.com/',
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.unread)
def test_create_bookmark_minimal_payload_does_not_archive(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
@ -165,6 +193,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description'])
data = {'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertTrue(self.bookmark1.unread)
data = {'unread': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.unread)
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)

View file

@ -10,9 +10,14 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank', unread: bool = False):
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
f'''
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener"
class="{'text-italic' if unread else ''}">{bookmark.resolved_title}</a>
''',
html
)
@ -114,6 +119,15 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_bookmark_link_target_should_respect_unread_flag(self):
bookmark = self.setup_bookmark()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=False)
bookmark = self.setup_bookmark(unread=True)
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self', unread=True)
def test_web_archive_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)

View file

@ -21,6 +21,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(bookmark.title, html_tag.title)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
self.assertEqual(bookmark.unread, html_tag.to_read)
tag_names = parse_tag_string(html_tag.tags)
@ -34,52 +35,16 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
BookmarkHtmlTag(href='https://example.com/bar', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/baz', title='Baz title', description='Baz description',
add_date='4', to_read=True),
]
import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user())
# Check result
self.assertEqual(result.total, 3)
self.assertEqual(result.success, 3)
self.assertEqual(result.failed, 0)
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 3)
self.assertBookmarksImported(html_tags)
def test_synchronize(self):
# Initial import
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
# Change data, add some new data
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
description='Updated Example description', add_date='111', tags='updated-example-tag'),
BookmarkHtmlTag(href='https://foo.com', title='Updated Foo title', description='Updated Foo description',
add_date='222', tags='new-tag'),
BookmarkHtmlTag(href='https://bar.com', title='Updated Bar title', description='Updated Bar description',
add_date='333', tags='updated-bar-tag, updated-other-tag'),
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
]
# Import updated data
import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user())
# Check result
self.assertEqual(result.total, 4)
self.assertEqual(result.success, 4)
@ -90,6 +55,48 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(len(bookmarks), 4)
self.assertBookmarksImported(html_tags)
def test_synchronize(self):
# Initial import
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://example.com/bar', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=True),
]
import_html = self.render_html(tags=html_tags)
import_netscape_html(import_html, self.get_or_create_test_user())
# Change data, add some new data
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
description='Updated Example description', add_date='111', tags='updated-example-tag'),
BookmarkHtmlTag(href='https://example.com/foo', title='Updated Foo title', description='Updated Foo description',
add_date='222', tags='new-tag'),
BookmarkHtmlTag(href='https://example.com/bar', title='Updated Bar title', description='Updated Bar description',
add_date='333', tags='updated-bar-tag, updated-other-tag'),
BookmarkHtmlTag(href='https://example.com/unread', title='Unread title', description='Unread description',
add_date='3', to_read=False),
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
]
# Import updated data
import_html = self.render_html(tags=html_tags)
result = import_netscape_html(import_html, self.get_or_create_test_user())
# Check result
self.assertEqual(result.total, 5)
self.assertEqual(result.success, 5)
self.assertEqual(result.failed, 0)
# Check bookmarks
bookmarks = Bookmark.objects.all()
self.assertEqual(len(bookmarks), 5)
self.assertBookmarksImported(html_tags)
def test_import_with_some_invalid_bookmarks(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com'),

View file

@ -17,15 +17,18 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual(bookmark.date_added, html_tag.add_date)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.tag_string, html_tag.tags)
self.assertEqual(bookmark.to_read, html_tag.to_read)
def test_parse_bookmarks(self):
html_tags = [
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
add_date='1', tags='example-tag'),
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
BookmarkHtmlTag(href='https://example.com/foo', title='Foo title', description='',
add_date='2', tags=''),
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
BookmarkHtmlTag(href='https://example.com/bar', title='Bar title', description='Bar description',
add_date='3', tags='bar-tag, other-tag'),
BookmarkHtmlTag(href='https://example.com/baz', title='Baz title', description='Baz description',
add_date='3', to_read=True),
]
html = self.render_html(html_tags)
bookmarks = parse(html)

View file

@ -343,6 +343,32 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmarks(self.user, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
unread_bookmarks = [
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
]
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_bookmarks(self.user, '!unread')
self.assertCountEqual(list(query), unread_bookmarks)
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
unread_bookmarks = [
self.setup_bookmark(is_archived=True, unread=True),
self.setup_bookmark(is_archived=True, unread=True),
self.setup_bookmark(is_archived=True, unread=True),
]
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_archived_bookmarks(self.user, '!unread')
self.assertCountEqual(list(query), unread_bookmarks)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data()

View file

@ -162,6 +162,16 @@ def unarchive(request, bookmark_id: int):
unarchive_bookmark(bookmark)
def mark_as_read(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
raise Http404('Bookmark does not exist')
bookmark.unread = False
bookmark.save()
@login_required
def action(request):
# Determine action
@ -171,6 +181,8 @@ def action(request):
unarchive(request, request.POST['unarchive'])
if 'remove' in request.POST:
remove(request, request.POST['remove'])
if 'mark_as_read' in request.POST:
mark_as_read(request, request.POST['mark_as_read'])
if 'bulk_archive' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
archive_bookmarks(bookmark_ids, request.user)

View file

@ -47,6 +47,8 @@ Example response:
"description": "Example description",
"website_title": "Website title",
"website_description": "Website description",
"is_archived": false,
"unread": false,
"tag_names": [
"tag1",
"tag2"
@ -94,6 +96,7 @@ Example payload:
"title": "Example title",
"description": "Example description",
"is_archived": false,
"unread": false,
"tag_names": [
"tag1",
"tag2"
@ -107,7 +110,33 @@ Example payload:
PUT /api/bookmarks/<id>/
```
Updates a bookmark. Tags are simply assigned using their names.
Updates a bookmark.
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
Tags are simply assigned using their names.
Example payload:
```json
{
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"tag_names": [
"tag1",
"tag2"
]
}
```
**Patch**
```
PATCH /api/bookmarks/<id>/
```
Updates a bookmark partially.
Allows to modify individual fields of a bookmark.
Tags are simply assigned using their names.
Example payload: