Compare commits

...

7 Commits

Author SHA1 Message Date
Sascha Ißbrücker
aac8bf39b8 Bump version 2022-05-26 04:19:22 +02:00
Aaron Bach
49f648a908 Add community reference to linkding-cli (#270) 2022-05-26 04:16:06 +02:00
Sascha Ißbrücker
68c3c27b38 Add PATCH support to bookmarks endpoint (#269)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-26 04:15:13 +02:00
kenc
792a19d15e Allow creating archived bookmark through REST API (#268)
* Add POST archived API endpoint

* Update API docs

* Expose is_archived in existing POST endpoint

* Add test to verify bookmark not archived by default

* Fix JSON payload in API docs

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-05-26 04:10:36 +02:00
Sascha Ißbrücker
2de6d8151b Improve about section (#265)
* Improve about section

* Add changelog link

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 21:33:08 +02:00
Sascha Ißbrücker
9e9d7ae7d2 Add background tasks to admin (#264)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 18:21:10 +02:00
Sascha Ißbrücker
4e8a183082 Update CHANGELOG.md 2022-05-21 13:42:33 +02:00
13 changed files with 211 additions and 24 deletions

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## v1.10.1 (21/05/2022)
### What's Changed
* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1
---
## v1.10.0 (21/05/2022) ## v1.10.0 (21/05/2022)
### What's Changed ### What's Changed
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253 * Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
@@ -33,6 +42,7 @@
* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229 * @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0 **Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
--- ---
## v1.8.8 (27/03/2022) ## v1.8.8 (27/03/2022)
@@ -187,8 +197,3 @@
## v1.2.0 (09/01/2021) ## v1.2.0 (09/01/2021)
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58) - [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45) - [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
---
## v1.1.1 (01/01/2021)
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)

View File

@@ -129,6 +129,7 @@ This section lists community projects around using linkding, in alphabetical ord
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon) - [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold) - [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya) - [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
## Development ## Development

View File

@@ -1,3 +1,5 @@
from background_task.admin import TaskAdmin, CompletedTaskAdmin
from background_task.models import Task, CompletedTask
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
@@ -107,3 +109,5 @@ linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(Toast, AdminToast)
linkding_admin_site.register(Task, TaskAdmin)
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)

View File

@@ -19,6 +19,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'description', 'description',
'website_title', 'website_title',
'website_description', 'website_description',
'is_archived',
'tag_names', 'tag_names',
'date_added', 'date_added',
'date_modified' 'date_modified'
@@ -33,6 +34,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Override optional char fields to provide default value # Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='') title = serializers.CharField(required=False, allow_blank=True, default='')
description = 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)
# Override readonly tag_names property to allow passing a list of tag names to create/update # Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[]) tag_names = TagListField(required=False, default=[])
@@ -41,14 +43,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url'] bookmark.url = validated_data['url']
bookmark.title = validated_data['title'] bookmark.title = validated_data['title']
bookmark.description = validated_data['description'] bookmark.description = validated_data['description']
bookmark.is_archived = validated_data['is_archived']
tag_string = build_tag_string(validated_data['tag_names']) tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user']) return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data): def update(self, instance: Bookmark, validated_data):
instance.url = validated_data['url'] # Update fields if they were provided in the payload
instance.title = validated_data['title'] for key in ['url', 'title', 'description']:
instance.description = validated_data['description'] if key in validated_data:
tag_string = build_tag_string(validated_data['tag_names']) setattr(instance, key, validated_data[key])
# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if 'tag_names' in validated_data:
tag_string = build_tag_string(validated_data['tag_names'])
return update_bookmark(instance, tag_string, self.context['user']) return update_bookmark(instance, tag_string, self.context['user'])

View File

@@ -11,4 +11,8 @@
.input-group > input[type=submit] { .input-group > input[type=submit] {
height: auto; height: auto;
} }
section.about table {
max-width: 500px;
}
} }

View File

@@ -98,13 +98,29 @@
</section> </section>
{# About section #} {# About section #}
<section class="content-area"> <section class="content-area about">
<h2>About</h2> <h2>About</h2>
<p>Version: {{ app_version }}</p> <table class="table">
<p> <tbody>
Code: <a href="https://github.com/sissbruecker/linkding/" <tr>
target="_blank">GitHub</a> <td>Version</td>
</p> <td>{{ version_info }}</td>
</tr>
<tr>
<td rowspan="3" style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding#documentation"
target="_blank">Documentation</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a></td>
</tr>
</tbody>
</table>
</section> </section>
</div> </div>

View File

@@ -83,6 +83,11 @@ class LinkdingApiTestCase(APITestCase):
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)
return response return response
def patch(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def delete(self, url, expected_status_code=status.HTTP_200_OK): def delete(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.delete(url) response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)

View File

@@ -34,6 +34,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['description'] = bookmark.description expectation['description'] = bookmark.description
expectation['website_title'] = bookmark.website_title expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
expectation['tag_names'] = tag_names expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z') expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z') expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
@@ -49,6 +50,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': 'https://example.com/', 'url': 'https://example.com/',
'title': 'Test title', 'title': 'Test title',
'description': 'Test description', 'description': 'Test description',
'is_archived': False,
'tag_names': ['tag1', 'tag2'] 'tag_names': ['tag1', 'tag2']
} }
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
@@ -56,6 +58,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, data['url']) self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title']) self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description']) self.assertEqual(bookmark.description, data['description'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertEqual(bookmark.tags.count(), 2) 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'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1) self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
@@ -92,6 +95,30 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1]) self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_create_archived_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'is_archived': True,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertTrue(bookmark.is_archived)
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)
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)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived)
def test_get_bookmark(self): def test_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
@@ -104,6 +131,56 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url']) self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self):
data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '')
self.assertEqual(updated_bookmark.tag_names, [])
def test_patch_bookmark(self):
data = {'url': 'https://example.com'}
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.assertEqual(self.bookmark1.url, data['url'])
data = {'title': 'Updated title'}
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.assertEqual(self.bookmark1.title, data['title'])
data = {'description': 'Updated description'}
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.assertEqual(self.bookmark1.description, data['description'])
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)
self.bookmark1.refresh_from_db()
tag_names = [tag.name for tag in self.bookmark1.tags.all()]
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, self.bookmark1.url)
self.assertEqual(updated_bookmark.title, self.bookmark1.title)
self.assertEqual(updated_bookmark.description, self.bookmark1.description)
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
def test_delete_bookmark(self): def test_delete_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -1,8 +1,14 @@
import random
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from unittest.mock import patch, Mock
import requests
from requests import RequestException
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -38,3 +44,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display']) self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target']) self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration']) self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML(f'''
<tr>
<td>Version</td>
<td>{get_version_info(random.random())}</td>
</tr>
''', html)
def test_get_version_info_just_displays_latest_when_versions_are_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v{app_version}'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest)')
def test_get_version_info_shows_latest_version_when_versions_are_not_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v123.0.1'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest: 123.0.1)')
def test_get_version_info_silently_ignores_request_errors(self):
with patch.object(requests, 'get', side_effect=RequestException()):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version}')

View File

@@ -1,5 +1,8 @@
import logging import logging
import time
from functools import lru_cache
import requests
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
@@ -17,10 +20,11 @@ logger = logging.getLogger(__name__)
try: try:
with open("version.txt", "r") as f: with open("version.txt", "r") as f:
app_version = f.read().strip("\n") app_version = f.read().strip("\n")
except Exception as exc: except Exception as exc:
logging.exception(exc) logging.exception(exc)
pass pass
@login_required @login_required
def general(request): def general(request):
if request.method == 'POST': if request.method == 'POST':
@@ -32,14 +36,41 @@ def general(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success') 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') import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash())
return render(request, 'settings/general.html', { return render(request, 'settings/general.html', {
'form': form, 'form': form,
'import_success_message': import_success_message, 'import_success_message': import_success_message,
'import_errors_message': import_errors_message, 'import_errors_message': import_errors_message,
'app_version': app_version 'version_info': version_info,
}) })
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):
latest_version = None
try:
latest_version_url = 'https://api.github.com/repos/sissbruecker/linkding/releases/latest'
response = requests.get(latest_version_url, timeout=5)
json = response.json()
latest_version = json['name'][1:]
except requests.exceptions.RequestException:
pass
latest_version_info = ''
if latest_version == app_version:
latest_version_info = ' (latest)'
elif latest_version is not None:
latest_version_info = f' (latest: {latest_version})'
return f'{app_version}{latest_version_info}'
def get_ttl_hash(seconds=3600):
"""Return the same value within `seconds` time period"""
return round(time.time() / seconds)
@login_required @login_required
def integrations(request): def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new") application_url = request.build_absolute_uri("/bookmarks/new")

View File

@@ -24,7 +24,7 @@ The following resources are available:
GET /api/bookmarks/ GET /api/bookmarks/
``` ```
List bookmarks. List bookmarks.
Parameters: Parameters:
@@ -65,7 +65,7 @@ Example response:
GET /api/bookmarks/archived/ GET /api/bookmarks/archived/
``` ```
List archived bookmarks. List archived bookmarks.
Parameters and response are the same as for the regular list endpoint. Parameters and response are the same as for the regular list endpoint.
@@ -83,7 +83,8 @@ Retrieves a single bookmark by ID.
POST /api/bookmarks/ POST /api/bookmarks/
``` ```
Creates a new bookmark. Tags are simply assigned using their names. Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive.
Example payload: Example payload:
@@ -92,6 +93,7 @@ Example payload:
"url": "https://example.com", "url": "https://example.com",
"title": "Example title", "title": "Example title",
"description": "Example description", "description": "Example description",
"is_archived": false,
"tag_names": [ "tag_names": [
"tag1", "tag1",
"tag2" "tag2"
@@ -201,4 +203,3 @@ Example payload:
"name": "example" "name": "example"
} }
``` ```

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.10.1", "version": "1.11.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -1 +1 @@
1.10.1 1.11.0