Compare commits

..

11 Commits

Author SHA1 Message Date
Sascha Ißbrücker
6775633be5 Bump version 2024-01-27 10:58:21 +01:00
Jonathan Sundqvist
150dfecc6f Support Open Graph description (#602)
* Support pytest for running tests

* Support extracting description from meta og:description property

* Revert changes to TOC

* Add test

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-01-27 10:28:46 +01:00
Jonathan Sundqvist
81ae55bc1c Add tooltip to truncated bookmark titles (#607)
* Add title to link so you can see the entire title when hover

* Tweak JS, styles

* Fix snapshot tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-01-27 10:16:44 +01:00
Sascha Ißbrücker
935189ecc2 Improve bulk tag performance (#612) 2024-01-27 09:13:21 +01:00
JnsDornbusch
7997f20d89 Adjust archive.org donation link in general.html (#603)
Adjust archive.org donation link due to broken link.
2024-01-23 22:57:50 +01:00
Jonathan Sundqvist
ae27500cde Bump playwright dependencies (#601) 2024-01-23 22:45:25 +01:00
Adam Shand
71d853999e Add CapRover as managed hosting option (#585)
Add a note that I've created a one-click app for Linkding in CapRover (Scalable PaaS, automated Docker+nginx).
2024-01-23 22:37:51 +01:00
Sebastian Ruml
70288d6865 Increase tag limit in tag autocomplete (#581)
- increas tag limit to 5000

Co-authored-by: Sebastian Ruml <sebastian@sebastianruml.name>
2024-01-23 22:32:16 +01:00
Sascha Ißbrücker
e83d519cab Bump version 2023-12-08 22:01:24 +01:00
Sascha Ißbrücker
6355d8dff1 Properly encode search query param (#587) 2023-12-08 21:53:54 +01:00
Sascha Ißbrücker
227cfdb063 Update CHANGELOG.md 2023-11-24 10:23:44 +01:00
21 changed files with 261 additions and 54 deletions

View File

@@ -1,5 +1,20 @@
# Changelog # Changelog
## v1.23.0 (24/11/2023)
### What's Changed
* Add Alpine based Docker image (experimental) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/570
* Add backup CLI command by @sissbruecker in https://github.com/sissbruecker/linkding/pull/571
* Update browser extension links by @OPerepadia in https://github.com/sissbruecker/linkding/pull/574
* Include archived bookmarks in export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/579
### New Contributors
* @OPerepadia made their first contribution in https://github.com/sissbruecker/linkding/pull/574
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.3...v1.23.0
---
## v1.22.3 (04/11/2023) ## v1.22.3 (04/11/2023)
### What's Changed ### What's Changed

View File

@@ -9,11 +9,11 @@
## Overview ## Overview
- [Introduction](#introduction) - [Introduction](#introduction)
- [Installation](#installation) - [Installation](#installation)
- [Using Docker](#using-docker) - [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose) - [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup) - [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup) - [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options) - [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation) - [Documentation](#documentation)
- [Browser Extension](#browser-extension) - [Browser Extension](#browser-extension)
- [Community](#community) - [Community](#community)
@@ -103,7 +103,7 @@ docker-compose up -d
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation. To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
### User setup ### User Setup
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it: For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
@@ -182,6 +182,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel) - [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods)) - [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
## Documentation ## Documentation
@@ -299,3 +300,8 @@ Start the Django development server with:
python3 manage.py runserver python3 manage.py runserver
``` ```
The frontend is now available under http://localhost:8000 The frontend is now available under http://localhost:8000
Run all tests with pytest
```
pytest
```

View File

@@ -59,10 +59,18 @@ class BookmarkItem {
constructor(element) { constructor(element) {
this.element = element; this.element = element;
// Toggle notes
const notesToggle = element.querySelector(".toggle-notes"); const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) { if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this)); notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
} }
// Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span");
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
titleAnchor.dataset.tooltip = titleSpan.textContent;
}
} }
onToggleNotes(event) { onToggleNotes(event) {

View File

@@ -22,7 +22,7 @@
async function init() { async function init() {
// For now we cache all tags on load as the template did before // For now we cache all tags on load as the template did before
try { try {
tags = await apiClient.getTags({limit: 1000, offset: 0}); tags = await apiClient.getTags({limit: 5000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase())) tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) { } catch (e) {
console.warn('TagAutocomplete: Error loading tag list'); console.warn('TagAutocomplete: Error loading tag list');

View File

@@ -67,9 +67,9 @@ def archive_bookmark(bookmark: Bookmark):
def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(is_archived=True, date_modified=timezone.now()) Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(is_archived=True,
date_modified=timezone.now())
def unarchive_bookmark(bookmark: Bookmark): def unarchive_bookmark(bookmark: Bookmark):
@@ -81,70 +81,76 @@ def unarchive_bookmark(bookmark: Bookmark):
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(is_archived=False, date_modified=timezone.now()) Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(is_archived=False,
date_modified=timezone.now())
def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.delete() Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).delete()
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User): def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) owned_bookmark_ids = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).values_list('id',
flat=True)
tag_names = parse_tag_string(tag_string) tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, current_user) tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks: BookmarkToTagRelationShip = Bookmark.tags.through
bookmark.tags.add(*tags) relationships = []
bookmark.date_modified = timezone.now() for tag in tags:
for bookmark_id in owned_bookmark_ids:
relationships.append(BookmarkToTagRelationShip(bookmark_id=bookmark_id, tag=tag))
Bookmark.objects.bulk_update(bookmarks, ['date_modified']) # Insert all bookmark -> tag associations at once, should ignore errors if association already exists
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(date_modified=timezone.now())
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User): def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids) owned_bookmark_ids = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).values_list('id',
flat=True)
tag_names = parse_tag_string(tag_string) tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, current_user) tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks: BookmarkToTagRelationShip = Bookmark.tags.through
bookmark.tags.remove(*tags) for tag in tags:
bookmark.date_modified = timezone.now() # Remove all bookmark -> tag associations for the owned bookmarks and the current tag
BookmarkToTagRelationShip.objects.filter(bookmark_id__in=owned_bookmark_ids, tag=tag).delete()
Bookmark.objects.bulk_update(bookmarks, ['date_modified']) Bookmark.objects.filter(id__in=owned_bookmark_ids).update(date_modified=timezone.now())
def mark_bookmarks_as_read(bookmark_ids: [Union[int, str]], current_user: User): def mark_bookmarks_as_read(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(unread=False, date_modified=timezone.now()) Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(unread=False,
date_modified=timezone.now())
def mark_bookmarks_as_unread(bookmark_ids: [Union[int, str]], current_user: User): def mark_bookmarks_as_unread(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(unread=True, date_modified=timezone.now()) Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(unread=True,
date_modified=timezone.now())
def share_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): def share_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(shared=True, date_modified=timezone.now()) Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(shared=True,
date_modified=timezone.now())
def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User): def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids) sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(shared=False, date_modified=timezone.now()) Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(shared=False,
date_modified=timezone.now())
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):

View File

@@ -41,8 +41,13 @@ def load_website_metadata(url: str):
title = soup.title.string.strip() if soup.title is not None else None title = soup.title.string.strip() if soup.title is not None else None
description_tag = soup.find('meta', attrs={'name': 'description'}) description_tag = soup.find('meta', attrs={'name': 'description'})
description = description = description_tag['content'].strip() if description_tag and description_tag[ description = description_tag['content'].strip() if description_tag and description_tag[
'content'] else None 'content'] else None
if not description:
description_tag = soup.find('meta', attrs={'property': 'og:description'})
description = description_tag['content'].strip() if description_tag and description_tag['content'] else None
end = timezone.now() end = timezone.now()
logger.debug(f'Parsing duration: {end - start}') logger.debug(f'Parsing duration: {end - start}')
finally: finally:

View File

@@ -107,6 +107,18 @@ ul.bookmark-list {
padding: 0; padding: 0;
} }
@keyframes appear {
0% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Bookmarks */ /* Bookmarks */
li[ld-bookmark-item] { li[ld-bookmark-item] {
position: relative; position: relative;
@@ -122,6 +134,27 @@ li[ld-bookmark-item] {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
content: attr(data-tooltip);
position: absolute;
z-index: 10;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
height: fit-content;
background-color: #292f62;
color: #fff;
padding: $unit-1;
border-radius: $border-radius;
border: 1px solid #424a8c;
font-size: $font-size-sm;
font-style: normal;
white-space: normal;
animation: 0.3s ease 0s appear;
}
} }
&.unread .title a { &.unread .title a {

View File

@@ -14,11 +14,11 @@
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
<div class="title"> <div class="title">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"> <a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %} {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<img src="{% static bookmark_item.favicon_file %}" alt=""> <img src="{% static bookmark_item.favicon_file %}" alt="">
{% endif %} {% endif %}
{{ bookmark_item.title }} <span>{{ bookmark_item.title }}</span>
</a> </a>
</div> </div>
{% if bookmark_list.show_url %} {% if bookmark_list.show_url %}

View File

@@ -95,7 +95,7 @@
props: { props: {
name: 'q', name: 'q',
placeholder: 'Search for words or #tags', placeholder: 'Search for words or #tags',
value: '{{ search.q|safe }}', value: input.value,
tags: uniqueTags, tags: uniqueTags,
mode: '{{ mode }}', mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}', linkTarget: '{{ request.user_profile.bookmark_link_target }}',

View File

@@ -99,7 +99,7 @@
Machine</a>. Machine</a>.
This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified. case it goes offline or its content is modified.
Please consider donating to the <a href="https://archive.org/donate/index.php" target="_blank" Please consider donating to the <a href="https://archive.org/donate" target="_blank"
rel="noopener">Internet Archive</a> if you make use of this feature. rel="noopener">Internet Archive</a> if you make use of this feature.
</div> </div>
</div> </div>

View File

@@ -422,3 +422,31 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertEqual(actions_form.attrs['action'], self.assertEqual(actions_form.attrs['action'],
'/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo') '/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo')
def test_encode_search_params(self):
bookmark = self.setup_bookmark(description='alert(\'xss\')', is_archived=True)
url = reverse('bookmarks:archived') + '?q=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
self.assertContains(response, bookmark.url)
url = reverse('bookmarks:archived') + '?sort=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:archived') + '?unread=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:archived') + '?shared=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:archived') + '?user=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:archived') + '?page=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')

View File

@@ -418,3 +418,31 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(actions_form.attrs['action'], self.assertEqual(actions_form.attrs['action'],
'/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo') '/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo')
def test_encode_search_params(self):
bookmark = self.setup_bookmark(description='alert(\'xss\')')
url = reverse('bookmarks:index') + '?q=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
self.assertContains(response, bookmark.url)
url = reverse('bookmarks:index') + '?sort=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:index') + '?unread=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:index') + '?shared=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:index') + '?user=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:index') + '?page=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')

View File

@@ -183,9 +183,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
</div> </div>
''', html) ''', html)
def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
def test_should_hide_notes_if_there_are_no_notes(self): self.assertContains(response, '<details class="notes">', count=1)
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1)

View File

@@ -500,3 +500,35 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(actions_form.attrs['action'], self.assertEqual(actions_form.attrs['action'],
'/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo') '/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo')
def test_encode_search_params(self):
self.authenticate()
user = self.get_or_create_test_user()
user.profile.enable_sharing = True
user.profile.save()
bookmark = self.setup_bookmark(description='alert(\'xss\')', shared=True)
url = reverse('bookmarks:shared') + '?q=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
self.assertContains(response, bookmark.url)
url = reverse('bookmarks:shared') + '?sort=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:shared') + '?unread=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:shared') + '?shared=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:shared') + '?user=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')
url = reverse('bookmarks:shared') + '?page=alert(%27xss%27)'
response = self.client.get(url)
self.assertNotContains(response, 'alert(\'xss\')')

View File

@@ -24,7 +24,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
target="{link_target}" target="{link_target}"
rel="noopener"> rel="noopener">
{favicon_img} {favicon_img}
{bookmark.resolved_title} <span>{bookmark.resolved_title}</span>
</a> </a>
''', ''',
html html

View File

@@ -336,6 +336,28 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2]) self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_tag_bookmarks_should_handle_existing_relationships(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1])
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
BookmarkToTagRelationShip = Bookmark.tags.through
self.assertEqual(3, BookmarkToTagRelationShip.objects.count())
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
self.assertEqual(6, BookmarkToTagRelationShip.objects.count())
def test_tag_bookmarks_should_only_tag_specified_bookmarks(self): def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):
bookmark1 = self.setup_bookmark() bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark() bookmark2 = self.setup_bookmark()

View File

@@ -29,14 +29,17 @@ class WebsiteLoaderTestCase(TestCase):
# clear cached metadata before test run # clear cached metadata before test run
website_loader.load_website_metadata.cache_clear() website_loader.load_website_metadata.cache_clear()
def render_html_document(self, title, description): def render_html_document(self, title, description='', og_description=''):
meta_description = f'<meta name="description" content="{description}">' if description else ''
meta_og_description = f'<meta property="og:description" content="{og_description}">' if og_description else ''
return f''' return f'''
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{title}</title> <title>{title}</title>
<meta name="description" content="{description}"> {meta_description}
{meta_og_description}
</head> </head>
<body></body> <body></body>
</html> </html>
@@ -94,3 +97,19 @@ class WebsiteLoaderTestCase(TestCase):
metadata = website_loader.load_website_metadata('https://example.com') metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title) self.assertEqual('test title', metadata.title)
self.assertEqual('test description', metadata.description) self.assertEqual('test description', metadata.description)
def test_load_website_metadata_using_og_description(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
mock_load_page.return_value = self.render_html_document('test title', '',
og_description='test og description')
metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title)
self.assertEqual('test og description', metadata.description)
def test_load_website_metadata_prefers_description_over_og_description(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
mock_load_page.return_value = self.render_html_document('test title', 'test description',
og_description='test og description')
metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title)
self.assertEqual('test description', metadata.description)

View File

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

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = siteroot.settings.dev
# -- recommended but optional:
python_files = tests.py test_*.py *_tests.py

View File

@@ -1,13 +1,12 @@
asgiref==3.5.2 asgiref==3.5.2
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
bleach==6.0.0
bleach-allowlist==1.0.3 bleach-allowlist==1.0.3
bleach==6.0.0
certifi==2023.7.22 certifi==2023.7.22
charset-normalizer==2.1.1 charset-normalizer==2.1.1
click==8.1.3 click==8.1.3
confusable-homoglyphs==3.2.0 confusable-homoglyphs==3.2.0
coverage==5.5 coverage==5.5
Django==4.1.13
django-appconf==1.0.5 django-appconf==1.0.5
django-compressor==4.1 django-compressor==4.1
django-debug-toolbar==3.6.0 django-debug-toolbar==3.6.0
@@ -15,15 +14,18 @@ django-generate-secret-key==1.0.2
django-registration==3.3 django-registration==3.3
django-sass-processor==1.2.1 django-sass-processor==1.2.1
django-widget-tweaks==1.4.12 django-widget-tweaks==1.4.12
Django==4.1.13
django4-background-tasks==1.2.7 django4-background-tasks==1.2.7
djangorestframework==3.13.1 djangorestframework==3.13.1
greenlet==2.0.1 greenlet==3.0.1
idna==3.3 idna==3.3
libsass==0.21.0 libsass==0.21.0
Markdown==3.4.3 Markdown==3.4.3
playwright==1.29.1 playwright==1.40.0
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
pyee==9.0.4 pyee==11.0.1
pytest-django==4.7.0
pytest==7.4.4
python-dateutil==2.8.2 python-dateutil==2.8.2
pytz==2022.2.1 pytz==2022.2.1
rcssmin==1.1.0 rcssmin==1.1.0

View File

@@ -1 +1 @@
1.23.0 1.24.0