Compare commits

...

40 Commits

Author SHA1 Message Date
Sascha Ißbrücker
e7c55cd318 Bump version 2021-09-04 22:31:55 +02:00
Sascha Ißbrücker
d87dde6bae Create snapshots on web.archive.org for bookmarks (#150)
* Implement initial background tasks concept

* fix property reference

* update requirements.txt

* simplify bookmark null check

* improve web archive url display

* add background tasks test

* add basic supervisor setup

* schedule missing snapshot creation on login

* remove task locks and clear task history before starting background task processor

* batch create snapshots after import

* fix script reference in supervisord.conf

* add option to disable background tasks

* restructure feature overview
2021-09-04 22:31:04 +02:00
Sascha Ißbrücker
8d214649b7 Change Docker base image to slim-buster 2021-08-27 10:14:48 +02:00
Sascha Ißbrücker
dfb040bbb1 Update CHANGELOG.md 2021-08-26 12:44:06 +02:00
Sascha Ißbrücker
076c5d7658 Bump version 2021-08-26 12:40:03 +02:00
Sascha Ißbrücker
e47c00bd07 Add support for micro-, nanosecond timestamps in importer (#151) 2021-08-26 12:33:54 +02:00
Sascha Ißbrücker
55a0d189dd Update CHANGELOG.md 2021-08-25 12:36:38 +02:00
Sascha Ißbrücker
d39ce076ec Bump version 2021-08-25 10:25:22 +02:00
Chris Cesare
aa0258d3b6 remove duplicate word in README (#136) 2021-08-25 10:20:35 +02:00
Taku Izumi
937858cf58 Fix website scraper decoding content incorrectly (#126)
* Avoid stall on web scraping

This patch fixes stall on web scraping.
I encountered a stall (scraping never ends) when adding
a bookmark of some site.
To avoid this case, adding a timeout parameter at requests.get()
function is a solution.

Signed-off-by: Taku Izumi <admin@orz-style.com>

* Avoid character corruption of scraping some Japanese sites

This patch fixes character corruption of scraping some Japanese
sites. To avoid character corruption, I use r.content instead
of r.text in load_page function.

The reason of character corruption is encoding problem, I think.
r.text handles data as unicode encoded text, so if scraping
web site's charset is not unicode encoded, character corruption
occurs. r.content handles data as str[], we can avoid encoding
problem.

Signed-off-by: Taku Izumi <admin@orz-style.com>

* use charset_normalizer to determine response encoding

Co-authored-by: Taku Izumi <admin@orz-style.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-25 10:16:23 +02:00
Sascha Ißbrücker
8047ba6c63 Fix importer not validating bookmark models (#149) 2021-08-25 09:20:01 +02:00
Damanpreet Singh
de903bc341 Add about section in settings (#134)
* About section in settings

* Added about section in settings tab

* fix code style

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-24 19:47:58 +02:00
Sascha Ißbrücker
c8fcc426b0 Update CHANGELOG.md 2021-08-17 06:07:40 +02:00
Sascha Ißbrücker
eb915210d3 Update CHANGELOG.md 2021-08-17 06:02:07 +02:00
Sascha Ißbrücker
ad9a0f84f2 Bump version 2021-08-17 05:53:25 +02:00
Sascha Ißbrücker
cc04a17e2f Upgrade Django major (#144)
* Bump dependency versions

* Configure default auto field implementation

* fix admin to use token proxy model

* update django docs link
2021-08-17 05:48:45 +02:00
Thomas Bouve
69105d3d3c Support running container as an arbitrary user of the root group (#138)
* Support OpenShift containers.

* Improve comment wording

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-16 09:16:24 +02:00
Sascha Ißbrücker
c269d16855 Update CHANGELOG.md 2021-08-15 10:47:46 +02:00
Sascha Ißbrücker
90ee3cdb94 Update CHANGELOG.md 2021-08-15 10:46:23 +02:00
Sascha Ißbrücker
2c19266ef8 bump version 2021-08-15 09:52:25 +02:00
ulixxe
048a8b1162 improve tag query performance (#142)
* changed query on tag search for speedup related to issues #112 and #141

* fix tests and only conditionally append tag filter

* add bookmark tags query tests

* reuse bookmark queries for tag queries

* fix tag query test setup

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-15 09:28:40 +02:00
Sascha Ißbrücker
2fb0bb1224 Exclude tests from coverage 2021-05-14 23:45:32 +02:00
Sascha Ißbrücker
3e48b22095 Add settings view tests 2021-05-14 23:34:53 +02:00
Sascha Ißbrücker
9aa17d0528 Add code coverage script 2021-05-14 12:23:11 +02:00
Sascha Ißbrücker
d643fca98f Add query tests 2021-05-14 10:26:33 +02:00
Sascha Ißbrücker
f293fa15bc Update CI config to install Node dependencies 2021-05-14 02:38:44 +02:00
Sascha Ißbrücker
f58434077b Add bookmark view tests 2021-05-14 02:32:19 +02:00
Sascha Ißbrücker
59641e787c Update CHANGELOG.md 2021-05-13 20:09:53 +02:00
Sascha Ißbrücker
0d36a3bb86 Update CHANGELOG.md 2021-05-13 19:21:21 +02:00
Sascha Ißbrücker
b25f3d5529 Bump version 2021-05-13 18:29:46 +02:00
mattofr
24746deaae Admin documentation (#91)
* Started admin documentation

* Small correction

* Polish admin docs and reference from README.md

Co-authored-by: emacs <emacs@hp2530p.tradesystem.nl>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-05-13 18:28:25 +02:00
André Kelpe
e4a082231f Add how-to document (#102)
* adds how-to for using the bookmarklet on Android/Chrome

* Polish how-to doc and reference from README.md

* Add how-to for creating share action in Safari

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-05-13 17:52:13 +02:00
dependabot[bot]
5a380212d9 Bump django from 2.2.18 to 2.2.20 (#110)
Bumps [django](https://github.com/django/django) from 2.2.18 to 2.2.20.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/2.2.18...2.2.20)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:23:37 +02:00
dependabot[bot]
96068719cd Bump urllib3 from 1.25.3 to 1.25.8 (#119)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.25.3 to 1.25.8.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.25.3...1.25.8)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:21:27 +02:00
dependabot[bot]
e42d562750 Bump django-debug-toolbar from 3.2 to 3.2.1 (#115)
Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 3.2 to 3.2.1.
- [Release notes](https://github.com/jazzband/django-debug-toolbar/releases)
- [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst)
- [Commits](https://github.com/jazzband/django-debug-toolbar/compare/3.2...3.2.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:17:51 +02:00
dependabot[bot]
ff456b10ee Bump django-registration from 3.0.1 to 3.1.2 (#106)
Bumps [django-registration](https://github.com/ubernostrum/django-registration) from 3.0.1 to 3.1.2.
- [Release notes](https://github.com/ubernostrum/django-registration/releases)
- [Commits](https://github.com/ubernostrum/django-registration/compare/3.0.1...3.1.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:13:36 +02:00
Sascha Ißbrücker
3a05666680 Update CHANGELOG.md 2021-04-07 00:43:39 +02:00
Sascha Ißbrücker
dbe92b4b84 Bump version 2021-04-06 23:39:02 +02:00
Sascha Ißbrücker
90f62d3482 Fix relative date formatting (#107) 2021-04-06 23:38:15 +02:00
Sascha Ißbrücker
847f9644f4 Update CHANGELOG.md 2021-04-04 10:31:48 +02:00
69 changed files with 2184 additions and 124 deletions

3
.coveragerc Normal file
View File

@@ -0,0 +1,3 @@
[run]
source = bookmarks
omit = bookmarks/tests/*

View File

@@ -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

View File

@@ -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

View File

@@ -12,7 +12,13 @@ jobs:
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install dependencies
- name: Set up Node
uses: actions/setup-node@v2
with:
node-version: 14
- name: Install Python dependencies
run: pip install -r requirements.txt
- name: Install Node dependencies
run: npm install
- name: Run tests
run: python manage.py test

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

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

View File

@@ -1,5 +1,45 @@
# 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)
---
## v1.6.4 (13/05/2021)
- Update dependencies for security fixes
---
## v1.6.3 (07/04/2021)
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
---
## v1.6.2 (04/04/2021)
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
---
## v1.6.1 (31/03/2021)
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)

View File

@@ -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
@@ -47,6 +47,8 @@ EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
RUN ["chmod", "g+w", "."]
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"]

View File

@@ -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
@@ -97,10 +94,18 @@ The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedo
Check the [options document](docs/Options.md) on how to configure your linkding installation.
## Administration
Check the [administration document](docs/Admin.md) on how to use the admin app that is bundled with linkding.
## Backups
Check the [backups document](docs/backup.md) for options on how to create backups.
## How To
Check the [how-to document](docs/how-to.md) for tips and tricks around using linkding.
## API
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information.
@@ -118,7 +123,7 @@ Note that any proxy servers that you are running in front of linkding may have t
## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.2/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3

5
background-tasks-wrapper.sh Executable file
View 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

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.db.models import Count, QuerySet
from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -97,4 +97,4 @@ 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(Token, TokenAdmin)
linkding_admin_site.register(TokenProxy, TokenAdmin)

View File

@@ -3,3 +3,7 @@ from django.apps import AppConfig
class BookmarksConfig(AppConfig):
name = 'bookmarks'
def ready(self):
# Register signal handlers
import bookmarks.signals

View 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()

View File

@@ -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),
),
]

View File

@@ -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()

View File

@@ -62,43 +62,17 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=False) \
.distinct()
bookmarks_query = query_bookmarks(user, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=True) \
.distinct()
bookmarks_query = query_archived_bookmarks(user, query_string)
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet:
query_set = Tag.objects
# Filter for user
query_set = query_set.filter(owner=user)
# Only show tags which have bookmarks
query_set = query_set.filter(bookmark__isnull=False)
# Split query into search terms and tags
query = _parse_query_string(query_string)
# Filter for search terms and tags
for term in query['search_terms']:
query_set = query_set.filter(
Q(bookmark__title__contains=term)
| Q(bookmark__description__contains=term)
| Q(bookmark__website_title__contains=term)
| Q(bookmark__website_description__contains=term)
| Q(bookmark__url__contains=term)
)
for tag_name in query['tag_names']:
query_set = query_set.filter(
bookmark__tags__name__iexact=tag_name
)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()

View File

@@ -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

View File

@@ -1,12 +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__)
@@ -37,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
@@ -45,7 +50,10 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
bookmark.url = netscape_bookmark.href
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
if netscape_bookmark.date_added:
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
bookmark.unread = False
bookmark.title = netscape_bookmark.title
@@ -53,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

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass
from datetime import datetime
import pyparsing as pp
@@ -9,7 +8,7 @@ class NetscapeBookmark:
href: str
title: str
description: str
date_added: int
date_added: str
tag_string: str
@@ -17,8 +16,7 @@ def extract_bookmark_link(tag):
href = tag[0].href
title = tag[0].text
tag_string = tag[0].tags
date_added_string = tag[0].add_date if tag[0].add_date else datetime.now().timestamp()
date_added = int(date_added_string)
date_added = tag[0].add_date
return {
'href': href,

View 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)

View File

@@ -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
View 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)

View File

@@ -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;

View File

@@ -3,7 +3,7 @@
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
<li>
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
@@ -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 }}"

View File

@@ -7,13 +7,13 @@
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% append_query_param q=tag.name|hash_tag %}"
class="mr-2">
class="mr-2" data-is-tag-item>
<span class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_query_param q=tag.name|hash_tag %}"
class="mr-2">
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}

View File

@@ -17,7 +17,7 @@
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
token can access and manage all your bookmarks.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
</section>
</div>

View File

@@ -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 %}

View File

@@ -1,3 +1,6 @@
import random
import logging
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.crypto import get_random_string
@@ -16,16 +19,35 @@ class BookmarkFactoryMixin:
return self.user
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None):
def setup_bookmark(self,
is_archived: bool = False,
tags=None,
user: User = None,
url: str = '',
title: str = '',
description: str = '',
website_title: str = '',
website_description: str = '',
web_archive_snapshot_url: str = '',
):
if tags is None:
tags = []
if user is None:
user = self.get_or_create_test_user()
unique_id = get_random_string(length=32)
if not url:
unique_id = get_random_string(length=32)
url = 'https://example.com/' + unique_id
bookmark = Bookmark(
url='https://example.com/' + unique_id,
url=url,
title=title,
description=description,
website_title=website_title,
website_description=website_description,
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:
@@ -33,10 +55,11 @@ class BookmarkFactoryMixin:
bookmark.save()
return bookmark
def setup_tag(self, user: User = None):
def setup_tag(self, user: User = None, name: str = ''):
if user is None:
user = self.get_or_create_test_user()
name = get_random_string(length=32)
if not name:
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=user)
tag.save()
return tag
@@ -62,3 +85,49 @@ class LinkdingApiTestCase(APITestCase):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)
return response
_words = [
'quasi',
'consequatur',
'necessitatibus',
'debitis',
'quod',
'vero',
'qui',
'commodi',
'quod',
'odio',
'aliquam',
'veniam',
'architecto',
'consequatur',
'autem',
'qui',
'iste',
'asperiores',
'soluta',
'et',
]
def random_sentence(num_words: int = None, including_word: str = ''):
if num_words is None:
num_words = random.randint(5, 10)
selected_words = random.choices(_words, k=num_words)
if including_word:
selected_words.append(including_word)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,20 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<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="1616337559000" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
<DD>test description 2
<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>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="https://example.com/1" ADD_DATE="invaliddate" 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>
<DD>test description 2
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
<DD>test description 3
</DL><p>

View File

@@ -0,0 +1,35 @@
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'))

View File

@@ -0,0 +1,132 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html
)
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html,
count=0
)
def assertVisibleTags(self, response, tags: [Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=False),
self.setup_bookmark(is_archived=True, user=other_user),
]
response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=False, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]], user=other_user)
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue'),
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)

View File

@@ -6,7 +6,7 @@ from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin):
class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import build_tag_string
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
'url': 'http://example.com/edited',
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'return_url': reverse('bookmarks:index'),
}
return {**form_data, **overrides}
def test_should_edit_bookmark(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertEqual(bookmark.owner, self.user)
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
def test_should_use_bookmark_index_as_default_return_url(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML(
'<input type="hidden" name="return_url" value="{0}" '
'id="id_return_url">'.format(reverse('bookmarks:index')),
html)
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML('<input type="text" name="url" '
'value="{0}" placeholder=" " '
'autofocus class="form-input" required '
'id="id_url">'.format(bookmark.url),
html)
tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML('<input type="text" name="tag_string" '
'value="{0}" autocomplete="off" '
'class="form-input" '
'id="id_tag_string">'.format(tag_string),
html)
self.assertInHTML('<input type="text" name="title" maxlength="512" '
'autocomplete="off" class="form-input" '
'value="{0}" id="id_title">'.format(bookmark.title),
html)
self.assertInHTML('<textarea name="description" cols="40" rows="4" class="form-input" id="id_description">{0}'
'</textarea>'.format(bookmark.description),
html)
def test_should_prefill_return_url_from_url_parameter(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]) + '?return_url=/test-return-url')
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="return_url" value="/test-return-url" id="id_return_url">', html)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'return_url': reverse('bookmarks:close')})
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
self.assertRedirects(response, form_data['return_url'])

View File

@@ -0,0 +1,132 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html
)
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html,
count=0
)
def assertVisibleTags(self, response, tags: [Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(user=other_user),
]
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[invisible_tags[1]], is_archived=True)
self.setup_bookmark(tags=[invisible_tags[2]], user=other_user)
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue'),
self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(tags=[invisible_tags[0]])
self.setup_bookmark(tags=[invisible_tags[1]])
self.setup_bookmark(tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)

View File

@@ -0,0 +1,81 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
'url': 'http://example.com',
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'auto_close': '',
}
return {**form_data, **overrides}
def test_should_create_new_bookmark(self):
form_data = self.create_form_data()
self.client.post(reverse('bookmarks:new'), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
self.assertEqual(bookmark.owner, self.user)
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="url" value="http://example.com" '
'placeholder=" " autofocus class="form-input" required '
'id="id_url">',
html)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(
reverse('bookmarks:new') + '?auto_close')
html = response.content.decode()
self.assertInHTML(
'<input type="hidden" name="auto_close" value="true" '
'id="id_auto_close">',
html)
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(
self):
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">',html)
def test_should_redirect_to_index_view(self):
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:index'))
def test_auto_close_should_redirect_to_close_view(self):
form_data = self.create_form_data({'auto_close': 'true'})
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:close'))

View File

@@ -0,0 +1,35 @@
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'))

View File

@@ -0,0 +1,35 @@
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'))

View File

@@ -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)

View File

@@ -4,8 +4,8 @@ from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.utils import timezone, formats
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
@@ -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)

View File

@@ -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(

View 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)

View 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)

View File

@@ -1,6 +1,6 @@
from django.core.paginator import Paginator
from django.test import SimpleTestCase, RequestFactory
from django.template import Template, RequestContext
from django.test import SimpleTestCase, RequestFactory
class PaginationTagTest(SimpleTestCase):

View File

@@ -1,14 +1,223 @@
import operator
from django.contrib.auth import get_user_model
from django.db.models import QuerySet
from django.test import TestCase
from bookmarks import queries
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique
User = get_user_model()
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def setup_bookmark_search_data(self) -> None:
tag1 = self.setup_tag(name='tag1')
tag2 = self.setup_tag(name='tag2')
self.setup_tag(name='unused_tag1')
self.other_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark(),
]
self.term1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1'),
self.setup_bookmark(title=random_sentence(including_word='term1')),
self.setup_bookmark(description=random_sentence(including_word='term1')),
self.setup_bookmark(website_title=random_sentence(including_word='term1')),
self.setup_bookmark(website_description=random_sentence(including_word='term1')),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url='http://example.com/term1/term2'),
self.setup_bookmark(title=random_sentence(including_word='term1'),
description=random_sentence(including_word='term2')),
self.setup_bookmark(description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2')),
self.setup_bookmark(website_title=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2')),
self.setup_bookmark(website_description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2')),
]
self.tag1_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(title=random_sentence(), tags=[tag1]),
self.setup_bookmark(description=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_title=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1]),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[tag1]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[tag1]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[tag1]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[tag1]),
]
self.tag2_bookmarks = [
self.setup_bookmark(tags=[tag2]),
]
self.tag1_tag2_bookmarks = [
self.setup_bookmark(tags=[tag1, tag2]),
]
def setup_tag_search_data(self):
tag1 = self.setup_tag(name='tag1')
tag2 = self.setup_tag(name='tag2')
self.setup_tag(name='unused_tag1')
self.other_bookmarks = [
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
]
self.term1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url='http://example.com/term1/term2', tags=[self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'),
description=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
]
self.tag1_bookmarks = [
self.setup_bookmark(tags=[tag1, self.setup_tag()]),
self.setup_bookmark(title=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(description=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1, self.setup_tag()]),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[tag1, self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'),
tags=[tag1, self.setup_tag()]),
]
self.tag2_bookmarks = [
self.setup_bookmark(tags=[tag2, self.setup_tag()]),
]
self.tag1_tag2_bookmarks = [
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
]
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())
return all_tags
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
expected_items = []
for item_list in item_lists:
expected_items = expected_items + item_list
expected_items = unique(expected_items, operator.attrgetter('id'))
self.assertCountEqual(list(query), expected_items)
def test_query_bookmarks_should_return_all_for_empty_query(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
self.assertQueryResult(query, [
self.other_bookmarks,
self.term1_bookmarks,
self.term1_term2_bookmarks,
self.tag1_bookmarks,
self.term1_tag1_bookmarks,
self.tag2_bookmarks,
self.tag1_tag2_bookmarks
])
def test_query_bookmarks_should_search_single_term(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1')
self.assertQueryResult(query, [
self.term1_bookmarks,
self.term1_term2_bookmarks,
self.term1_tag1_bookmarks
])
def test_query_bookmarks_should_search_multiple_terms(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term2 term1')
self.assertQueryResult(query, [self.term1_term2_bookmarks])
def test_query_bookmarks_should_search_single_tag(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1')
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
def test_query_bookmarks_should_search_multiple_tags(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #tag2')
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#Tag1 #TAG2')
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_terms_and_tags_combined(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag1')
self.assertQueryResult(query, [self.term1_tag1_bookmarks])
def test_query_bookmarks_should_return_no_matches(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term3')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 term3')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag2')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag3')
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmarks(self.get_or_create_test_user(), '#unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #unused_tag1')
self.assertQueryResult(query, [])
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
@@ -18,7 +227,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
self.assertQueryResult(query, [[bookmark1, bookmark2]])
def test_query_archived_bookmarks_should_not_return_unarchived_bookmarks(self):
bookmark1 = self.setup_bookmark(is_archived=True)
@@ -29,7 +238,156 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
self.assertQueryResult(query, [[bookmark1, bookmark2]])
def test_query_bookmarks_should_only_return_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
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)
query = queries.query_bookmarks(self.user, '')
self.assertQueryResult(query, [owned_bookmarks])
def test_query_archived_bookmarks_should_only_return_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
]
self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user)
query = queries.query_archived_bookmarks(self.user, '')
self.assertQueryResult(query, [owned_bookmarks])
def test_query_bookmarks_should_use_tag_projection(self):
self.setup_bookmark_search_data()
# Test projection on bookmarks with tags
query = queries.query_bookmarks(self.user, '#tag1 #tag2')
for bookmark in query:
self.assertEqual(bookmark.tag_count, 2)
self.assertEqual(bookmark.tag_string, 'tag1,tag2')
self.assertTrue(bookmark.tag_projection)
# Test projection on bookmarks without tags
query = queries.query_bookmarks(self.user, 'term2')
for bookmark in query:
self.assertEqual(bookmark.tag_count, 0)
self.assertEqual(bookmark.tag_string, None)
self.assertTrue(bookmark.tag_projection)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.other_bookmarks),
self.get_tags_from_bookmarks(self.term1_bookmarks),
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag2_bookmarks),
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_single_term(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_bookmarks),
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
def test_query_bookmark_tags_should_search_multiple_terms(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term2 term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
])
def test_query_bookmark_tags_should_search_single_tag(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_multiple_tags(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#tag1 #tag2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#Tag1 #TAG2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term1 #tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
def test_query_bookmark_tags_should_return_no_matches(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term3')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 term3')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #tag2')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag3')
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag1 #unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #unused_tag1')
self.assertQueryResult(query, [])
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
tag1 = self.setup_tag()
@@ -40,7 +398,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag1], list(query))
self.assertQueryResult(query, [[tag1]])
def test_query_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
@@ -50,7 +408,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag], list(query))
self.assertQueryResult(query, [[tag]])
def test_query_archived_bookmark_tags_should_return_tags_for_archived_bookmarks_only(self):
tag1 = self.setup_tag()
@@ -61,7 +419,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag2], list(query))
self.assertQueryResult(query, [[tag2]])
def test_query_archived_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
@@ -71,4 +429,34 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag], list(query))
self.assertQueryResult(query, [[tag]])
def test_query_bookmark_tags_should_only_return_user_owned_tags(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
]
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_bookmark_tags(self.user, '')
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
def test_query_archived_bookmark_tags_should_only_return_user_owned_tags(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),
]
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])

View File

@@ -0,0 +1,40 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SettingsApiViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.api'))
self.assertEqual(response.status_code, 200)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.api'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.api'))
def test_should_generate_api_token_if_not_exists(self):
self.assertEqual(Token.objects.count(), 0)
self.client.get(reverse('bookmarks:settings.api'))
self.assertEqual(Token.objects.count(), 1)
token = Token.objects.first()
self.assertEqual(token.user, self.user)
def test_should_not_generate_api_token_if_exists(self):
Token.objects.get_or_create(user=self.user)
self.assertEqual(Token.objects.count(), 1)
self.client.get(reverse('bookmarks:settings.api'))
self.assertEqual(Token.objects.count(), 1)

View File

@@ -0,0 +1,45 @@
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertFormErrorHint(self, response, text: str):
self.assertContains(response, '<div class="has-error">')
self.assertContains(response, text)
def test_should_export_successfully(self):
self.setup_bookmark(tags=[self.setup_tag()])
self.setup_bookmark(tags=[self.setup_tag()])
self.setup_bookmark(tags=[self.setup_tag()])
response = self.client.get(
reverse('bookmarks:settings.export'),
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['content-type'], 'text/plain; charset=UTF-8')
self.assertEqual(response['Content-Disposition'], 'attachment; filename="bookmarks.html"')
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.export'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.export'))
def test_should_show_hint_when_export_raises_error(self):
with patch('bookmarks.services.exporter.export_netscape_html') as mock_export_netscape_html:
mock_export_netscape_html.side_effect = Exception('Nope')
response = self.client.get(reverse('bookmarks:settings.export'), follow=True)
self.assertTemplateUsed(response, 'settings/general.html')
self.assertFormErrorHint(response, 'An error occurred during bookmark export.')

View File

@@ -0,0 +1,36 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.general'))
self.assertEqual(response.status_code, 200)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.general'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
def test_should_save_profile(self):
form_data = {
'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
self.user.profile.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.profile.theme, form_data['theme'])
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])

View File

@@ -0,0 +1,79 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertFormSuccessHint(self, response, text: str):
self.assertContains(response, '<div class="has-success">')
self.assertContains(response, text)
def assertNoFormSuccessHint(self, response):
self.assertNotContains(response, '<div class="has-success">')
def assertFormErrorHint(self, response, text: str):
self.assertContains(response, '<div class="has-error">')
self.assertContains(response, text)
def assertNoFormErrorHint(self, response):
self.assertNotContains(response, '<div class="has-error">')
def test_should_import_successfully(self):
with open('bookmarks/tests/resources/simple_valid_import_file.html') as import_file:
response = self.client.post(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertFormSuccessHint(response, '3 bookmarks were successfully imported')
self.assertNoFormErrorHint(response)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.import'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.import'))
def test_should_show_hint_if_there_is_no_file(self):
response = self.client.post(
reverse('bookmarks:settings.import'),
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
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(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
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(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertFormSuccessHint(response, '2 bookmarks were successfully imported')
self.assertFormErrorHint(response, '1 bookmarks could not be imported')

View File

@@ -0,0 +1,22 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.integrations'))
self.assertEqual(response.status_code, 200)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.integrations'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.integrations'))

View 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)

View File

@@ -1,4 +1,5 @@
import datetime
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone

View File

@@ -1,5 +1,5 @@
from django.test import TestCase
from django.contrib.auth.models import User
from django.test import TestCase
from bookmarks.models import UserProfile

View File

@@ -1,7 +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):
@@ -23,6 +26,14 @@ class UtilsTestCase(TestCase):
result = humanize_absolute_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_absolute_date_should_use_current_date_as_default(self):
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 1)), 'Today')
# 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_absolute_date(timezone.datetime(2021, 1, 13)), 'Today')
def test_humanize_relative_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 1, 1), '1 year ago'),
@@ -45,3 +56,53 @@ class UtilsTestCase(TestCase):
for test_case in test_cases:
result = humanize_relative_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_relative_date_should_use_current_date_as_default(self):
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 1)), 'Today')
# 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)

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Optional
from dateutil.relativedelta import relativedelta
from django.template.defaultfilters import pluralize
@@ -20,7 +21,9 @@ weekday_names = {
}
def humanize_absolute_date(value: datetime, now=timezone.now()):
def humanize_absolute_date(value: datetime, now: Optional[datetime] = None):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
yesterday = now - relativedelta(days=1)
@@ -36,7 +39,9 @@ def humanize_absolute_date(value: datetime, now=timezone.now()):
return weekday_names[value.isoweekday()]
def humanize_relative_date(value: datetime, now: datetime = timezone.now()):
def humanize_relative_date(value: datetime, now: Optional[datetime] = None):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
if delta.years > 0:
@@ -53,3 +58,40 @@ def humanize_relative_date(value: datetime, now: datetime = timezone.now()):
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')

View File

@@ -9,11 +9,17 @@ from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm
from bookmarks.queries import query_bookmarks
from bookmarks.services.exporter import export_netscape_html
from bookmarks.services.importer import import_netscape_html
from bookmarks.services import exporter
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
})
@@ -55,11 +62,11 @@ def bookmark_import(request):
if import_file is None:
messages.error(request, 'Please select a file to import.', 'bookmark_import_errors')
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
try:
content = import_file.read().decode()
result = import_netscape_html(content, request.user)
result = importer.import_netscape_html(content, request.user)
success_msg = str(result.success) + ' bookmarks were successfully imported.'
messages.success(request, success_msg, 'bookmark_import_success')
if result.failed > 0:
@@ -78,7 +85,7 @@ def bookmark_export(request):
# noinspection PyBroadException
try:
bookmarks = query_bookmarks(request.user, '')
file_content = export_netscape_html(bookmarks)
file_content = exporter.export_netscape_html(bookmarks)
response = HttpResponse(content_type='text/plain; charset=UTF-8')
response['Content-Disposition'] = 'attachment; filename="bookmarks.html"'

View File

@@ -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

5
coverage.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
coverage erase
coverage run manage.py test
coverage report --sort=cover

65
docs/Admin.md Normal file
View File

@@ -0,0 +1,65 @@
# Administration
This document describes how to make use of the admin app that comes as part of each linkding installation. This is the default Django admin app with some linkding specific customizations.
The admin app provides several features that are not available in the linkding UI:
- User management and user self-management
- Bookmark and tag management, including bulk operations
## Linkding administration page
To open the Admin app, go the *Settings* view and click on the *Admin* tab. This should open a new window with the admin app.
Alternatively you can open the URL directly by adding `/admin` to the URL of your linkding installation.
## User management
Go to the linkding administration page and select *Users*.
Here you can add and delete users, and change the password of a user.
Once you have added a user you can, if needed, give the user staff status, which means this user can also access the linkding administration page.
This page also allows you to change your own password if necessary.
## Bookmark management
While the linkding UI itself now has a bulk edit feature for bookmarks you can also use the admin app to manage bookmarks or to do bulk operations.
In the main linkding administration page, choose *Bookmarks*.
First select the bookmarks to operate on:
- Specify a filter to determine which bookmarks to operate on:
- In the column *by username*, you can choose to filter for bookmarks for a specific user
- In the column *by is archived*, you can choose to filter for bookmarks that are either archived or not
- In the column *by tags*, you can choose to filter for specific tags
- In the search box you can also add a text filter (note that this doesn't use the same search syntax as the linkding UI itself)
Now a list of bookmarks which match your filter is displayed, each bookmark on a separate line.
Each line starts with a checkbox.
Either choose the individual bookmarks you want to do a bulk operation on, or choose the top checkbox to select all shown bookmarks.
Open the "Action" select box to choose the desired bulk operation:
- Delete
- Archive
- Unarchive
Click the button next to the checkbox to execute the operation.
## Tag management
While linkding UI currently only allows to create or assign tags, you can use the admin app to manage your tags. This can be especially useful if you want to clean up your tag collection.
In the main linkding administration page, choose *Tags*.
Similar to bookmarks management described above you can now specify which tags to operate on by specifying a filter and then selecting the individual tags.
Open the "Action" select box to choose the desired bulk operation:
- Delete
- Delete unused tags - this will only delete the selected tags that are currently not assigned to any bookmark
Click the button next to the checkbox to execute the operation.
Note that deleting a tag does not affect the bookmarks that are tagged with this tag, it only removes the tag from those bookmarks.

View File

@@ -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`

46
docs/how-to.md Normal file
View File

@@ -0,0 +1,46 @@
# How To
Collection of tips and tricks around using linkding.
## Using the bookmarklet on Android/Chrome
This how-to explains the usage of the standard linkding bookmarklet on Android / Chrome.
Chrome on Android does not permit running bookmarklets in the same way you can on a desktop system. There is however a workaround that is explained here.
**Note** that this only works with Chrome and not with other browsers on Android.
Create a bookmark of your linkding deployment by clicking the star icon which you find in the three dots menu in the top right. Next you have to edit the bookmark. Edit the URL and replace it it with the bookmarklet code of your instance and give it an easy to type name like `bm` for bookmark or `ld` for linkding:
```
javascript: (function() { var bookmarkUrl = window.location; var applicationUrl = 'http://<YOUR_INSTANCE_HERE>/bookmarks/new'; applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '&auto_close'; window.open(applicationUrl);})();
```
Now when you are browsing the web and you want to save the current page as a bookmark to your linkding instance simply type `bm` into the address bar and select it from the results. The bookmarklet code will trigger and you will be redirected so save the current page.
For more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-android/
## Create a share action on iOS for adding bookmarks to linkding
This how-to explains how to make use of the app shortcuts iOS app to create a share action that can be used in Safari for adding bookmarks to your linkding instance.
**In the shortcuts app:**
- create new shortcut
- go to shortcut details, enable to option to show the shortcut in share menu
- from the available share input types only select "URL"
- add Safari action "Display website in Safari" (paraphrasing, not sure how it's called in english)
- for URL enter your linkding instance URL and specifically point to the new bookmark form, then add the shortcut input variable from the list of suggested variables after the URL parameter. Visually it should look something like this: `https://linkding.mydomain.com/bookmarks/new?url=[Shortcut input]`, where `[Shortcut input]` is a visual block that was inserted after selecting the shortcut input variable suggestion. This is basically a placeholder that will get replaced with the actual URL that you want to bookmark. See screenshot at the end for an example on how this looks.
- save, give the shortcut a nice name + glyph
Example of how the shortcut configuration should look:
![Screenshot](/docs/ios-app-shortcut-example.png?raw=true "Screenshot demonstrating how to insert the input placeholder into the URL")
**Using the share action from Safari:**
- browse to the website that you want to share
- click the share button
- your new app shortcut should now be available as share action
- select the app shortcut
- this should open a new Safari overlay showing the add bookmark form with the URL field prefilled
- after saving the bookmark you can close the overlay and continue browsing

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

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

View File

@@ -1,20 +1,26 @@
asgiref==3.4.1
beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
Django==2.2.18
Django==3.2.6
django-background-tasks==1.2.5
django-compat==1.0.15
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.0.1
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.2
django-picklefield==3.0.1
django-registration==3.2
django-sass-processor==1.0.1
django-widget-tweaks==1.4.8
djangorestframework==3.12.4
idna==2.8
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
requests==2.22.0
pytz==2021.1
requests==2.26.0
soupsieve==1.9.2
sqlparse==0.3.0
urllib3==1.25.3
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

View File

@@ -1,26 +1,32 @@
asgiref==3.4.1
beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
Django==2.2.18
django-appconf==1.0.3
django-compressor==2.3
django-debug-toolbar==3.2
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
django-picklefield==2.0
django-registration==3.0.1
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.2
django-picklefield==3.0.1
django-registration==3.2
django-sass-processor==1.0.1
django-widget-tweaks==1.4.8
djangorestframework==3.12.4
idna==2.8
libsass==0.19.2
libsass==0.21.0
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
pytz==2021.1
rcssmin==1.0.6
requests==2.22.0
requests==2.26.0
rjsmin==1.1.0
six==1.12.0
six==1.16.0
soupsieve==1.9.2
sqlparse==0.3.0
urllib3==1.25.3
sqlparse==0.4.1
typing-extensions==3.10.0.0
urllib3==1.26.6
waybackpy==2.4.3

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = [
'django_generate_secret_key',
'rest_framework',
'rest_framework.authtoken',
'background_task',
]
MIDDLEWARE = [
@@ -73,6 +74,8 @@ TEMPLATES = [
},
]
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
WSGI_APPLICATION = 'siteroot.wsgi.application'
# Database
@@ -164,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

View File

@@ -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
View 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

View File

@@ -1 +1 @@
1.6.2
1.8.0