Implement archive feature (#73)

* Implement archive function (#46)

* Implement archive view (#46)

* Filter tags for archived/unarchived (#46)

* Implement archived bookmarks endpoint (#46)

* Implement archive mode for search component (#46)

* Move bookmarklet to settings (#46)

* Update modified timestamp on archive/unarchive (#46)

* Fix bookmarklet (#46)
This commit is contained in:
Sascha Ißbrücker
2021-02-14 18:00:22 +01:00
committed by GitHub
parent f555bba9e9
commit c61e8ee2cd
23 changed files with 398 additions and 85 deletions

12
API.md
View File

@@ -49,7 +49,7 @@ Example response:
"website_description": "Website description",
"tag_names": [
"tag1",
"tag2"
"tag2"
],
"date_added": "2020-09-26T09:46:23.006313Z",
"date_modified": "2020-09-26T16:01:14.275335Z"
@@ -59,6 +59,16 @@ Example response:
}
```
**Archived**
```
GET /api/bookmarks/archived/
```
List archived bookmarks.
Parameters and response are the same as for the regular list endpoint.
**Retrieve**
```

View File

@@ -1,4 +1,6 @@
from rest_framework import viewsets, mixins
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from bookmarks import queries
@@ -27,6 +29,16 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def get_serializer_context(self):
return {'user': self.request.user}
@action(methods=['get'], detail=False)
def archived(self, request):
user = request.user
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(user, query_string)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
return self.get_paginated_response(data)
class TagViewSet(viewsets.GenericViewSet,
mixins.ListModelMixin,

View File

@@ -8,6 +8,7 @@
export let placeholder;
export let value;
export let tags;
export let mode = 'default';
export let apiClient;
let isFocus = false;
@@ -111,7 +112,9 @@
let bookmarks = []
if (value && value.length >= 3) {
const fetchedBookmarks = await apiClient.getBookmarks(value, {limit: 5, offset: 0})
const fetchedBookmarks = mode === 'archive'
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
: await apiClient.getBookmarks(value, {limit: 5, offset: 0})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)

View File

@@ -11,4 +11,13 @@ export class ApiClient {
.then(response => response.json())
.then(data => data.results)
}
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
const encodedQuery = encodeURIComponent(query)
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
}

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.13 on 2021-02-14 09:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0005_auto_20210103_1212'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='is_archived',
field=models.BooleanField(default=False),
),
]

View File

@@ -39,6 +39,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
unread = models.BooleanField(default=True)
is_archived = models.BooleanField(default=False)
date_added = models.DateTimeField()
date_modified = models.DateTimeField()
date_accessed = models.DateTimeField(blank=True, null=True)

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.models import User
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet
from bookmarks.models import Bookmark, Tag
from bookmarks.utils import unique
@@ -17,7 +17,17 @@ class Concat(Aggregate):
**extra)
def query_bookmarks(user: User, query_string: str):
def query_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(is_archived=False)
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(is_archived=True)
def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
# Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'),
@@ -51,7 +61,19 @@ def query_bookmarks(user: User, query_string: str):
return query_set
def query_tags(user: User, query_string: str):
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=False) \
.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=True) \
.distinct()
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet:
query_set = Tag.objects
# Filter for user

View File

@@ -39,6 +39,20 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
return bookmark
def archive_bookmark(bookmark: Bookmark):
bookmark.is_archived = True
bookmark.date_modified = timezone.now()
bookmark.save()
return bookmark
def unarchive_bookmark(bookmark: Bookmark):
bookmark.is_archived = False
bookmark.date_modified = timezone.now()
bookmark.save()
return bookmark
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description

View File

@@ -0,0 +1,34 @@
{% extends "bookmarks/layout.html" %}
{% load static %}
{% load shared %}
{% load bookmarks %}
{% block content %}
<div class="bookmarks-page columns">
{# Bookmark list #}
<section class="content-area column col-8 col-md-12">
<div class="content-area-header">
<h2>Archived bookmarks</h2>
<div class="spacer"></div>
{% bookmark_search query tags mode='archive' %}
</div>
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</section>
{# Tag list #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header">
<h2>Tags</h2>
</div>
{% tag_cloud tags %}
</section>
</div>
<script src="{% static "bundle.js" %}"></script>
{% endblock %}

View File

@@ -24,6 +24,13 @@
<div class="actions">
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Unarchive</a>
{% else %}
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Archive</a>
{% endif %}
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm"
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>

View File

@@ -1,24 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="columns">
<section class="content-area column col-12">
<div class="content-area-header">
<h2>Bookmarklet</h2>
</div>
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
</div>
{% endblock %}

View File

@@ -1,8 +1,8 @@
<div class="empty">
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks, <a
href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or <a
href="{% url 'bookmarks:bookmarklet' %}">configuring</a> the bookmarklet.
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
<a href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
</p>
</div>

View File

@@ -11,17 +11,7 @@
<div class="content-area-header">
<h2>Bookmarks</h2>
<div class="spacer"></div>
<div class="search">
<form action="{% url 'bookmarks:index' %}" method="get" role="search">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
</form>
</div>
{% bookmark_search query tags %}
</div>
{% if empty %}
@@ -40,24 +30,5 @@
</section>
</div>
{# Replace search input with auto-complete component #}
<script src="{% static "bundle.js" %}"></script>
<script type="application/javascript">
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ query }}',
tags: currentTags,
apiClient
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
</script>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{# 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:bookmarklet' %}" class="btn btn-link">Bookmarklet</a>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div>
@@ -17,7 +17,7 @@
<!-- menu component -->
<ul class="menu">
<li>
<a href="{% url 'bookmarks:bookmarklet' %}" class="btn btn-link">Bookmarklet</a>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>

View File

@@ -0,0 +1,34 @@
<div class="search">
<form action="" method="get" role="search">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
</form>
</div>
{# Replace search input with auto-complete component #}
<script type="application/javascript">
window.addEventListener("load", function() {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ query }}',
tags: currentTags,
mode: '{{ mode }}',
apiClient
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
});
</script>

View File

@@ -51,6 +51,25 @@
{% endif %}
</section>
{# Integrations section #}
<section class="content-area">
<div class="content-area-header">
<a id="bookmarklet"><h2>Bookmarklet</h2></a>
</div>
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
{# API token section #}
<section class="content-area">
<div class="content-area-header">

View File

@@ -61,3 +61,14 @@ def bookmark_list(context, bookmarks: Page, return_url: str):
'bookmarks': bookmarks,
'return_url': return_url
}
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
def bookmark_search(context, query: str, tags: [Tag], mode: str = 'default'):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
return {
'query': query,
'tags_string': tags_string,
'mode': mode,
}

View File

@@ -0,0 +1,47 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
User = get_user_model()
class BookmarkServiceTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def test_archive(self):
bookmark = Bookmark(
url='https://example.com',
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user
)
bookmark.save()
self.assertFalse(bookmark.is_archived)
archive_bookmark(bookmark)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertTrue(updated_bookmark.is_archived)
def test_unarchive(self):
bookmark = Bookmark(
url='https://example.com',
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
is_archived=True,
)
bookmark.save()
unarchive_bookmark(bookmark)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertFalse(updated_bookmark.is_archived)

View File

@@ -0,0 +1,99 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from django.utils.crypto import get_random_string
from bookmarks.models import Bookmark, Tag
from bookmarks import queries
User = get_user_model()
class QueriesTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self):
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
tag.save()
return tag
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
def test_query_archived_bookmarks_should_not_return_unarchived_bookmarks(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_archived_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag1], list(query))
def test_query_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))
def test_query_archived_bookmark_tags_should_return_tags_for_archived_bookmarks_only(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag2], list(query))
def test_query_archived_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))

View File

@@ -9,7 +9,7 @@ from bookmarks.services.tags import get_or_create_tag, get_or_create_tags
User = get_user_model()
class TagTestCase(TestCase):
class TagServiceTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')

View File

@@ -11,11 +11,13 @@ urlpatterns = [
url(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
# Bookmarks
path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
path('bookmarks/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
path('bookmarklet', views.bookmarks.bookmarklet, name='bookmarklet'),
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
# Settings
path('settings', views.settings.index, name='settings.index'),
path('settings/import', views.settings.bookmark_import, name='settings.import'),

View File

@@ -8,47 +8,58 @@ from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.queries import get_user_tags
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark
_default_page_size = 30
@login_required
def index(request):
page = request.GET.get('page')
query_string = request.GET.get('q')
query_set = queries.query_bookmarks(request.user, query_string)
tags = queries.query_bookmark_tags(request.user, query_string)
base_url = reverse('bookmarks:index')
context = get_bookmark_view_context(request, query_set, tags, base_url)
return render(request, 'bookmarks/index.html', context)
@login_required
def archived(request):
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(request.user, query_string)
tags = queries.query_archived_bookmark_tags(request.user, query_string)
base_url = reverse('bookmarks:archived')
context = get_bookmark_view_context(request, query_set, tags, base_url)
return render(request, 'bookmarks/archive.html', context)
def get_bookmark_view_context(request, query_set, tags, base_url):
page = request.GET.get('page')
query_string = request.GET.get('q')
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
tags = queries.query_tags(request.user, query_string)
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
return_url = generate_index_return_url(page, query_string)
return_url = generate_return_url(base_url, page, query_string)
if request.GET.get('tag'):
mod = request.GET.copy()
mod.pop('tag')
request.GET = mod
context = {
return {
'bookmarks': bookmarks,
'tags': tags,
'tags_string': tags_string,
'query': query_string if query_string else '',
'empty': paginator.count == 0,
'return_url': return_url
}
return render(request, 'bookmarks/index.html', context)
def generate_index_return_url(page, query_string):
def generate_return_url(base_url, page, query_string):
url_query = {}
if query_string is not None:
url_query['q'] = query_string
if page is not None:
url_query['page'] = page
base_url = reverse('bookmarks:index')
url_params = urllib.parse.urlencode(url_query)
return_url = base_url if url_params == '' else base_url + '?' + url_params
return urllib.parse.quote_plus(return_url)
@@ -76,7 +87,7 @@ def new(request):
if initial_auto_close:
form.initial['auto_close'] = 'true'
all_tags = get_user_tags(request.user)
all_tags = queries.get_user_tags(request.user)
context = {
'form': form,
'auto_close': initial_auto_close,
@@ -105,7 +116,7 @@ def edit(request, bookmark_id: int):
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
form.initial['return_url'] = return_url
all_tags = get_user_tags(request.user)
all_tags = queries.get_user_tags(request.user)
context = {
'form': form,
@@ -127,10 +138,21 @@ def remove(request, bookmark_id: int):
@login_required
def bookmarklet(request):
return render(request, 'bookmarks/bookmarklet.html', {
'application_url': request.build_absolute_uri("/bookmarks/new")
})
def archive(request, bookmark_id: int):
bookmark = Bookmark.objects.get(pk=bookmark_id)
archive_bookmark(bookmark)
return_url = request.GET.get('return_url')
return_url = return_url if return_url else reverse('bookmarks:index')
return HttpResponseRedirect(return_url)
@login_required
def unarchive(request, bookmark_id: int):
bookmark = Bookmark.objects.get(pk=bookmark_id)
unarchive_bookmark(bookmark)
return_url = request.GET.get('return_url')
return_url = return_url if return_url else reverse('bookmarks:archived')
return HttpResponseRedirect(return_url)
@login_required

View File

@@ -18,10 +18,12 @@ logger = logging.getLogger(__name__)
def index(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/index.html', {
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'application_url': application_url,
'api_token': api_token.key
})