mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-11 11:39:44 +02:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
013ea16578 | ||
![]() |
cf1085c781 | ||
![]() |
5d1dc38d1d | ||
![]() |
de6e91fd75 | ||
![]() |
506d3cad25 | ||
![]() |
fdfafbbb0b | ||
![]() |
54ce6d5fe6 | ||
![]() |
13ff9ac4f8 | ||
![]() |
48e4958218 | ||
![]() |
b618a8b10b | ||
![]() |
90a46c1fb9 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v1.11.1 (03/07/2022)
|
||||
|
||||
### What's Changed
|
||||
* Fix duplicate tags on import by @wahlm in https://github.com/sissbruecker/linkding/pull/289
|
||||
* Add apple-touch-icon by @daveonkels in https://github.com/sissbruecker/linkding/pull/282
|
||||
* Bump waybackpy to 3.0.6 by @dustinblackman in https://github.com/sissbruecker/linkding/pull/281
|
||||
|
||||
### New Contributors
|
||||
* @wahlm made their first contribution in https://github.com/sissbruecker/linkding/pull/289
|
||||
* @daveonkels made their first contribution in https://github.com/sissbruecker/linkding/pull/282
|
||||
* @dustinblackman made their first contribution in https://github.com/sissbruecker/linkding/pull/281
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.0...v1.11.1
|
||||
|
||||
---
|
||||
|
||||
## v1.11.0 (26/05/2022)
|
||||
|
||||
### What's Changed
|
||||
|
@@ -130,6 +130,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [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)
|
||||
- [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)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
|
||||
## Development
|
||||
|
||||
|
@@ -9,7 +9,7 @@ from django.utils.translation import ngettext, gettext
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import TokenProxy
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile, Toast
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -103,11 +121,18 @@ class AdminToast(admin.ModelAdmin):
|
||||
list_filter = ('owner__username',)
|
||||
|
||||
|
||||
class AdminFeedToken(admin.ModelAdmin):
|
||||
list_display = ('key', 'user')
|
||||
search_fields = ['key']
|
||||
list_filter = ('user__username',)
|
||||
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||
linkding_admin_site.register(Task, TaskAdmin)
|
||||
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)
|
||||
|
@@ -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])
|
||||
|
||||
|
56
bookmarks/feeds.py
Normal file
56
bookmarks/feeds.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, FeedToken
|
||||
from bookmarks import queries
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedContext:
|
||||
feed_token: FeedToken
|
||||
query_set: QuerySet[Bookmark]
|
||||
|
||||
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_bookmarks(feed_token.user, query_string)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
return item.resolved_title
|
||||
|
||||
def item_description(self, item: Bookmark):
|
||||
return item.resolved_description
|
||||
|
||||
def item_link(self, item: Bookmark):
|
||||
return item.url
|
||||
|
||||
def item_pubdate(self, item: Bookmark):
|
||||
return item.date_added
|
||||
|
||||
|
||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
title = 'All bookmarks'
|
||||
description = 'All bookmarks'
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse('bookmarks:feeds.all', args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
title = 'Unread bookmarks'
|
||||
description = 'All unread bookmarks'
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse('bookmarks:feeds.unread', args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set.filter(unread=True)
|
27
bookmarks/migrations/0014_alter_bookmark_unread.py
Normal file
27
bookmarks/migrations/0014_alter_bookmark_unread.py
Normal 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),
|
||||
]
|
24
bookmarks/migrations/0015_feedtoken.py
Normal file
24
bookmarks/migrations/0015_feedtoken.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2.13 on 2022-07-23 20:35
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0014_alter_bookmark_unread'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FeedToken',
|
||||
fields=[
|
||||
('key', models.CharField(max_length=40, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_token', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
@@ -1,3 +1,5 @@
|
||||
import binascii
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from django import forms
|
||||
@@ -50,7 +52,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 +99,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):
|
||||
@@ -166,3 +169,27 @@ class Toast(models.Model):
|
||||
message = models.TextField()
|
||||
acknowledged = models.BooleanField(default=False)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class FeedToken(models.Model):
|
||||
"""
|
||||
Adapted from authtoken.models.Token
|
||||
"""
|
||||
key = models.CharField(max_length=40, primary_key=True)
|
||||
user = models.OneToOneField(get_user_model(),
|
||||
related_name='feed_token',
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.key:
|
||||
self.key = self.generate_key()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def generate_key(cls):
|
||||
return binascii.hexlify(os.urandom(20)).decode()
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
|
@@ -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,
|
||||
}
|
||||
|
@@ -173,6 +173,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_ca
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||
logging.warning(
|
||||
f'Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL.')
|
||||
continue
|
||||
|
||||
# Get tag models by string, schedule inserts for bookmark -> tag associations
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
@@ -191,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:
|
||||
|
@@ -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]:
|
||||
|
@@ -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>
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -38,9 +38,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
|
||||
token can access and manage all your bookmarks.</p>
|
||||
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
|
||||
<p>
|
||||
<strong>Please treat this token as you would any other credential.</strong>
|
||||
Any party with access to this token can access and manage all your bookmarks.
|
||||
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
||||
After deleting the token, a new one will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="content-area">
|
||||
<h2>RSS Feeds</h2>
|
||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||
<ul>
|
||||
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
||||
</ul>
|
||||
<p>
|
||||
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
||||
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
|
||||
Any party with access to these URLs can read all your bookmarks.
|
||||
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
|
||||
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import random
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
@@ -23,6 +22,7 @@ class BookmarkFactoryMixin:
|
||||
|
||||
def setup_bookmark(self,
|
||||
is_archived: bool = False,
|
||||
unread: bool = False,
|
||||
tags=None,
|
||||
user: User = None,
|
||||
url: str = '',
|
||||
@@ -32,6 +32,8 @@ class BookmarkFactoryMixin:
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
):
|
||||
if not title:
|
||||
title = get_random_string(length=32)
|
||||
if tags is None:
|
||||
tags = []
|
||||
if user is None:
|
||||
@@ -49,6 +51,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 +98,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 +119,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 ''}
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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>')
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
191
bookmarks/tests/test_feeds.py
Normal file
191
bookmarks/tests/test_feeds.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import datetime
|
||||
import email
|
||||
import urllib.parse
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.models import FeedToken, User
|
||||
|
||||
|
||||
def rfc2822_date(date):
|
||||
if not isinstance(date, datetime.datetime):
|
||||
date = datetime.datetime.combine(date, datetime.time())
|
||||
return email.utils.format_datetime(date)
|
||||
|
||||
|
||||
class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
self.token = FeedToken.objects.get_or_create(user=user)[0]
|
||||
|
||||
def test_all_returns_404_for_unknown_feed_token(self):
|
||||
response = self.client.get(reverse('bookmarks:feeds.all', args=['foo']))
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_all_metadata(self):
|
||||
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
|
||||
response = self.client.get(feed_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, '<title>All bookmarks</title>')
|
||||
self.assertContains(response, '<description>All bookmarks</description>')
|
||||
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
|
||||
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
|
||||
|
||||
def test_all_returns_all_unarchived_bookmarks(self):
|
||||
bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(unread=True),
|
||||
]
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, '<item>', count=len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
expected_item = '<item>' \
|
||||
f'<title>{bookmark.resolved_title}</title>' \
|
||||
f'<link>{bookmark.url}</link>' \
|
||||
f'<description>{bookmark.resolved_description}</description>' \
|
||||
f'<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>' \
|
||||
f'<guid>{bookmark.url}</guid>' \
|
||||
'</item>'
|
||||
self.assertContains(response, expected_item, count=1)
|
||||
|
||||
def test_all_with_query(self):
|
||||
tag1 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1])
|
||||
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
|
||||
|
||||
url = feed_url + f'?q={bookmark1.title}'
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=1)
|
||||
self.assertContains(response, f'<guid>{bookmark1.url}</guid>', count=1)
|
||||
|
||||
url = feed_url + '?q=' + urllib.parse.quote('#' + tag1.name)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=2)
|
||||
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
|
||||
self.assertContains(response, f'<guid>{bookmark3.url}</guid>', count=1)
|
||||
|
||||
url = feed_url + '?q=' + urllib.parse.quote(f'#{tag1.name} {bookmark2.title}')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=1)
|
||||
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
|
||||
|
||||
def test_all_returns_only_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
self.setup_bookmark(unread=True, user=other_user)
|
||||
self.setup_bookmark(unread=True, user=other_user)
|
||||
self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, '<item>', count=0)
|
||||
|
||||
def test_unread_returns_404_for_unknown_feed_token(self):
|
||||
response = self.client.get(reverse('bookmarks:feeds.unread', args=['foo']))
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_unread_metadata(self):
|
||||
feed_url = reverse('bookmarks:feeds.unread', args=[self.token.key])
|
||||
response = self.client.get(feed_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, '<title>Unread bookmarks</title>')
|
||||
self.assertContains(response, '<description>All unread bookmarks</description>')
|
||||
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
|
||||
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
|
||||
|
||||
def test_unread_returns_unread_and_unarchived_bookmarks(self):
|
||||
self.setup_bookmark(unread=False)
|
||||
self.setup_bookmark(unread=False)
|
||||
self.setup_bookmark(unread=False)
|
||||
self.setup_bookmark(unread=True, is_archived=True)
|
||||
self.setup_bookmark(unread=True, is_archived=True)
|
||||
self.setup_bookmark(unread=False, is_archived=True)
|
||||
|
||||
unread_bookmarks = [
|
||||
self.setup_bookmark(unread=True),
|
||||
self.setup_bookmark(unread=True),
|
||||
self.setup_bookmark(unread=True),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, '<item>', count=len(unread_bookmarks))
|
||||
|
||||
for bookmark in unread_bookmarks:
|
||||
expected_item = '<item>' \
|
||||
f'<title>{bookmark.resolved_title}</title>' \
|
||||
f'<link>{bookmark.url}</link>' \
|
||||
f'<description>{bookmark.resolved_description}</description>' \
|
||||
f'<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>' \
|
||||
f'<guid>{bookmark.url}</guid>' \
|
||||
'</item>'
|
||||
self.assertContains(response, expected_item, count=1)
|
||||
|
||||
def test_unread_with_query(self):
|
||||
tag1 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True, tags=[tag1])
|
||||
bookmark3 = self.setup_bookmark(unread=True, tags=[tag1])
|
||||
|
||||
self.setup_bookmark(unread=True)
|
||||
self.setup_bookmark(unread=True)
|
||||
self.setup_bookmark(unread=True)
|
||||
|
||||
feed_url = reverse('bookmarks:feeds.all', args=[self.token.key])
|
||||
|
||||
url = feed_url + f'?q={bookmark1.title}'
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=1)
|
||||
self.assertContains(response, f'<guid>{bookmark1.url}</guid>', count=1)
|
||||
|
||||
url = feed_url + '?q=' + urllib.parse.quote('#' + tag1.name)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=2)
|
||||
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
|
||||
self.assertContains(response, f'<guid>{bookmark3.url}</guid>', count=1)
|
||||
|
||||
url = feed_url + '?q=' + urllib.parse.quote(f'#{tag1.name} {bookmark2.title}')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<item>', count=1)
|
||||
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
|
||||
|
||||
def test_unread_returns_only_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
self.setup_bookmark(unread=True, user=other_user)
|
||||
self.setup_bookmark(unread=True, user=other_user)
|
||||
self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, '<item>', count=0)
|
@@ -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'),
|
||||
@@ -111,6 +118,19 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
self.assertEqual(len(bookmarks), 1)
|
||||
self.assertBookmarksImported(html_tags[1:1])
|
||||
|
||||
def test_import_invalid_bookmark_does_not_associate_tags(self):
|
||||
html_tags = [
|
||||
# No URL
|
||||
BookmarkHtmlTag(tags='tag1, tag2, tag3'),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
# Sqlite silently ignores relationships that have a non-persisted bookmark,
|
||||
# thus testing if the bulk create receives no relationships
|
||||
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||
with patch.object(BookmarkToTagRelationShip.objects, 'bulk_create') as mock_bulk_create:
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
mock_bulk_create.assert_called_once_with([], ignore_conflicts=True)
|
||||
|
||||
def test_import_tags(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -3,6 +3,7 @@ from django.urls import reverse
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.models import FeedToken
|
||||
|
||||
|
||||
class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -38,3 +39,28 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.client.get(reverse('bookmarks:settings.integrations'))
|
||||
|
||||
self.assertEqual(Token.objects.count(), 1)
|
||||
|
||||
def test_should_generate_feed_token_if_not_exists(self):
|
||||
self.assertEqual(FeedToken.objects.count(), 0)
|
||||
|
||||
self.client.get(reverse('bookmarks:settings.integrations'))
|
||||
|
||||
self.assertEqual(FeedToken.objects.count(), 1)
|
||||
token = FeedToken.objects.first()
|
||||
self.assertEqual(token.user, self.user)
|
||||
|
||||
def test_should_not_generate_feed_token_if_exists(self):
|
||||
FeedToken.objects.get_or_create(user=self.user)
|
||||
self.assertEqual(FeedToken.objects.count(), 1)
|
||||
|
||||
self.client.get(reverse('bookmarks:settings.integrations'))
|
||||
|
||||
self.assertEqual(FeedToken.objects.count(), 1)
|
||||
|
||||
def test_should_display_feed_urls(self):
|
||||
response = self.client.get(reverse('bookmarks:settings.integrations'))
|
||||
html = response.content.decode()
|
||||
|
||||
token = FeedToken.objects.first()
|
||||
self.assertInHTML(f'<a href="http://testserver/feeds/{token.key}/all">All bookmarks</a>', html)
|
||||
self.assertInHTML(f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>', html)
|
||||
|
@@ -4,6 +4,7 @@ from django.views.generic import RedirectView
|
||||
|
||||
from bookmarks.api.routes import router
|
||||
from bookmarks import views
|
||||
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
|
||||
|
||||
app_name = 'bookmarks'
|
||||
urlpatterns = [
|
||||
@@ -25,5 +26,8 @@ urlpatterns = [
|
||||
# Toasts
|
||||
path('toasts/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'),
|
||||
# API
|
||||
path('api/', include(router.urls), name='api')
|
||||
path('api/', include(router.urls), name='api'),
|
||||
# Feeds
|
||||
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
|
||||
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
|
||||
]
|
||||
|
@@ -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)
|
||||
|
@@ -10,7 +10,7 @@ from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import UserProfileForm
|
||||
from bookmarks.models import UserProfileForm, FeedToken
|
||||
from bookmarks.queries import query_bookmarks
|
||||
from bookmarks.services import exporter
|
||||
from bookmarks.services import importer
|
||||
@@ -75,9 +75,14 @@ def get_ttl_hash(seconds=3600):
|
||||
def integrations(request):
|
||||
application_url = request.build_absolute_uri("/bookmarks/new")
|
||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||
feed_token = FeedToken.objects.get_or_create(user=request.user)[0]
|
||||
all_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.all', args=[feed_token.key]))
|
||||
unread_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.unread', args=[feed_token.key]))
|
||||
return render(request, 'settings/integrations.html', {
|
||||
'application_url': application_url,
|
||||
'api_token': api_token.key
|
||||
'api_token': api_token.key,
|
||||
'all_feed_url': all_feed_url,
|
||||
'unread_feed_url': unread_feed_url,
|
||||
})
|
||||
|
||||
|
||||
|
31
docs/API.md
31
docs/API.md
@@ -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:
|
||||
|
||||
|
@@ -13,7 +13,7 @@ As described in the installation docs, you should mount the `/etc/linkding/data`
|
||||
Below, we describe several methods to create a backup of the database:
|
||||
|
||||
- Manual backup using the export function from the UI
|
||||
- Create a copy of the SQLite databse with the SQLite backup function
|
||||
- Create a copy of the SQLite database with the SQLite backup function
|
||||
- Create a plain textfile with the contents of the SQLite database with the SQLite dump function
|
||||
|
||||
Choose the method that fits you best.
|
||||
|
@@ -13,7 +13,7 @@ Chrome on Android does not permit running bookmarklets in the same way you can o
|
||||
Create a bookmark of your linkding deployment by clicking the star icon which you find in the three dots menu in the top right. Next you have to edit the bookmark. Edit the URL and replace it it with the bookmarklet code of your instance and give it an easy to type name like `bm` for bookmark or `ld` for linkding:
|
||||
|
||||
```
|
||||
javascript: (function() { var bookmarkUrl = window.location; var applicationUrl = 'http://<YOUR_INSTANCE_HERE>/bookmarks/new'; applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '&auto_close'; window.open(applicationUrl);})();
|
||||
javascript:window.open(`http://<YOUR_INSTANCE_HERE>/bookmarks/new?url=${encodeURIComponent(window.location)}&auto_close`)
|
||||
```
|
||||
|
||||
Now when you are browsing the web and you want to save the current page as a bookmark to your linkding instance simply type `bm` into the address bar and select it from the results. The bookmarklet code will trigger and you will be redirected so save the current page.
|
||||
|
208
package-lock.json
generated
208
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.8.6",
|
||||
"version": "1.11.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkding",
|
||||
"version": "1.8.6",
|
||||
"version": "1.11.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^21.0.2",
|
||||
@@ -15,7 +15,7 @@
|
||||
"rollup-plugin-svelte": "^7.1.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"spectre.css": "^0.5.8",
|
||||
"svelte": "^3.46.4"
|
||||
"svelte": "^3.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -41,6 +41,58 @@
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
|
||||
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.0.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
|
||||
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
|
||||
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
|
||||
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
|
||||
"integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "21.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
|
||||
@@ -119,6 +171,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
|
||||
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
@@ -145,9 +208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "3.2.0",
|
||||
@@ -524,23 +587,6 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
||||
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
|
||||
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
@@ -548,6 +594,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sourcemap-codec": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
|
||||
@@ -570,21 +625,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "3.46.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
|
||||
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg==",
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.49.0.tgz",
|
||||
"integrity": "sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA==",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
|
||||
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
|
||||
"version": "5.14.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
|
||||
"integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.2",
|
||||
"acorn": "^8.5.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map": "~0.7.2",
|
||||
"source-map-support": "~0.5.19"
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
@@ -623,6 +679,49 @@
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
|
||||
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
|
||||
"requires": {
|
||||
"@jridgewell/set-array": "^1.0.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.9"
|
||||
}
|
||||
},
|
||||
"@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="
|
||||
},
|
||||
"@jridgewell/set-array": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
|
||||
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw=="
|
||||
},
|
||||
"@jridgewell/source-map": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
|
||||
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
|
||||
"requires": {
|
||||
"@jridgewell/gen-mapping": "^0.3.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.9"
|
||||
}
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
"version": "1.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
|
||||
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
|
||||
},
|
||||
"@jridgewell/trace-mapping": {
|
||||
"version": "0.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
|
||||
"integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
|
||||
"requires": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"@rollup/plugin-commonjs": {
|
||||
"version": "21.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
|
||||
@@ -685,6 +784,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
|
||||
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
@@ -708,9 +812,9 @@
|
||||
}
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"builtin-modules": {
|
||||
"version": "3.2.0",
|
||||
@@ -1000,24 +1104,17 @@
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
||||
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
|
||||
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"sourcemap-codec": {
|
||||
@@ -1039,18 +1136,19 @@
|
||||
}
|
||||
},
|
||||
"svelte": {
|
||||
"version": "3.46.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
|
||||
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg=="
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.49.0.tgz",
|
||||
"integrity": "sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA=="
|
||||
},
|
||||
"terser": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
|
||||
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
|
||||
"version": "5.14.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
|
||||
"integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
|
||||
"requires": {
|
||||
"@jridgewell/source-map": "^0.3.2",
|
||||
"acorn": "^8.5.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map": "~0.7.2",
|
||||
"source-map-support": "~0.5.19"
|
||||
"source-map-support": "~0.5.20"
|
||||
}
|
||||
},
|
||||
"wrappy": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.11.1",
|
||||
"version": "1.12.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -25,6 +25,6 @@
|
||||
"rollup-plugin-svelte": "^7.1.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"spectre.css": "^0.5.8",
|
||||
"svelte": "^3.46.4"
|
||||
"svelte": "^3.49.0"
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ beautifulsoup4==4.7.1
|
||||
certifi==2019.6.16
|
||||
charset-normalizer==2.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==3.2.13
|
||||
Django==3.2.14
|
||||
django-background-tasks==1.2.5
|
||||
django-compat==1.0.15
|
||||
django-generate-secret-key==1.0.2
|
||||
|
@@ -4,7 +4,7 @@ certifi==2019.6.16
|
||||
charset-normalizer==2.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
coverage==5.5
|
||||
Django==3.2.13
|
||||
Django==3.2.14
|
||||
django-appconf==1.0.4
|
||||
django-background-tasks==1.2.5
|
||||
django-compat==1.0.15
|
||||
|
@@ -1 +1 @@
|
||||
1.11.1
|
||||
1.12.0
|
||||
|
Reference in New Issue
Block a user