Compare commits

...

22 Commits

Author SHA1 Message Date
Sascha Ißbrücker
14e4950fec Bump version 2022-05-14 10:13:58 +02:00
Sascha Ißbrücker
f92c3dd403 Make Internet Archive integration opt-in (#250)
* Make web archive integration opt-in

* Add toast message about web archive integration opt-in

* Improve wording for web archive setting

* Add toast admin

* Fix toast clear button visited styles

* Add test for redirect

* Improve wording

* Ensure redirects to same domain

* Improve wording

* Fix snapshot test

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 09:46:51 +02:00
clach04
56173aea3f Issue #187 clarify archive.org feature (#229)
* Issue #187 clarify archive.org feature

Use the name in the readme that archive.org use.

* Simplify

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-05-14 08:49:21 +02:00
Rithas K
c97d5c3dc5 Feature: Shortcut key for new bookmark (#241)
* Add shortcut key for new bookmark

* Use location.assign to keep history

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 08:44:03 +02:00
Pascal Iske
6dd07edf90 docs(readme): add community helm chart link to readme (#242) 2022-05-14 08:34:47 +02:00
dependabot[bot]
1274d6dd4f Bump django from 3.2.12 to 3.2.13 (#244)
Bumps [django](https://github.com/django/django) from 3.2.12 to 3.2.13.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.12...3.2.13)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-14 08:31:41 +02:00
Sascha Ißbrücker
6cf35ecca6 Add whitespace after auto-completed tag (#249)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 01:18:41 +02:00
Sascha Ißbrücker
c5c527400c Remove IntelliJ project files 2022-05-14 01:13:17 +02:00
Sascha Ißbrücker
dc0a4e33bd Scroll menu items into view when using keyboard (#248)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-14 01:02:48 +02:00
Sascha Ißbrücker
ca5dcb882c Update CHANGELOG.md 2022-03-27 12:03:24 +02:00
Sascha Ißbrücker
04649e0901 Update CHANGELOG.md 2022-03-27 11:59:36 +02:00
Sascha Ißbrücker
a85f1cfe83 Bump version 2022-03-27 11:54:28 +02:00
Sascha Ißbrücker
3906d9e5b8 Prevent external redirects 2022-03-27 11:47:45 +02:00
Sascha Ißbrücker
eca98a13f5 Prevent bookmark actions through get requests 2022-03-27 10:56:09 +02:00
Sascha Ißbrücker
10e5861f01 Update CHANGELOG.md 2022-03-26 11:31:46 +01:00
Sascha Ißbrücker
f68c67e272 Update CHANGELOG.md 2022-03-26 11:07:11 +01:00
Sascha Ißbrücker
e2a52b9cba Bump version 2022-03-26 11:02:06 +01:00
Sascha Ißbrücker
4ad2d2111a Bump npm packages (#224)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-03-26 10:58:47 +01:00
Christoph Schmatzler
c16e87f9c7 Allow specifying port through LD_SERVER_PORT environment variable (#156)
* Allow specifying port through LD_SERVER_PORT environment variable

Co-authored-by: Christoph Schmatzler <christoph@medium.place>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-03-26 10:24:38 +01:00
elmodor
673466ab28 Increase buffer size (#189)
Handles requests with a larger block size than the default 4096
2022-03-26 10:02:36 +01:00
Sascha Ißbrücker
13f27f5412 Update CHANGELOG.md 2022-03-25 19:57:59 +01:00
Sascha Ißbrücker
530c4b74c4 Update CHANGELOG.md 2022-03-25 19:49:43 +01:00
58 changed files with 1204 additions and 475 deletions

45
.gitignore vendored
View File

@@ -3,55 +3,14 @@
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/dictionaries
.idea/**/shelf
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
cmake-build-release/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
.idea
*.iml
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

15
.idea/compiler.xml generated
View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<wildcardResourcePatterns>
<entry name="!?*.java" />
<entry name="!?*.form" />
<entry name="!?*.class" />
<entry name="!?*.groovy" />
<entry name="!?*.scala" />
<entry name="!?*.flex" />
<entry name="!?*.kt" />
<entry name="!?*.clj" />
</wildcardResourcePatterns>
</component>
</project>

11
.idea/dataSources.xml generated
View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="SQLite - db.sqlite3" uuid="c880bd6d-554c-484d-a5be-45581d9a9377">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/db.sqlite3</jdbc-url>
</data-source>
</component>
</project>

4
.idea/encodings.xml generated
View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

View File

@@ -1,12 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E402" />
</list>
</option>
</inspection_tool>
</profile>
</component>

13
.idea/misc.xml generated
View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="HaskellBuildOptions">
<ghcPath>/usr/local/bin/ghc</ghcPath>
<stackPath>/usr/local/bin/stack</stackPath>
</component>
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" default="false" project-jdk-name="Python 3.7 (linkding)" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/linkdings.iml" filepath="$PROJECT_DIR$/linkdings.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

View File

@@ -1,5 +1,23 @@
# Changelog
## v1.8.8 (27/03/2022)
- [**bug**] Prevent bookmark actions through get requests
- [**bug**] Prevent external redirects
---
## v1.8.7 (26/03/2022)
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
---
## v1.8.6 (25/03/2022)
- [bug] fix bookmark access restrictions
- [bug] prevent external redirects
- [chore] bump dependencies
---
## v1.8.5 (13/12/2021)
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)

View File

@@ -14,7 +14,7 @@ The name comes from:
- Bulk editing
- Bookmark archive
- Dark mode
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Automatically provides titles and descriptions of bookmarked websites
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), and a bookmarklet that should work in most browsers
@@ -168,5 +168,6 @@ The frontend is now available under http://localhost:8000
## Community
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)

View File

@@ -7,7 +7,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
from bookmarks.models import Bookmark, Tag, UserProfile, Toast
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -58,7 +58,7 @@ class AdminTag(admin.ModelAdmin):
def bookmarks_count(self, obj):
return obj.bookmarks_count
bookmarks_count.admin_order_field = 'bookmarks_count'
def delete_unused_tags(self, request, queryset: QuerySet):
@@ -95,8 +95,15 @@ class AdminCustomUser(UserAdmin):
return super(AdminCustomUser, self).get_inline_instances(request, obj)
class AdminToast(admin.ModelAdmin):
list_display = ('key', 'message', 'owner', 'acknowledged')
search_fields = ('key', 'message')
list_filter = ('owner__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)

View File

@@ -11,6 +11,7 @@
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
@@ -86,7 +87,7 @@
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end);
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close();
}
@@ -100,6 +101,16 @@
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
@@ -114,7 +125,8 @@
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}>
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>

View File

@@ -0,0 +1,12 @@
from bookmarks.models import Toast
def toasts(request):
user = request.user if hasattr(request, 'user') else None
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
has_toasts = len(toast_messages) > 0
return {
'has_toasts': has_toasts,
'toast_messages': toast_messages,
}

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.6 on 2022-01-08 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0010_userprofile_bookmark_link_target'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='web_archive_integration',
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.2.6 on 2022-01-08 19:24
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', '0011_userprofile_web_archive_integration'),
]
operations = [
migrations.CreateModel(
name='Toast',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=50)),
('message', models.TextField()),
('acknowledged', models.BooleanField(default=False)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.2.6 on 2022-01-08 19:27
from django.db import migrations
from django.contrib.auth import get_user_model
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(key='web_archive_opt_in_hint',
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
owner=user)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key='web_archive_opt_in_hint').delete()
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0012_toast'),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -128,18 +128,26 @@ class UserProfile(models.Model):
(BOOKMARK_LINK_TARGET_BLANK, 'New page'),
(BOOKMARK_LINK_TARGET_SELF, 'Same page'),
]
WEB_ARCHIVE_INTEGRATION_DISABLED = 'disabled'
WEB_ARCHIVE_INTEGRATION_ENABLED = 'enabled'
WEB_ARCHIVE_INTEGRATION_CHOICES = [
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
]
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
default=BOOKMARK_DATE_DISPLAY_RELATIVE)
bookmark_link_target = models.CharField(max_length=10, choices=BOOKMARK_LINK_TARGET_CHOICES, blank=False,
default=BOOKMARK_LINK_TARGET_BLANK)
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target']
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration']
@receiver(post_save, sender=get_user_model())
@@ -151,3 +159,10 @@ def create_user_profile(sender, instance, created, **kwargs):
@receiver(post_save, sender=get_user_model())
def save_user_profile(sender, instance, **kwargs):
instance.profile.save()
class Toast(models.Model):
key = models.CharField(max_length=50)
message = models.TextField()
acknowledged = models.BooleanField(default=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)

View File

@@ -29,7 +29,7 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
_update_bookmark_tags(bookmark, tag_string, current_user)
bookmark.save()
# Create snapshot on web archive
tasks.create_web_archive_snapshot(bookmark.id, False)
tasks.create_web_archive_snapshot(current_user, bookmark, False)
return bookmark
@@ -47,7 +47,7 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
bookmark.save()
# Update web archive snapshot, if URL changed
if has_url_changed:
tasks.create_web_archive_snapshot(bookmark.id, True)
tasks.create_web_archive_snapshot(current_user, bookmark, True)
return bookmark

View File

@@ -40,7 +40,7 @@ def import_netscape_html(html: str, user: User):
result.failed = result.failed + 1
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user.id)
tasks.schedule_bookmarks_without_snapshots(user)
return result

View File

@@ -4,30 +4,29 @@ import waybackpy
from background_task import background
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from waybackpy.exceptions import WaybackError
from bookmarks.models import Bookmark
from bookmarks.models import Bookmark, UserProfile
logger = logging.getLogger(__name__)
def when_background_tasks_enabled(fn):
def wrapper(*args, **kwargs):
if settings.LD_DISABLE_BACKGROUND_TASKS:
return
return fn(*args, **kwargs)
def is_web_archive_integration_active(user: User) -> bool:
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
web_archive_integration_enabled = \
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
# Expose attributes from wrapped TaskProxy function
attrs = vars(fn)
for key, value in attrs.items():
setattr(wrapper, key, value)
return wrapper
return background_tasks_enabled and web_archive_integration_enabled
def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):
if is_web_archive_integration_active(user):
_create_web_archive_snapshot_task(bookmark.id, force_update)
@when_background_tasks_enabled
@background()
def create_web_archive_snapshot(bookmark_id: int, force_update: bool):
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
@@ -52,11 +51,15 @@ def create_web_archive_snapshot(bookmark_id: int, force_update: bool):
logger.debug(f'Successfully created web archive link for bookmark: {bookmark}...')
@when_background_tasks_enabled
def schedule_bookmarks_without_snapshots(user: User):
if is_web_archive_integration_active(user):
_schedule_bookmarks_without_snapshots_task(user.id)
@background()
def schedule_bookmarks_without_snapshots(user_id: int):
def _schedule_bookmarks_without_snapshots_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
for bookmark in bookmarks_without_snapshots:
create_web_archive_snapshot(bookmark.id, False)
_create_web_archive_snapshot_task(bookmark.id, False)

View File

@@ -5,4 +5,4 @@ from bookmarks.services import tasks
@receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs):
tasks.schedule_bookmarks_without_snapshots(user.id)
tasks.schedule_bookmarks_without_snapshots(user)

View File

@@ -19,6 +19,7 @@
if (buttonEl.nodeName === 'BUTTON') {
confirmEl.type = buttonEl.type;
confirmEl.name = buttonEl.name;
confirmEl.value = buttonEl.value;
}
if (buttonEl.nodeName === 'A') {
confirmEl.href = buttonEl.href;
@@ -60,6 +61,21 @@
event.preventDefault();
}
});
// Add new bookmark
document.addEventListener('keydown', function(event) {
// Filter for new entry shortcut key
if (event.key !== 'n') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
window.location.assign("/bookmarks/new");
});
}
initConfirmationButtons();

View File

@@ -11,6 +11,18 @@ header {
margin-bottom: 40px;
}
header .toasts {
margin-bottom: 20px;
.toast {
margin-bottom: 0.4rem;
}
.toast a.btn-clear:visited {
color: currentColor;
}
}
.navbar {
.navbar-brand {

View File

@@ -153,13 +153,13 @@ ul.bookmark-list {
}
}
/* Bulk edit */
/* Bookmark actions / bulk edit */
$bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms;
.bulk-edit-form {
.bookmarks-page form.bookmark-actions {
.bulk-edit-bar {
margin-top: -17px;

View File

@@ -18,7 +18,7 @@
{% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}

View File

@@ -30,7 +30,7 @@
<span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on web archive" target="{{ link_target }}" rel="noopener">
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
@@ -44,7 +44,7 @@
<span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on web archive" target="{{ link_target }}" rel="noopener">
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
@@ -57,14 +57,14 @@
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Unarchive</a>
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive</button>
{% else %}
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Archive</a>
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive</button>
{% endif %}
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm btn-confirmation">Remove</a>
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
</div>
</li>
{% endfor %}

View File

@@ -18,7 +18,7 @@
{% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}

View File

@@ -27,19 +27,31 @@
{% endif %}
</head>
<body>
<header class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>
</section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
<section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %}
</section>
<header>
{% if has_toasts %}
<div class="toasts container grid-lg">
{% for toast in toast_messages %}
<div class="toast">
{{ toast.message }}
<a href="{% url 'bookmarks:toasts.acknowledge' toast.id %}?return_url={{ request.path | urlencode }}" class="btn btn-clear float-right"></a>
</div>
{% endfor %}
</div>
{% endif %}
<div class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>
</section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
<section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %}
</section>
{% endif %}
</div>
</header>
<div class="content container grid-lg">
{% block content %}

View File

@@ -17,14 +17,35 @@
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label>
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
Machine</a>. 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.
</div>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2">

View File

@@ -7,7 +7,7 @@ from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -19,12 +19,77 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
for bookmark in bookmarks:
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
def test_archive_should_archive_bookmark(self):
bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'archive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertTrue(bookmark.is_archived)
def test_can_only_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), {
'archive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertFalse(bookmark.is_archived)
def test_unarchive_should_unarchive_bookmark(self):
bookmark = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:action'), {
'unarchive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertFalse(bookmark.is_archived)
def test_unarchive_can_only_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
response = self.client.post(reverse('bookmarks:action'), {
'unarchive': [bookmark.id],
})
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.is_archived)
def test_delete_should_delete_bookmark(self):
bookmark = self.setup_bookmark()
self.client.post(reverse('bookmarks:action'), {
'remove': [bookmark.id],
})
self.assertEqual(Bookmark.objects.count(), 0)
def test_delete_can_only_delete_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
response = self.client.post(reverse('bookmarks:action'), {
'remove': [bookmark.id],
})
self.assertEqual(response.status_code, 404)
self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
@@ -39,7 +104,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
@@ -53,7 +118,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_unarchive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
@@ -68,7 +133,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_unarchive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
@@ -82,7 +147,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_delete': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
@@ -97,7 +162,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(user=other_user)
bookmark3 = self.setup_bookmark(user=other_user)
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_delete': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
@@ -113,7 +178,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_tag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
@@ -135,7 +200,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_tag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
@@ -156,7 +221,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_untag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
@@ -178,7 +243,7 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bulk_untag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
@@ -192,17 +257,17 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_bulk_edit_handles_empty_bookmark_id(self):
def test_handles_empty_bookmark_id(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
response = self.client.post(reverse('bookmarks:bulk_edit'), {
response = self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
})
self.assertEqual(response.status_code, 302)
response = self.client.post(reverse('bookmarks:bulk_edit'), {
response = self.client.post(reverse('bookmarks:action'), {
'bulk_archive': [''],
'bookmark_id': [],
})
@@ -215,18 +280,18 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
self.client.post(reverse('bookmarks:action'), {
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_bulk_edit_should_redirect_to_return_url(self):
def test_should_redirect_to_return_url(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
url = reverse('bookmarks:bulk_edit') + '?return_url=' + reverse('bookmarks:settings.index')
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index')
response = self.client.post(url, {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
@@ -234,15 +299,24 @@ class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertRedirects(response, reverse('bookmarks:settings.index'))
def test_bulk_edit_should_not_redirect_to_external_url(self):
def test_should_not_redirect_to_external_url(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
url = reverse('bookmarks:bulk_edit') + '?return_url=https://example.com'
response = self.client.post(url, {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
def post_with(return_url, follow=None):
url = reverse('bookmarks:action') + f'?return_url={return_url}'
return self.client.post(url, {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
}, follow=follow)
response = post_with('https://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('//example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('/foo//example.com', follow=True)
self.assertEqual(response.status_code, 404)

View File

@@ -1,55 +0,0 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkArchiveViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_archive_bookmark(self):
bookmark = self.setup_bookmark()
self.client.get(reverse('bookmarks:archive', args=[bookmark.id]))
bookmark.refresh_from_db()
self.assertTrue(bookmark.is_archived)
def test_should_redirect_to_index(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:archive', args=[bookmark.id]))
self.assertRedirects(response, reverse('bookmarks:index'))
def test_should_redirect_to_return_url_when_specified(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:archive', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
)
self.assertRedirects(response, reverse('bookmarks:close'))
def test_can_only_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(reverse('bookmarks:archive', args=[bookmark.id]))
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertFalse(bookmark.is_archived)
def test_should_not_redirect_to_external_url(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:archive', args=[bookmark.id]) + '?return_url=https://example.com'
)
self.assertRedirects(response, reverse('bookmarks:index'))

View File

@@ -88,11 +88,21 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_not_redirect_to_external_url(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'return_url': 'https://example.com'})
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
def post_with(return_url, follow=None):
form_data = self.create_form_data()
url = reverse('bookmarks:edit', args=[bookmark.id]) + f'?return_url={return_url}'
return self.client.post(url, form_data, follow=follow)
response = post_with('https://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('//example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('://example.com')
self.assertRedirects(response, reverse('bookmarks:index'))
response = post_with('/foo//example.com', follow=True)
self.assertEqual(response.status_code, 404)
def test_can_only_edit_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')

View File

@@ -1,54 +0,0 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkRemoveViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_delete_bookmark(self):
bookmark = self.setup_bookmark()
self.client.get(reverse('bookmarks:remove', args=[bookmark.id]))
self.assertEqual(Bookmark.objects.count(), 0)
def test_should_redirect_to_index(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:remove', args=[bookmark.id]))
self.assertRedirects(response, reverse('bookmarks:index'))
def test_should_redirect_to_return_url_when_specified(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:remove', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
)
self.assertRedirects(response, reverse('bookmarks:close'))
def test_should_not_redirect_to_external_url(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:remove', args=[bookmark.id]) + '?return_url=https://example.com'
)
self.assertRedirects(response, reverse('bookmarks:index'))
def test_can_only_edit_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(reverse('bookmarks:remove', args=[bookmark.id]))
self.assertEqual(response.status_code, 404)
self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())

View File

@@ -1,55 +0,0 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkUnarchiveViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_unarchive_bookmark(self):
bookmark = self.setup_bookmark(is_archived=True)
self.client.get(reverse('bookmarks:unarchive', args=[bookmark.id]))
bookmark.refresh_from_db()
self.assertFalse(bookmark.is_archived)
def test_should_redirect_to_archive(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:unarchive', args=[bookmark.id]))
self.assertRedirects(response, reverse('bookmarks:archived'))
def test_should_redirect_to_return_url_when_specified(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:unarchive', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
)
self.assertRedirects(response, reverse('bookmarks:close'))
def test_should_not_redirect_to_external_url(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:unarchive', args=[bookmark.id]) + '?return_url=https://example.com'
)
self.assertRedirects(response, reverse('bookmarks:archived'))
def test_can_only_archive_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
response = self.client.get(reverse('bookmarks:unarchive', args=[bookmark.id]))
bookmark.refresh_from_db()
self.assertEqual(response.status_code, 404)
self.assertTrue(bookmark.is_archived)

View File

@@ -28,7 +28,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
self.assertInHTML(f'''
<span class="date-label text-gray text-sm">
<a href="{url}"
title="Show snapshot on web archive" target="{link_target}" rel="noopener">
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
<span>{label_content}</span>
<span>∞</span>
</a>

View File

@@ -23,7 +23,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_called_once_with(bookmark.id, False)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
@@ -31,7 +31,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_called_once_with(bookmark.id, True)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, True)
def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:

View File

@@ -5,8 +5,8 @@ from background_task.models import Task
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from bookmarks.models import Bookmark
from bookmarks.services.tasks import create_web_archive_snapshot, schedule_bookmarks_without_snapshots
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
@@ -26,6 +26,11 @@ class MockWaybackUrlWithSaveError:
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.save()
@disable_logging
def run_pending_task(self, task_function):
func = getattr(task_function, 'task_function', None)
@@ -48,16 +53,16 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
create_web_archive_snapshot(bookmark.id, False)
self.run_pending_task(create_web_archive_snapshot)
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')) as mock_wayback_url:
create_web_archive_snapshot(123, False)
self.run_pending_task(create_web_archive_snapshot)
tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_wayback_url.assert_not_called()
@@ -67,15 +72,15 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(waybackpy, 'Url',
return_value=MockWaybackUrlWithSaveError()):
with self.assertRaises(NotImplementedError):
create_web_archive_snapshot(bookmark.id, False)
self.run_pending_task(create_web_archive_snapshot)
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
create_web_archive_snapshot(bookmark.id, False)
self.run_pending_task(create_web_archive_snapshot)
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
@@ -84,8 +89,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
create_web_archive_snapshot(bookmark.id, True)
self.run_pending_task(create_web_archive_snapshot)
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
@@ -93,7 +98,16 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
create_web_archive_snapshot(bookmark.id, False)
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.assertEqual(Task.objects.count(), 0)
def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(self):
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.assertEqual(Task.objects.count(), 0)
@@ -104,9 +118,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
schedule_bookmarks_without_snapshots(user.id)
self.run_pending_task(schedule_bookmarks_without_snapshots)
self.run_all_pending_tasks(create_web_archive_snapshot)
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
@@ -118,9 +132,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
schedule_bookmarks_without_snapshots(user.id)
self.run_pending_task(schedule_bookmarks_without_snapshots)
self.run_all_pending_tasks(create_web_archive_snapshot)
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
@@ -136,9 +150,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user)
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
schedule_bookmarks_without_snapshots(user.id)
self.run_pending_task(schedule_bookmarks_without_snapshots)
self.run_all_pending_tasks(create_web_archive_snapshot)
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all().filter(owner=user):
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
@@ -148,7 +162,13 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(self):
user = self.get_or_create_test_user()
schedule_bookmarks_without_snapshots(user.id)
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(self):
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
self.user.profile.save()
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)

View File

@@ -55,4 +55,4 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user.id)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)

View File

@@ -27,6 +27,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
@@ -36,3 +37,4 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.theme, form_data['theme'])
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])

View File

@@ -12,4 +12,4 @@ class SignalsTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
self.client.force_login(user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user.id)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)

View File

@@ -0,0 +1,108 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Toast
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence, disable_logging
class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_toast(self, user: User = None, message: str = None, acknowledged: bool = False):
if not user:
user = self.user
if not message:
message = random_sentence()
toast = Toast(owner=user, key='test', message=message, acknowledged=acknowledged)
toast.save()
return toast
def test_should_render_unacknowledged_toasts(self):
self.create_toast()
self.create_toast()
self.create_toast(acknowledged=True)
response = self.client.get(reverse('bookmarks:index'))
# Should render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">')
# Should render two toasts
self.assertContains(response, '<div class="toast">', count=2)
def test_should_not_render_acknowledged_toasts(self):
self.create_toast(acknowledged=True)
self.create_toast(acknowledged=True)
self.create_toast(acknowledged=True)
response = self.client.get(reverse('bookmarks:index'))
# Should not render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
# Should not render toasts
self.assertContains(response, '<div class="toast">', count=0)
def test_should_not_render_toasts_of_other_users(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.create_toast(user=other_user)
self.create_toast(user=other_user)
self.create_toast(user=other_user)
response = self.client.get(reverse('bookmarks:index'))
# Should not render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
# Should not render toasts
self.assertContains(response, '<div class="toast">', count=0)
def test_toast_content(self):
toast = self.create_toast()
expected_toast = f'''
<div class="toast">
{toast.message}
<a href="{reverse('bookmarks:toasts.acknowledge', args=[toast.id])}?return_url={reverse('bookmarks:index')}" class="btn btn-clear float-right"></a>
</div>
'''
response = self.client.get(reverse('bookmarks:index'))
html = response.content.decode()
self.assertInHTML(expected_toast, html)
def test_acknowledge_toast(self):
toast = self.create_toast()
self.client.get(reverse('bookmarks:toasts.acknowledge', args=[toast.id]))
toast.refresh_from_db()
self.assertTrue(toast.acknowledged)
def test_acknowledge_toast_should_redirect_to_return_url(self):
toast = self.create_toast()
return_url = reverse('bookmarks:settings.general')
acknowledge_url = reverse('bookmarks:toasts.acknowledge', args=[toast.id])
acknowledge_url = acknowledge_url + '?return_url=' + return_url
response = self.client.get(acknowledge_url)
self.assertRedirects(response, return_url)
def test_acknowledge_toast_should_redirect_to_index_by_default(self):
toast = self.create_toast()
response = self.client.get(reverse('bookmarks:toasts.acknowledge', args=[toast.id]))
self.assertRedirects(response, reverse('bookmarks:index'))
@disable_logging
def test_acknowledge_toast_should_not_acknowledge_other_users_toast(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
toast = self.create_toast(user=other_user)
response = self.client.get(reverse('bookmarks:toasts.acknowledge', args=[toast.id]))
self.assertEqual(response.status_code, 404)

View File

@@ -15,16 +15,15 @@ urlpatterns = [
path('bookmarks/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
path('bookmarks/bulkedit', views.bookmarks.bulk_edit, name='bulk_edit'),
path('bookmarks/action', views.bookmarks.action, name='action'),
# Settings
path('settings', views.settings.general, name='settings.index'),
path('settings/general', views.settings.general, name='settings.general'),
path('settings/integrations', views.settings.integrations, name='settings.integrations'),
path('settings/import', views.settings.bookmark_import, name='settings.import'),
path('settings/export', views.settings.bookmark_export, name='settings.export'),
# Toasts
path('toasts/<int:toast_id>/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'),
# API
path('api/', include(router.urls), name='api')
]

View File

@@ -1,3 +1,4 @@
import re
from datetime import datetime
from typing import Optional
@@ -99,6 +100,6 @@ def parse_timestamp(value: str):
def get_safe_return_url(return_url: str, fallback_url: str):
# Use fallback if URL is none or URL is not on same domain
if not return_url or not return_url.startswith('/'):
if not return_url or not re.match(r'^/[a-z]+', return_url):
return fallback_url
return return_url

View File

@@ -1,2 +1,3 @@
from .bookmarks import *
from .settings import *
from .toasts import *

View File

@@ -135,7 +135,6 @@ def edit(request, bookmark_id: int):
return render(request, 'bookmarks/edit.html', context)
@login_required
def remove(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
@@ -143,11 +142,8 @@ def remove(request, bookmark_id: int):
raise Http404('Bookmark does not exist')
bookmark.delete()
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
return HttpResponseRedirect(return_url)
@login_required
def archive(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
@@ -155,11 +151,8 @@ def archive(request, bookmark_id: int):
raise Http404('Bookmark does not exist')
archive_bookmark(bookmark)
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
return HttpResponseRedirect(return_url)
@login_required
def unarchive(request, bookmark_id: int):
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
@@ -167,25 +160,32 @@ def unarchive(request, bookmark_id: int):
raise Http404('Bookmark does not exist')
unarchive_bookmark(bookmark)
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:archived'))
return HttpResponseRedirect(return_url)
@login_required
def bulk_edit(request):
bookmark_ids = request.POST.getlist('bookmark_id')
def action(request):
# Determine action
if 'archive' in request.POST:
archive(request, request.POST['archive'])
if 'unarchive' in request.POST:
unarchive(request, request.POST['unarchive'])
if 'remove' in request.POST:
remove(request, request.POST['remove'])
if 'bulk_archive' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
archive_bookmarks(bookmark_ids, request.user)
if 'bulk_unarchive' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
unarchive_bookmarks(bookmark_ids, request.user)
if 'bulk_delete' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
delete_bookmarks(bookmark_ids, request.user)
if 'bulk_tag' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
tag_bookmarks(bookmark_ids, tag_string, request.user)
if 'bulk_untag' in request.POST:
bookmark_ids = request.POST.getlist('bookmark_id')
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
untag_bookmarks(bookmark_ids, tag_string, request.user)

19
bookmarks/views/toasts.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, Http404
from django.urls import reverse
from bookmarks.models import Toast
from bookmarks.utils import get_safe_return_url
@login_required
def acknowledge(request, toast_id: int):
try:
toast = Toast.objects.get(pk=toast_id, owner=request.user)
except Toast.DoesNotExist:
raise Http404('Toast does not exist')
toast.acknowledged = True
toast.save()
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
return HttpResponseRedirect(return_url)

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Bootstrap script that gets executed in new Docker containers
LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
# Create data folder if it does not exist
mkdir -p data
@@ -18,4 +20,4 @@ if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then
fi
# Start uwsgi server
uwsgi uwsgi.ini
uwsgi --http :$LD_SERVER_PORT uwsgi.ini

View File

@@ -29,7 +29,7 @@ All options need to be defined as environment variables in the environment that
Values: `True`, `False` | Default = `False`
Disables background tasks, such as creating snapshots for bookmarks on the web archive.
Disables background tasks, such as creating snapshots for bookmarks on the [the Internet Archive Wayback Machine](https://archive.org/web/).
Enabling this flag will prevent the background task processor from starting up, and prevents scheduling tasks.
This might be useful if you are experiencing performance issues or other problematic behaviour due to background task processing.
@@ -44,4 +44,10 @@ This can be useful if you intend to store non fully qualified domain name URLs,
Values: `Integer` as seconds | Default = `60`
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
### `LD_SERVER_PORT`
Values: Valid port number | Default = `9090`
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="siteroot/settings/dev.py" />
<option name="manageScript" value="manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/build" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
</component>
</module>

644
package-lock.json generated
View File

@@ -1,8 +1,604 @@
{
"name": "linkding",
"version": "1.0.0",
"lockfileVersion": 1,
"version": "1.8.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "linkding",
"version": "1.8.6",
"license": "ISC",
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^13.1.3",
"rollup": "^2.70.1",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"spectre.css": "^0.5.8",
"svelte": "^3.46.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
"integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz",
"integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw=="
},
"node_modules/@babel/highlight": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.10.4",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"node_modules/@rollup/plugin-commonjs": {
"version": "21.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
"integrity": "sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg==",
"dependencies": {
"@rollup/pluginutils": "^3.1.0",
"commondir": "^1.0.1",
"estree-walker": "^2.0.1",
"glob": "^7.1.6",
"is-reference": "^1.2.1",
"magic-string": "^0.25.7",
"resolve": "^1.17.0"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"rollup": "^2.38.3"
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.3.tgz",
"integrity": "sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ==",
"dependencies": {
"@rollup/pluginutils": "^3.1.0",
"@types/resolve": "1.17.1",
"builtin-modules": "^3.1.0",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">= 10.0.0"
},
"peerDependencies": {
"rollup": "^2.42.0"
}
},
"node_modules/@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
"dependencies": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0"
}
},
"node_modules/@rollup/pluginutils/node_modules/estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="
},
"node_modules/@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="
},
"node_modules/@types/node": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.1.tgz",
"integrity": "sha512-hx6zWtudh3Arsbl3cXay+JnkvVgCKzCWKv42C9J01N2T2np4h8w5X8u6Tpz5mj38kE3M9FM0Pazx8vKFFMnjLQ=="
},
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
"integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"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=="
},
"node_modules/builtin-modules": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz",
"integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"node_modules/deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dependencies": {
"function-bind": "^1.1.1"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"engines": {
"node": ">=4"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-core-module": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
"integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
"dependencies": {
"has": "^1.0.3"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE="
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/jest-worker": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
"integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
"supports-color": "^7.0.0"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/jest-worker/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/jest-worker/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/magic-string": {
"version": "0.25.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
"dependencies": {
"sourcemap-codec": "^1.4.4"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
},
"node_modules/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/picomatch": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/require-relative": {
"version": "0.8.7",
"resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
"integrity": "sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4="
},
"node_modules/resolve": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz",
"integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==",
"dependencies": {
"is-core-module": "^2.1.0",
"path-parse": "^1.0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": {
"version": "2.70.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.1.tgz",
"integrity": "sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=10.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/rollup-plugin-svelte": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.0.tgz",
"integrity": "sha512-vopCUq3G+25sKjwF5VilIbiY6KCuMNHP1PFvx2Vr3REBNMDllKHFZN2B9jwwC+MqNc3UPKkjXnceLPEjTjXGXg==",
"dependencies": {
"require-relative": "^0.8.7",
"rollup-pluginutils": "^2.8.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"rollup": ">=2.0.0",
"svelte": ">=3.5.0"
}
},
"node_modules/rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
"integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"jest-worker": "^26.2.1",
"serialize-javascript": "^4.0.0",
"terser": "^5.0.0"
},
"peerDependencies": {
"rollup": "^2.0.0"
}
},
"node_modules/rollup-pluginutils": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz",
"integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==",
"dependencies": {
"estree-walker": "^0.6.1"
}
},
"node_modules/rollup-pluginutils/node_modules/estree-walker": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/serialize-javascript": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"dependencies": {
"randombytes": "^2.1.0"
}
},
"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==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
},
"node_modules/spectre.css": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/spectre.css/-/spectre.css-0.5.8.tgz",
"integrity": "sha512-3N4WocWY+Dl6b3e5v3nsZYyp+VSDcBfGDzyyHw/H78ie9BoAhHkxmrhLxo9y8RadxYzVrPjfPdlev3hXEUzR2w=="
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"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==",
"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==",
"dependencies": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.19"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
}
},
"dependencies": {
"@babel/code-frame": {
"version": "7.12.11",
@@ -28,9 +624,9 @@
}
},
"@rollup/plugin-commonjs": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.0.0.tgz",
"integrity": "sha512-/omBIJG1nHQc+bgkYDuLpb/V08QyutP9amOrJRUSlYJZP+b/68gM//D8sxJe3Yry2QnYIr3QjR3x4AlxJEN3GA==",
"version": "21.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
"integrity": "sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg==",
"requires": {
"@rollup/pluginutils": "^3.1.0",
"commondir": "^1.0.1",
@@ -42,9 +638,9 @@
}
},
"@rollup/plugin-node-resolve": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.0.1.tgz",
"integrity": "sha512-ltlsj/4Bhwwhb+Nb5xCz/6vieuEj2/BAkkqVIKmZwC7pIdl8srmgmglE4S0jFlZa32K4qvdQ6NHdmpRKD/LwoQ==",
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.3.tgz",
"integrity": "sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ==",
"requires": {
"@rollup/pluginutils": "^3.1.0",
"@types/resolve": "1.17.1",
@@ -180,9 +776,9 @@
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"function-bind": {
@@ -316,9 +912,9 @@
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"picomatch": {
"version": "2.2.2",
@@ -348,17 +944,17 @@
}
},
"rollup": {
"version": "2.35.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.35.1.tgz",
"integrity": "sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==",
"version": "2.70.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.1.tgz",
"integrity": "sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==",
"requires": {
"fsevents": "~2.1.2"
"fsevents": "~2.3.2"
}
},
"rollup-plugin-svelte": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.0.0.tgz",
"integrity": "sha512-cw4yv/5v1NQV3nPbpOJtikgkB+9mfSJaqKUdq7x5fVQJnwLtcdc2JOszBs5pBY+SemTs5pmJbdEMseEavbUtjQ==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.0.tgz",
"integrity": "sha512-vopCUq3G+25sKjwF5VilIbiY6KCuMNHP1PFvx2Vr3REBNMDllKHFZN2B9jwwC+MqNc3UPKkjXnceLPEjTjXGXg==",
"requires": {
"require-relative": "^0.8.7",
"rollup-pluginutils": "^2.8.2"
@@ -443,9 +1039,9 @@
}
},
"svelte": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.31.0.tgz",
"integrity": "sha512-r+n8UJkDqoQm1b+3tA3Lh6mHXKpcfOSOuEuIo5gE2W9wQYi64RYX/qE6CZBDDsP/H4M+N426JwY7XGH4xASvGQ=="
"version": "3.46.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg=="
},
"terser": {
"version": "5.5.1",

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.8.6",
"version": "1.9.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -19,12 +19,12 @@
},
"homepage": "https://github.com/sissbruecker/linkding#readme",
"dependencies": {
"spectre.css": "^0.5.8",
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.1",
"rollup": "^2.35.1",
"rollup-plugin-svelte": "^7.0.0",
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^13.1.3",
"rollup": "^2.70.1",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"svelte": "^3.31.0"
"spectre.css": "^0.5.8",
"svelte": "^3.46.4"
}
}

View File

@@ -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.12
Django==3.2.13
django-background-tasks==1.2.5
django-compat==1.0.15
django-generate-secret-key==1.0.2

View File

@@ -4,7 +4,7 @@ certifi==2019.6.16
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
coverage==5.5
Django==3.2.12
Django==3.2.13
django-appconf==1.0.4
django-background-tasks==1.2.5
django-compat==1.0.15

View File

@@ -69,6 +69,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'bookmarks.context_processors.toasts',
],
},
},

View File

@@ -1,5 +1,4 @@
[uwsgi]
http = :9090
chdir = /etc/linkding
module = siteroot.wsgi:application
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
@@ -11,6 +10,7 @@ vacuum=True
stats = 127.0.0.1:9191
uid = www-data
gid = www-data
buffer-size = 8192
if-env = LD_REQUEST_TIMEOUT
http-timeout = %(_)

View File

@@ -1 +1 @@
1.8.6
1.9.0