mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 22:49:23 +02:00
Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e7c55cd318 | ||
![]() |
d87dde6bae | ||
![]() |
8d214649b7 | ||
![]() |
dfb040bbb1 | ||
![]() |
076c5d7658 | ||
![]() |
e47c00bd07 | ||
![]() |
55a0d189dd | ||
![]() |
d39ce076ec | ||
![]() |
aa0258d3b6 | ||
![]() |
937858cf58 | ||
![]() |
8047ba6c63 | ||
![]() |
de903bc341 | ||
![]() |
c8fcc426b0 | ||
![]() |
eb915210d3 |
@@ -7,6 +7,7 @@
|
||||
/docs
|
||||
/static
|
||||
/build
|
||||
/out
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
@@ -17,10 +18,13 @@
|
||||
/*.patch
|
||||
/*.md
|
||||
/*.js
|
||||
/*.log
|
||||
/*.pid
|
||||
|
||||
# Whitelist files needed in build or prod image
|
||||
!/rollup.config.js
|
||||
!/bootstrap.sh
|
||||
!/background-tasks-wrapper.sh
|
||||
|
||||
# Remove development settings
|
||||
/siteroot/settings/dev.py
|
||||
|
@@ -5,5 +5,7 @@ LD_HOST_PORT=9090
|
||||
# Directory on the host system that should be mounted as data dir into the Docker container
|
||||
LD_HOST_DATA_DIR=./data
|
||||
|
||||
# Option to disable background tasks
|
||||
LD_DISABLE_BACKGROUND_TASKS=False
|
||||
# Option to disable URL validation for bookmarks completely
|
||||
LD_DISABLE_URL_VALIDATION=False
|
||||
LD_DISABLE_URL_VALIDATION=False
|
||||
|
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.2 (26/08/2021)
|
||||
- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)
|
||||
|
||||
---
|
||||
|
||||
## v1.7.1 (25/08/2021)
|
||||
- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)
|
||||
- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)
|
||||
- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 (17/08/2021)
|
||||
- Upgrade to Django 3
|
||||
- Bump other dependencies
|
||||
|
||||
---
|
||||
|
||||
## v1.6.5 (15/08/2021)
|
||||
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
|
||||
---
|
||||
|
@@ -9,7 +9,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.9-slim AS python-base
|
||||
FROM python:3.9.6-slim-buster AS python-base
|
||||
RUN apt-get update && apt-get -y install build-essential
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
@@ -33,7 +33,7 @@ RUN mkdir /opt/venv && \
|
||||
/opt/venv/bin/pip install -Ur requirements.txt
|
||||
|
||||
|
||||
FROM python:3.9-slim as final
|
||||
FROM python:3.9.6-slim-buster as final
|
||||
RUN apt-get update && apt-get -y install mime-support
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
|
15
README.md
15
README.md
@@ -13,17 +13,14 @@ The name comes from:
|
||||
- Search by text or tags
|
||||
- Bulk editing
|
||||
- Bookmark archive
|
||||
- Automatically provides titles and descriptions from linked 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)
|
||||
- Bookmarklet that should work in most browsers
|
||||
- Dark mode
|
||||
- Easy to set up using Docker
|
||||
- Uses SQLite as database
|
||||
- Works without Javascript
|
||||
- ...but has several UI enhancements when Javascript is enabled
|
||||
- Automatically creates snapshots of bookmarked websites on [web archive](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
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
- Easy to set up using Docker, uses SQLite as database
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
||||
@@ -88,7 +85,7 @@ If you can not or don't want to use Docker you can install the application manua
|
||||
|
||||
### Hosting
|
||||
|
||||
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
|
||||
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support the process here, but I can give some pointers on what to search for:
|
||||
- first get the app running (described in this document)
|
||||
- open the port that the application is running on in your servers firewall
|
||||
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
|
||||
|
5
background-tasks-wrapper.sh
Executable file
5
background-tasks-wrapper.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
|
||||
|
||||
python manage.py clean_tasks
|
||||
exec python manage.py process_tasks
|
@@ -3,3 +3,7 @@ from django.apps import AppConfig
|
||||
|
||||
class BookmarksConfig(AppConfig):
|
||||
name = 'bookmarks'
|
||||
|
||||
def ready(self):
|
||||
# Register signal handlers
|
||||
import bookmarks.signals
|
||||
|
15
bookmarks/management/commands/clean_tasks.py
Normal file
15
bookmarks/management/commands/clean_tasks.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Remove task locks and clear completed task history"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Remove task locks
|
||||
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
|
||||
# even though no process is working on them, and would prevent the task processor from picking the next task in
|
||||
# the queue
|
||||
Task.objects.all().update(locked_by=None, locked_at=None)
|
||||
# Clear task history to prevent them from bloating the DB
|
||||
CompletedTask.objects.all().delete()
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.20 on 2021-05-16 14:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0008_userprofile_bookmark_date_display'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='web_archive_snapshot_url',
|
||||
field=models.CharField(blank=True, max_length=2048),
|
||||
),
|
||||
]
|
@@ -41,6 +41,7 @@ class Bookmark(models.Model):
|
||||
description = models.TextField(blank=True)
|
||||
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)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
date_added = models.DateTimeField()
|
||||
|
@@ -6,6 +6,7 @@ from django.utils import timezone
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
from bookmarks.services.website_loader import load_website_metadata
|
||||
from bookmarks.services import tasks
|
||||
|
||||
|
||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
@@ -27,10 +28,16 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
# Update tag list
|
||||
_update_bookmark_tags(bookmark, tag_string, current_user)
|
||||
bookmark.save()
|
||||
# Create snapshot on web archive
|
||||
tasks.create_web_archive_snapshot(bookmark.id, False)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
# Detect URL change
|
||||
original_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
has_url_changed = original_bookmark.url != bookmark.url
|
||||
# Update website info
|
||||
_update_website_metadata(bookmark)
|
||||
# Update tag list
|
||||
@@ -38,6 +45,10 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
# Update dates
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
# Update web archive snapshot, if URL changed
|
||||
if has_url_changed:
|
||||
tasks.create_web_archive_snapshot(bookmark.id, True)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services.parser import parse, NetscapeBookmark
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
from bookmarks.utils import parse_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +39,9 @@ def import_netscape_html(html: str, user: User):
|
||||
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
||||
result.failed = result.failed + 1
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user.id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -47,7 +51,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
|
||||
bookmark.url = netscape_bookmark.href
|
||||
if netscape_bookmark.date_added:
|
||||
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
else:
|
||||
bookmark.date_added = timezone.now()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
@@ -57,6 +61,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
bookmark.description = netscape_bookmark.description
|
||||
bookmark.owner = user
|
||||
|
||||
bookmark.full_clean()
|
||||
bookmark.save()
|
||||
|
||||
# Set tags
|
||||
|
62
bookmarks/services/tasks.py
Normal file
62
bookmarks/services/tasks.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import logging
|
||||
|
||||
import waybackpy
|
||||
from background_task import background
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
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)
|
||||
|
||||
# Expose attributes from wrapped TaskProxy function
|
||||
attrs = vars(fn)
|
||||
for key, value in attrs.items():
|
||||
setattr(wrapper, key, value)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@when_background_tasks_enabled
|
||||
@background()
|
||||
def create_web_archive_snapshot(bookmark_id: int, force_update: bool):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
# Skip if snapshot exists and update is not explicitly requested
|
||||
if bookmark.web_archive_snapshot_url and not force_update:
|
||||
return
|
||||
|
||||
logger.debug(f'Create web archive link for bookmark: {bookmark}...')
|
||||
|
||||
wayback = waybackpy.Url(bookmark.url)
|
||||
|
||||
try:
|
||||
archive = wayback.save()
|
||||
except WaybackError as error:
|
||||
logger.exception(f'Error creating web archive link for bookmark: {bookmark}...', exc_info=error)
|
||||
raise
|
||||
|
||||
bookmark.web_archive_snapshot_url = archive.archive_url
|
||||
bookmark.save()
|
||||
logger.debug(f'Successfully created web archive link for bookmark: {bookmark}...')
|
||||
|
||||
|
||||
@when_background_tasks_enabled
|
||||
@background()
|
||||
def schedule_bookmarks_without_snapshots(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)
|
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from charset_normalizer import from_bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,5 +34,11 @@ def load_website_metadata(url: str):
|
||||
|
||||
|
||||
def load_page(url: str):
|
||||
r = requests.get(url)
|
||||
return r.text
|
||||
r = requests.get(url, timeout=10)
|
||||
|
||||
# Use charset_normalizer to determine encoding that best matches the response content
|
||||
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
|
||||
# This is different from Response.text which does respect the encoding specified in the response first,
|
||||
# before trying to determine one
|
||||
results = from_bytes(r.content)
|
||||
return str(results.best())
|
||||
|
8
bookmarks/signals.py
Normal file
8
bookmarks/signals.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.contrib.auth import user_logged_in
|
||||
from django.dispatch import receiver
|
||||
from bookmarks.services import tasks
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def user_logged_in(sender, request, user, **kwargs):
|
||||
tasks.schedule_bookmarks_without_snapshots(user.id)
|
@@ -54,6 +54,10 @@ ul.bookmark-list {
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.actions .date-label a {
|
||||
color: $gray-color;
|
||||
}
|
||||
|
||||
.actions .btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
|
@@ -27,11 +27,31 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if request.user.profile.bookmark_date_display == 'relative' %}
|
||||
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
<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="_blank" rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<span>∞</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
{% endif %}
|
||||
{% if request.user.profile.bookmark_date_display == 'absolute' %}
|
||||
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
<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="_blank" rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<span>∞</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
||||
|
@@ -69,6 +69,15 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# About section #}
|
||||
<section class="content-area">
|
||||
<h2>About</h2>
|
||||
<p>Version: {{ app_version }}</p>
|
||||
<p>
|
||||
Code: <a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import random
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
@@ -27,6 +28,7 @@ class BookmarkFactoryMixin:
|
||||
description: str = '',
|
||||
website_title: str = '',
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
):
|
||||
if tags is None:
|
||||
tags = []
|
||||
@@ -44,7 +46,8 @@ class BookmarkFactoryMixin:
|
||||
date_added=timezone.now(),
|
||||
date_modified=timezone.now(),
|
||||
owner=user,
|
||||
is_archived=is_archived
|
||||
is_archived=is_archived,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
@@ -117,3 +120,14 @@ def random_sentence(num_words: int = None, including_word: str = ''):
|
||||
random.shuffle(selected_words)
|
||||
|
||||
return ' '.join(selected_words)
|
||||
|
||||
|
||||
def disable_logging(f):
|
||||
def wrapper(*args):
|
||||
logging.disable(logging.CRITICAL)
|
||||
result = f(*args)
|
||||
logging.disable(logging.NOTSET)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
@@ -11,10 +11,10 @@
|
||||
<DT><A HREF="https://example.com/1" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag1">test title 1</A>
|
||||
<DD>test description 1
|
||||
|
||||
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
|
||||
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559000" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
|
||||
<DD>test description 2
|
||||
|
||||
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
|
||||
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559000000" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
|
||||
<DD>test description 3
|
||||
|
||||
</DL><p>
|
@@ -34,6 +34,27 @@ class BookmarkValidationTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def test_bookmark_model_should_not_allow_missing_url(self):
|
||||
bookmark = Bookmark(
|
||||
date_added=datetime.datetime.now(),
|
||||
date_modified=datetime.datetime.now(),
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
bookmark.full_clean()
|
||||
|
||||
def test_bookmark_model_should_not_allow_empty_url(self):
|
||||
bookmark = Bookmark(
|
||||
url='',
|
||||
date_added=datetime.datetime.now(),
|
||||
date_modified=datetime.datetime.now(),
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
bookmark.full_clean()
|
||||
|
||||
@override_settings(LD_DISABLE_URL_VALIDATION=False)
|
||||
def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):
|
||||
self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)
|
||||
|
@@ -24,9 +24,10 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
return template_to_render.render(context)
|
||||
|
||||
def setup_date_format_test(self, date_display_setting):
|
||||
def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.date_added = timezone.now() - relativedelta(days=8)
|
||||
bookmark.web_archive_snapshot_url = web_archive_url
|
||||
bookmark.save()
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.bookmark_date_display = date_display_setting
|
||||
@@ -39,7 +40,27 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<span class="text-gray text-sm">{formatted_date}</span>
|
||||
<span class="date-label text-gray text-sm">
|
||||
<span>{formatted_date}</span>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
|
||||
def test_should_render_web_archive_link_with_absolute_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
|
||||
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
|
||||
html = self.render_template([bookmark])
|
||||
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<a href="{bookmark.web_archive_snapshot_url}"
|
||||
title="Show snapshot on web archive" target="_blank" rel="noopener">
|
||||
<span>{formatted_date}</span>
|
||||
<span>∞</span>
|
||||
</a>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
|
||||
def test_should_respect_relative_date_setting(self):
|
||||
@@ -47,5 +68,23 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
html = self.render_template([bookmark])
|
||||
|
||||
self.assertInHTML('''
|
||||
<span class="text-gray text-sm">1 week ago</span>
|
||||
<span class="date-label text-gray text-sm">
|
||||
<span>1 week ago</span>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
|
||||
def test_should_render_web_archive_link_with_relative_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
|
||||
html = self.render_template([bookmark])
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<a href="{bookmark.web_archive_snapshot_url}"
|
||||
title="Show snapshot on web archive" target="_blank" rel="noopener">
|
||||
<span>1 week ago</span>
|
||||
<span>∞</span>
|
||||
</a>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
|
@@ -1,11 +1,14 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \
|
||||
delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.services import tasks
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -13,7 +16,30 @@ User = get_user_model()
|
||||
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
self.get_or_create_test_user()
|
||||
|
||||
def test_create_should_create_web_archive_snapshot(self):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
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)
|
||||
|
||||
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:
|
||||
bookmark = self.setup_bookmark()
|
||||
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)
|
||||
|
||||
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:
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.title = 'updated title'
|
||||
update_bookmark(bookmark, 'tag1 tag2', self.user)
|
||||
|
||||
mock_create_web_archive_snapshot.assert_not_called()
|
||||
|
||||
def test_archive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
|
154
bookmarks/tests/test_bookmarks_tasks.py
Normal file
154
bookmarks/tests/test_bookmarks_tasks.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import waybackpy
|
||||
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.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
class MockWaybackUrl:
|
||||
|
||||
def __init__(self, archive_url: str):
|
||||
self.archive_url = archive_url
|
||||
|
||||
def save(self):
|
||||
return self
|
||||
|
||||
|
||||
class MockWaybackUrlWithSaveError:
|
||||
def save(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
@disable_logging
|
||||
def run_pending_task(self, task_function):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
task = Task.objects.all()[0]
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
|
||||
@disable_logging
|
||||
def run_all_pending_tasks(self, task_function):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
tasks = Task.objects.all()
|
||||
|
||||
for task in tasks:
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
|
||||
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
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)
|
||||
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)
|
||||
|
||||
mock_wayback_url.assert_not_called()
|
||||
|
||||
def test_create_web_archive_snapshot_should_handle_wayback_save_error(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
def test_create_web_archive_snapshot_should_force_update_snapshot(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, True)
|
||||
self.run_pending_task(create_web_archive_snapshot)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
|
||||
|
||||
@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)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_create_snapshot_task_for_all_bookmarks_without_snapshot(self):
|
||||
user = self.get_or_create_test_user()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
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)
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_not_update_bookmarks_with_existing_snapshot(self):
|
||||
user = self.get_or_create_test_user()
|
||||
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
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)
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(self):
|
||||
user = self.get_or_create_test_user()
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
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)
|
||||
|
||||
for bookmark in Bookmark.objects.all().filter(owner=user):
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
for bookmark in Bookmark.objects.all().filter(owner=other_user):
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, '')
|
||||
|
||||
@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)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
45
bookmarks/tests/test_importer.py
Normal file
45
bookmarks/tests/test_importer.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services.importer import import_netscape_html
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
class ImporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def create_import_html(self, bookmark_tags_string: str):
|
||||
return f'''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
{bookmark_tags_string}
|
||||
</DL><p>
|
||||
'''
|
||||
|
||||
@disable_logging
|
||||
def test_validate_empty_or_missing_bookmark_url(self):
|
||||
test_html = self.create_import_html(f'''
|
||||
<!-- Empty URL -->
|
||||
<DT><A HREF="" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Empty URL</A>
|
||||
<DD>Empty URL
|
||||
<!-- Missing URL -->
|
||||
<DT><A ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Missing URL</A>
|
||||
<DD>Missing URL
|
||||
''')
|
||||
|
||||
import_result = import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(import_result.success, 0)
|
||||
|
||||
def test_schedule_snapshot_creation(self):
|
||||
user = self.get_or_create_test_user()
|
||||
test_html = self.create_import_html('')
|
||||
|
||||
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)
|
@@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -52,6 +52,7 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertNoFormSuccessHint(response)
|
||||
self.assertFormErrorHint(response, 'Please select a file to import.')
|
||||
|
||||
@disable_logging
|
||||
def test_should_show_hint_if_import_raises_exception(self):
|
||||
with open('bookmarks/tests/resources/invalid_import_file.png', 'rb') as import_file:
|
||||
response = self.client.post(
|
||||
@@ -64,6 +65,7 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertNoFormSuccessHint(response)
|
||||
self.assertFormErrorHint(response, 'An error occurred during bookmark import.')
|
||||
|
||||
@disable_logging
|
||||
def test_should_show_respective_hints_if_not_all_bookmarks_were_imported_successfully(self):
|
||||
with open('bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html') as import_file:
|
||||
response = self.client.post(
|
||||
|
15
bookmarks/tests/test_signals.py
Normal file
15
bookmarks/tests/test_signals.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class SignalsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_login_should_schedule_snapshot_creation(self):
|
||||
user = self.get_or_create_test_user()
|
||||
|
||||
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)
|
@@ -1,9 +1,10 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.utils import humanize_absolute_date, humanize_relative_date
|
||||
from bookmarks.utils import humanize_absolute_date, humanize_relative_date, parse_timestamp
|
||||
|
||||
|
||||
class UtilsTestCase(TestCase):
|
||||
@@ -63,3 +64,45 @@ class UtilsTestCase(TestCase):
|
||||
# Regression: Test that subsequent calls use current date instead of cached date (#107)
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
|
||||
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 13)), 'Today')
|
||||
|
||||
def verify_timestamp(self, date, factor=1):
|
||||
timestamp_string = str(int(date.timestamp() * factor))
|
||||
parsed_date = parse_timestamp(timestamp_string)
|
||||
self.assertEqual(date, parsed_date)
|
||||
|
||||
def test_parse_timestamp_fails_for_invalid_timestamps(self):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_timestamp('invalid')
|
||||
|
||||
def test_parse_timestamp_parses_millisecond_timestamps(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
fifty_years_ago = now - relativedelta(year=50)
|
||||
fifty_years_from_now = now + relativedelta(year=50)
|
||||
|
||||
self.verify_timestamp(now)
|
||||
self.verify_timestamp(fifty_years_ago)
|
||||
self.verify_timestamp(fifty_years_from_now)
|
||||
|
||||
def test_parse_timestamp_parses_microsecond_timestamps(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
fifty_years_ago = now - relativedelta(year=50)
|
||||
fifty_years_from_now = now + relativedelta(year=50)
|
||||
|
||||
self.verify_timestamp(now, 1000)
|
||||
self.verify_timestamp(fifty_years_ago, 1000)
|
||||
self.verify_timestamp(fifty_years_from_now, 1000)
|
||||
|
||||
def test_parse_timestamp_parses_nanosecond_timestamps(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
fifty_years_ago = now - relativedelta(year=50)
|
||||
fifty_years_from_now = now + relativedelta(year=50)
|
||||
|
||||
self.verify_timestamp(now, 1000000)
|
||||
self.verify_timestamp(fifty_years_ago, 1000000)
|
||||
self.verify_timestamp(fifty_years_from_now, 1000000)
|
||||
|
||||
def test_parse_timestamp_fails_for_out_of_range_timestamp(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.verify_timestamp(now, 1000000000)
|
||||
|
@@ -58,3 +58,40 @@ def humanize_relative_date(value: datetime, now: Optional[datetime] = None):
|
||||
return 'Yesterday'
|
||||
else:
|
||||
return weekday_names[value.isoweekday()]
|
||||
|
||||
|
||||
def parse_timestamp(value: str):
|
||||
"""
|
||||
Parses a string timestamp into a datetime value
|
||||
First tries to parse the timestamp as milliseconds.
|
||||
If that fails with an error indicating that the timestamp exceeds the maximum,
|
||||
it tries to parse the timestamp as microseconds, and then as nanoseconds
|
||||
:param value:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
timestamp = int(value)
|
||||
except ValueError:
|
||||
raise ValueError(f'{value} is not a valid timestamp')
|
||||
|
||||
try:
|
||||
return datetime.utcfromtimestamp(timestamp).astimezone()
|
||||
except (OverflowError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# Value exceeds the max. allowed timestamp
|
||||
# Try parsing as microseconds
|
||||
try:
|
||||
return datetime.utcfromtimestamp(timestamp / 1000).astimezone()
|
||||
except (OverflowError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# Value exceeds the max. allowed timestamp
|
||||
# Try parsing as nanoseconds
|
||||
try:
|
||||
return datetime.utcfromtimestamp(timestamp / 1000000).astimezone()
|
||||
except (OverflowError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# Timestamp is out of range
|
||||
raise ValueError(f'{value} exceeds maximum value for a timestamp')
|
||||
|
@@ -14,6 +14,12 @@ from bookmarks.services import importer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
app_version = f.read().strip("\n")
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
pass
|
||||
|
||||
@login_required
|
||||
def general(request):
|
||||
@@ -30,6 +36,7 @@ def general(request):
|
||||
'form': form,
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
'app_version': app_version
|
||||
})
|
||||
|
||||
|
||||
|
@@ -12,5 +12,10 @@ python manage.py generate_secret_key
|
||||
# Ensure the DB folder is owned by the right user
|
||||
chown -R www-data: /etc/linkding/data
|
||||
|
||||
# Start background task processor using supervisord, unless explicitly disabled
|
||||
if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then
|
||||
supervisord -c supervisord.conf
|
||||
fi
|
||||
|
||||
# Start uwsgi server
|
||||
uwsgi uwsgi.ini
|
||||
|
@@ -25,11 +25,20 @@ All options need to be defined as environment variables in the environment that
|
||||
|
||||
## List of options
|
||||
|
||||
### `LD_DISABLE_BACKGROUND_TASKS`
|
||||
|
||||
Values: `True`, `False` | Default = `False`
|
||||
|
||||
Disables background tasks, such as creating snapshots for bookmarks on the web archive.
|
||||
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.
|
||||
|
||||
### `LD_DISABLE_URL_VALIDATION`
|
||||
|
||||
Values: `True`, `False` | Default = `False`
|
||||
|
||||
Completely disables URL validation for bookmarks. This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
|
||||
Completely disables URL validation for bookmarks.
|
||||
This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
|
||||
|
||||
### `LD_REQUEST_TIMEOUT`
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -4,6 +4,8 @@ certifi==2019.6.16
|
||||
charset-normalizer==2.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==3.2.6
|
||||
django-background-tasks==1.2.5
|
||||
django-compat==1.0.15
|
||||
django-generate-secret-key==1.0.2
|
||||
django-picklefield==3.0.1
|
||||
django-registration==3.2
|
||||
@@ -17,6 +19,8 @@ pytz==2021.1
|
||||
requests==2.26.0
|
||||
soupsieve==1.9.2
|
||||
sqlparse==0.4.1
|
||||
supervisor==4.2.2
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
uWSGI==2.0.18
|
||||
waybackpy==2.4.3
|
||||
|
@@ -6,6 +6,8 @@ confusable-homoglyphs==3.2.0
|
||||
coverage==5.5
|
||||
Django==3.2.6
|
||||
django-appconf==1.0.4
|
||||
django-background-tasks==1.2.5
|
||||
django-compat==1.0.15
|
||||
django-compressor==2.4.1
|
||||
django-debug-toolbar==3.2.1
|
||||
django-generate-secret-key==1.0.2
|
||||
@@ -27,3 +29,4 @@ soupsieve==1.9.2
|
||||
sqlparse==0.4.1
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
waybackpy==2.4.3
|
||||
|
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||
'django_generate_secret_key',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'background_task',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -166,3 +167,14 @@ ALLOW_REGISTRATION = False
|
||||
|
||||
# URL validation flag
|
||||
LD_DISABLE_URL_VALIDATION = os.getenv('LD_DISABLE_URL_VALIDATION', False) in (True, 'True', '1')
|
||||
|
||||
# Background task enabled setting
|
||||
LD_DISABLE_BACKGROUND_TASKS = os.getenv('LD_DISABLE_BACKGROUND_TASKS', False) in (True, 'True', '1')
|
||||
|
||||
# django-background-tasks
|
||||
MAX_ATTEMPTS = 5
|
||||
# How many tasks will run in parallel
|
||||
# We want to keep this low to prevent SQLite lock errors and in general not to consume too much resources on smaller
|
||||
# specced systems like Raspberries. Should be OK as tasks are not time critical.
|
||||
BACKGROUND_TASK_RUN_ASYNC = True
|
||||
BACKGROUND_TASK_ASYNC_THREADS = 2
|
||||
|
@@ -43,6 +43,11 @@ LOGGING = {
|
||||
'django.db.backends': {
|
||||
'level': 'ERROR', # Set to DEBUG to log all SQL calls
|
||||
'handlers': ['console'],
|
||||
},
|
||||
'bookmarks.services.tasks': { # Log task output
|
||||
'level': 'DEBUG',
|
||||
'handlers': ['console'],
|
||||
'propagate': False,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
10
supervisord.conf
Normal file
10
supervisord.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
[supervisord]
|
||||
user=root
|
||||
loglevel=info
|
||||
|
||||
[program:jobs]
|
||||
user=www-data
|
||||
command=sh background-tasks-wrapper.sh
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
@@ -1 +1 @@
|
||||
1.7.0
|
||||
1.8.0
|
||||
|
Reference in New Issue
Block a user