mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
74 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
14e4950fec | ||
![]() |
f92c3dd403 | ||
![]() |
56173aea3f | ||
![]() |
c97d5c3dc5 | ||
![]() |
6dd07edf90 | ||
![]() |
1274d6dd4f | ||
![]() |
6cf35ecca6 | ||
![]() |
c5c527400c | ||
![]() |
dc0a4e33bd | ||
![]() |
ca5dcb882c | ||
![]() |
04649e0901 | ||
![]() |
a85f1cfe83 | ||
![]() |
3906d9e5b8 | ||
![]() |
eca98a13f5 | ||
![]() |
10e5861f01 | ||
![]() |
f68c67e272 | ||
![]() |
e2a52b9cba | ||
![]() |
4ad2d2111a | ||
![]() |
c16e87f9c7 | ||
![]() |
673466ab28 | ||
![]() |
13f27f5412 | ||
![]() |
530c4b74c4 | ||
![]() |
3eb8cfe45e | ||
![]() |
f5b07eebba | ||
![]() |
3ba8f7e30b | ||
![]() |
9a63c367a8 | ||
![]() |
edb71286e7 | ||
![]() |
1ffc3e0266 | ||
![]() |
66995cfab2 | ||
![]() |
68143de992 | ||
![]() |
b93a9fadb6 | ||
![]() |
77fea02f77 | ||
![]() |
fcc0b6f591 | ||
![]() |
e1c9a7add6 | ||
![]() |
82b4268a26 | ||
![]() |
5287eb3f8b | ||
![]() |
d298260122 | ||
![]() |
12e5810aee | ||
![]() |
1dabd0266b | ||
![]() |
7390fc3f4f | ||
![]() |
5e003ede92 | ||
![]() |
984eef92e2 | ||
![]() |
eae6ca6e07 | ||
![]() |
a6bfaa7c78 | ||
![]() |
7aa1630be2 | ||
![]() |
4f9fcb41bd | ||
![]() |
da4a81305a | ||
![]() |
df33144dd0 | ||
![]() |
123fa54d5a | ||
![]() |
2ab4aa5566 | ||
![]() |
d4cba7d5fa | ||
![]() |
3d8fd66e50 | ||
![]() |
3ff7a5ba91 | ||
![]() |
88c109c9a4 | ||
![]() |
a1d5ff6532 | ||
![]() |
e7c55cd318 | ||
![]() |
d87dde6bae | ||
![]() |
8d214649b7 | ||
![]() |
dfb040bbb1 | ||
![]() |
076c5d7658 | ||
![]() |
e47c00bd07 | ||
![]() |
55a0d189dd | ||
![]() |
d39ce076ec | ||
![]() |
aa0258d3b6 | ||
![]() |
937858cf58 | ||
![]() |
8047ba6c63 | ||
![]() |
de903bc341 | ||
![]() |
c8fcc426b0 | ||
![]() |
eb915210d3 | ||
![]() |
ad9a0f84f2 | ||
![]() |
cc04a17e2f | ||
![]() |
69105d3d3c | ||
![]() |
c269d16855 | ||
![]() |
90ee3cdb94 |
@@ -7,6 +7,8 @@
|
||||
/docs
|
||||
/static
|
||||
/build
|
||||
/out
|
||||
/.git
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
@@ -17,10 +19,13 @@
|
||||
/*.patch
|
||||
/*.md
|
||||
/*.js
|
||||
/*.log
|
||||
/*.pid
|
||||
|
||||
# Whitelist files needed in build or prod image
|
||||
!/rollup.config.js
|
||||
!/bootstrap.sh
|
||||
!/background-tasks-wrapper.sh
|
||||
|
||||
# Remove development settings
|
||||
/siteroot/settings/dev.py
|
||||
|
@@ -5,5 +5,7 @@ LD_HOST_PORT=9090
|
||||
# Directory on the host system that should be mounted as data dir into the Docker container
|
||||
LD_HOST_DATA_DIR=./data
|
||||
|
||||
# Option to disable background tasks
|
||||
LD_DISABLE_BACKGROUND_TASKS=False
|
||||
# Option to disable URL validation for bookmarks completely
|
||||
LD_DISABLE_URL_VALIDATION=False
|
||||
LD_DISABLE_URL_VALIDATION=False
|
||||
|
45
.gitignore
vendored
45
.gitignore
vendored
@@ -3,55 +3,14 @@
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# CMake
|
||||
cmake-build-debug/
|
||||
cmake-build-release/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
.idea
|
||||
*.iml
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
15
.idea/compiler.xml
generated
15
.idea/compiler.xml
generated
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<wildcardResourcePatterns>
|
||||
<entry name="!?*.java" />
|
||||
<entry name="!?*.form" />
|
||||
<entry name="!?*.class" />
|
||||
<entry name="!?*.groovy" />
|
||||
<entry name="!?*.scala" />
|
||||
<entry name="!?*.flex" />
|
||||
<entry name="!?*.kt" />
|
||||
<entry name="!?*.clj" />
|
||||
</wildcardResourcePatterns>
|
||||
</component>
|
||||
</project>
|
11
.idea/dataSources.xml
generated
11
.idea/dataSources.xml
generated
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="SQLite - db.sqlite3" uuid="c880bd6d-554c-484d-a5be-45581d9a9377">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/db.sqlite3</jdbc-url>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
4
.idea/encodings.xml
generated
4
.idea/encodings.xml
generated
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
||||
</project>
|
12
.idea/inspectionProfiles/Project_Default.xml
generated
12
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,12 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredErrors">
|
||||
<list>
|
||||
<option value="E402" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
13
.idea/misc.xml
generated
13
.idea/misc.xml
generated
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="HaskellBuildOptions">
|
||||
<ghcPath>/usr/local/bin/ghc</ghcPath>
|
||||
<stackPath>/usr/local/bin/stack</stackPath>
|
||||
</component>
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="ES6" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" default="false" project-jdk-name="Python 3.7 (linkding)" project-jdk-type="Python SDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/linkdings.iml" filepath="$PROJECT_DIR$/linkdings.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
4
.idea/watcherTasks.xml
generated
4
.idea/watcherTasks.xml
generated
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
|
||||
</project>
|
74
CHANGELOG.md
74
CHANGELOG.md
@@ -1,5 +1,79 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.8 (27/03/2022)
|
||||
- [**bug**] Prevent bookmark actions through get requests
|
||||
- [**bug**] Prevent external redirects
|
||||
|
||||
---
|
||||
|
||||
## v1.8.7 (26/03/2022)
|
||||
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
|
||||
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
|
||||
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
|
||||
---
|
||||
|
||||
## v1.8.6 (25/03/2022)
|
||||
- [bug] fix bookmark access restrictions
|
||||
- [bug] prevent external redirects
|
||||
- [chore] bump dependencies
|
||||
---
|
||||
|
||||
## v1.8.5 (13/12/2021)
|
||||
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
|
||||
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)
|
||||
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
|
||||
|
||||
---
|
||||
|
||||
## v1.8.4 (16/10/2021)
|
||||
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
|
||||
|
||||
---
|
||||
|
||||
## v1.8.3 (03/10/2021)
|
||||
- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)
|
||||
|
||||
---
|
||||
|
||||
## v1.8.2 (02/10/2021)
|
||||
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
|
||||
---
|
||||
|
||||
## v1.8.1 (01/10/2021)
|
||||
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
|
||||
- allows to press `s` to focus the search input
|
||||
|
||||
---
|
||||
|
||||
## v1.8.0 (04/09/2021)
|
||||
- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)
|
||||
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
|
||||
- This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
|
@@ -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"]
|
||||
|
19
README.md
19
README.md
@@ -13,17 +13,14 @@ The name comes from:
|
||||
- Search by text or tags
|
||||
- Bulk editing
|
||||
- Bookmark archive
|
||||
- Automatically provides titles and descriptions from linked websites
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
- Bookmarklet that should work in most browsers
|
||||
- Dark mode
|
||||
- Easy to set up using Docker
|
||||
- Uses SQLite as database
|
||||
- Works without Javascript
|
||||
- ...but has several UI enhancements when Javascript is enabled
|
||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
||||
- Automatically provides titles and descriptions of bookmarked websites
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), and a bookmarklet that should work in most browsers
|
||||
- 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
|
||||
@@ -126,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
|
||||
@@ -171,4 +168,6 @@ The frontend is now available under http://localhost:8000
|
||||
|
||||
## Community
|
||||
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||
|
13
SECURITY.md
Normal file
13
SECURITY.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.8.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com
|
||||
|
||||
I'll try to get back to you as soon as possible.
|
5
background-tasks-wrapper.sh
Executable file
5
background-tasks-wrapper.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
|
||||
|
||||
python manage.py clean_tasks
|
||||
exec python manage.py process_tasks
|
@@ -5,9 +5,9 @@ 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.models import Bookmark, Tag, UserProfile, Toast
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ class AdminTag(admin.ModelAdmin):
|
||||
def bookmarks_count(self, obj):
|
||||
return obj.bookmarks_count
|
||||
|
||||
bookmarks_count.admin_order_field = 'bookmarks_count'
|
||||
|
||||
def delete_unused_tags(self, request, queryset: QuerySet):
|
||||
unused_tags = queryset.filter(bookmark__isnull=True)
|
||||
unused_tags_count = unused_tags.count()
|
||||
@@ -93,8 +95,15 @@ class AdminCustomUser(UserAdmin):
|
||||
return super(AdminCustomUser, self).get_inline_instances(request, obj)
|
||||
|
||||
|
||||
class AdminToast(admin.ModelAdmin):
|
||||
list_display = ('key', 'message', 'owner', 'acknowledged')
|
||||
search_fields = ('key', 'message')
|
||||
list_filter = ('owner__username',)
|
||||
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(Token, TokenAdmin)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
|
@@ -41,14 +41,14 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
bookmark.url = validated_data['url']
|
||||
bookmark.title = validated_data['title']
|
||||
bookmark.description = validated_data['description']
|
||||
tag_string = build_tag_string(validated_data['tag_names'], ' ')
|
||||
tag_string = build_tag_string(validated_data['tag_names'])
|
||||
return create_bookmark(bookmark, tag_string, self.context['user'])
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
instance.url = validated_data['url']
|
||||
instance.title = validated_data['title']
|
||||
instance.description = validated_data['description']
|
||||
tag_string = build_tag_string(validated_data['tag_names'], ' ')
|
||||
tag_string = build_tag_string(validated_data['tag_names'])
|
||||
return update_bookmark(instance, tag_string, self.context['user'])
|
||||
|
||||
|
||||
|
@@ -3,3 +3,7 @@ from django.apps import AppConfig
|
||||
|
||||
class BookmarksConfig(AppConfig):
|
||||
name = 'bookmarks'
|
||||
|
||||
def ready(self):
|
||||
# Register signal handlers
|
||||
import bookmarks.signals
|
||||
|
@@ -11,6 +11,7 @@
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
let suggestionList = null;
|
||||
|
||||
let suggestions = [];
|
||||
let selectedIndex = 0;
|
||||
@@ -86,7 +87,7 @@
|
||||
function complete(suggestion) {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end);
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||
|
||||
close();
|
||||
}
|
||||
@@ -100,6 +101,16 @@
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
selectedIndex = newIndex;
|
||||
|
||||
// Scroll to selected list item
|
||||
setTimeout(() => {
|
||||
if (suggestionList) {
|
||||
const selectedListItem = suggestionList.querySelector('li.selected');
|
||||
if (selectedListItem) {
|
||||
selectedListItem.scrollIntoView({block: 'center'});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -114,7 +125,8 @@
|
||||
</div>
|
||||
|
||||
<!-- autocomplete suggestion list -->
|
||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}>
|
||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
||||
bind:this={suggestionList}>
|
||||
<!-- menu list items -->
|
||||
{#each suggestions as tag,i}
|
||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||
|
12
bookmarks/context_processors.py
Normal file
12
bookmarks/context_processors.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from bookmarks.models import Toast
|
||||
|
||||
|
||||
def toasts(request):
|
||||
user = request.user if hasattr(request, 'user') else None
|
||||
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
|
||||
has_toasts = len(toast_messages) > 0
|
||||
|
||||
return {
|
||||
'has_toasts': has_toasts,
|
||||
'toast_messages': toast_messages,
|
||||
}
|
15
bookmarks/management/commands/clean_tasks.py
Normal file
15
bookmarks/management/commands/clean_tasks.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Remove task locks and clear completed task history"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Remove task locks
|
||||
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
|
||||
# even though no process is working on them, and would prevent the task processor from picking the next task in
|
||||
# the queue
|
||||
Task.objects.all().update(locked_by=None, locked_at=None)
|
||||
# Clear task history to prevent them from bloating the DB
|
||||
CompletedTask.objects.all().delete()
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.20 on 2021-05-16 14:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0008_userprofile_bookmark_date_display'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='web_archive_snapshot_url',
|
||||
field=models.CharField(blank=True, max_length=2048),
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.6 on 2021-10-03 06:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0009_bookmark_web_archive_snapshot_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='bookmark_link_target',
|
||||
field=models.CharField(choices=[('_blank', 'New page'), ('_self', 'Same page')], default='_blank', max_length=10),
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.6 on 2022-01-08 12:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0010_userprofile_bookmark_link_target'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='web_archive_integration',
|
||||
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
|
||||
),
|
||||
]
|
26
bookmarks/migrations/0012_toast.py
Normal file
26
bookmarks/migrations/0012_toast.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.6 on 2022-01-08 19:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0011_userprofile_web_archive_integration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Toast',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=50)),
|
||||
('message', models.TextField()),
|
||||
('acknowledged', models.BooleanField(default=False)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
30
bookmarks/migrations/0013_web_archive_optin_toast.py
Normal file
30
bookmarks/migrations/0013_web_archive_optin_toast.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.2.6 on 2022-01-08 19:27
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
for user in User.objects.all():
|
||||
toast = Toast(key='web_archive_opt_in_hint',
|
||||
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
|
||||
owner=user)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key='web_archive_opt_in_hint').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('bookmarks', '0012_toast'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
@@ -20,11 +20,19 @@ class Tag(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
def sanitize_tag_name(tag_name: str):
|
||||
# strip leading/trailing spaces
|
||||
# replace inner spaces with replacement char
|
||||
return tag_name.strip().replace(' ', '-')
|
||||
|
||||
|
||||
def parse_tag_string(tag_string: str, delimiter: str = ','):
|
||||
if not tag_string:
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
names = [name.strip() for name in names if name]
|
||||
# remove empty names, sanitize remaining names
|
||||
names = [sanitize_tag_name(name) for name in names if name]
|
||||
# remove duplicates
|
||||
names = unique(names, str.lower)
|
||||
names.sort(key=str.lower)
|
||||
|
||||
@@ -41,6 +49,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()
|
||||
@@ -90,12 +99,10 @@ class BookmarkForm(forms.ModelForm):
|
||||
widget=forms.Textarea())
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
# Hidden field that determines where to redirect after saving the form
|
||||
return_url = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url']
|
||||
fields = ['url', 'tag_string', 'title', 'description', 'auto_close']
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
@@ -115,16 +122,32 @@ class UserProfile(models.Model):
|
||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
|
||||
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
|
||||
]
|
||||
BOOKMARK_LINK_TARGET_BLANK = '_blank'
|
||||
BOOKMARK_LINK_TARGET_SELF = '_self'
|
||||
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||
(BOOKMARK_LINK_TARGET_BLANK, 'New page'),
|
||||
(BOOKMARK_LINK_TARGET_SELF, 'Same page'),
|
||||
]
|
||||
WEB_ARCHIVE_INTEGRATION_DISABLED = 'disabled'
|
||||
WEB_ARCHIVE_INTEGRATION_ENABLED = 'enabled'
|
||||
WEB_ARCHIVE_INTEGRATION_CHOICES = [
|
||||
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
|
||||
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
|
||||
]
|
||||
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
|
||||
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
|
||||
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
|
||||
default=BOOKMARK_DATE_DISPLAY_RELATIVE)
|
||||
bookmark_link_target = models.CharField(max_length=10, choices=BOOKMARK_LINK_TARGET_CHOICES, blank=False,
|
||||
default=BOOKMARK_LINK_TARGET_BLANK)
|
||||
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
|
||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ['theme', 'bookmark_date_display']
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration']
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@@ -136,3 +159,10 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
||||
|
||||
|
||||
class Toast(models.Model):
|
||||
key = models.CharField(max_length=50)
|
||||
message = models.TextField()
|
||||
acknowledged = models.BooleanField(default=False)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
|
@@ -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(current_user, bookmark, 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(current_user, bookmark, True)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@@ -79,7 +90,7 @@ def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
tag_names = parse_tag_string(tag_string, ' ')
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
for bookmark in bookmarks:
|
||||
@@ -92,7 +103,7 @@ def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user
|
||||
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
tag_names = parse_tag_string(tag_string, ' ')
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
for bookmark in bookmarks:
|
||||
@@ -114,7 +125,7 @@ def _update_website_metadata(bookmark: Bookmark):
|
||||
|
||||
|
||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string, ' ')
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
bookmark.tags.set(tags)
|
||||
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services.parser import parse, NetscapeBookmark
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
from bookmarks.utils import parse_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +39,9 @@ def import_netscape_html(html: str, user: User):
|
||||
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
||||
result.failed = result.failed + 1
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -47,7 +51,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
|
||||
bookmark.url = netscape_bookmark.href
|
||||
if netscape_bookmark.date_added:
|
||||
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
else:
|
||||
bookmark.date_added = timezone.now()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
@@ -57,6 +61,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
bookmark.description = netscape_bookmark.description
|
||||
bookmark.owner = user
|
||||
|
||||
bookmark.full_clean()
|
||||
bookmark.save()
|
||||
|
||||
# Set tags
|
||||
|
65
bookmarks/services/tasks.py
Normal file
65
bookmarks/services/tasks.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
|
||||
import waybackpy
|
||||
from background_task import background
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_web_archive_integration_active(user: User) -> bool:
|
||||
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
web_archive_integration_enabled = \
|
||||
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
|
||||
return background_tasks_enabled and web_archive_integration_enabled
|
||||
|
||||
|
||||
def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):
|
||||
if is_web_archive_integration_active(user):
|
||||
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
||||
|
||||
|
||||
@background()
|
||||
def _create_web_archive_snapshot_task(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}...')
|
||||
|
||||
|
||||
def schedule_bookmarks_without_snapshots(user: User):
|
||||
if is_web_archive_integration_active(user):
|
||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
|
||||
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
_create_web_archive_snapshot_task(bookmark.id, False)
|
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from charset_normalizer import from_bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,5 +34,11 @@ def load_website_metadata(url: str):
|
||||
|
||||
|
||||
def load_page(url: str):
|
||||
r = requests.get(url)
|
||||
return r.text
|
||||
r = requests.get(url, timeout=10)
|
||||
|
||||
# Use charset_normalizer to determine encoding that best matches the response content
|
||||
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
|
||||
# This is different from Response.text which does respect the encoding specified in the response first,
|
||||
# before trying to determine one
|
||||
results = from_bytes(r.content)
|
||||
return str(results.best())
|
||||
|
8
bookmarks/signals.py
Normal file
8
bookmarks/signals.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.contrib.auth import user_logged_in
|
||||
from django.dispatch import receiver
|
||||
from bookmarks.services import tasks
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def user_logged_in(sender, request, user, **kwargs):
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
@@ -19,6 +19,7 @@
|
||||
if (buttonEl.nodeName === 'BUTTON') {
|
||||
confirmEl.type = buttonEl.type;
|
||||
confirmEl.name = buttonEl.name;
|
||||
confirmEl.value = buttonEl.value;
|
||||
}
|
||||
if (buttonEl.nodeName === 'A') {
|
||||
confirmEl.href = buttonEl.href;
|
||||
@@ -40,6 +41,43 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initGlobalShortcuts() {
|
||||
// Focus search button
|
||||
document.addEventListener('keydown', function (event) {
|
||||
// Filter for shortcut key
|
||||
if (event.key !== 's') return;
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget = targetNodeName === 'INPUT'
|
||||
|| targetNodeName === 'SELECT'
|
||||
|| targetNodeName === 'TEXTAREA';
|
||||
|
||||
initConfirmationButtons()
|
||||
if (isInputTarget) return;
|
||||
|
||||
const searchInput = document.querySelector('input[type="search"]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Add new bookmark
|
||||
document.addEventListener('keydown', function(event) {
|
||||
// Filter for new entry shortcut key
|
||||
if (event.key !== 'n') return;
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget = targetNodeName === 'INPUT'
|
||||
|| targetNodeName === 'SELECT'
|
||||
|| targetNodeName === 'TEXTAREA';
|
||||
|
||||
if (isInputTarget) return;
|
||||
|
||||
window.location.assign("/bookmarks/new");
|
||||
});
|
||||
}
|
||||
|
||||
initConfirmationButtons();
|
||||
initGlobalShortcuts();
|
||||
})()
|
@@ -11,6 +11,18 @@ header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
|
||||
.navbar-brand {
|
||||
|
@@ -1,14 +1,16 @@
|
||||
.bookmarks-page .search {
|
||||
$searchbox-width: 180px;
|
||||
$searchbox-width-md: 300px;
|
||||
$searchbox-height: 1.8rem;
|
||||
|
||||
// Regular input
|
||||
input[type='search'] {
|
||||
width: 180px;
|
||||
width: $searchbox-width;
|
||||
height: $searchbox-height;
|
||||
-webkit-appearance: none;
|
||||
|
||||
@media (min-width: $control-width-md) {
|
||||
width: 300px;
|
||||
width: $searchbox-width-md;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +20,19 @@
|
||||
height: $searchbox-height;
|
||||
|
||||
.form-autocomplete-input {
|
||||
width: $searchbox-width;
|
||||
height: $searchbox-height;
|
||||
width: 100%;
|
||||
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (min-width: $control-width-md) {
|
||||
width: $searchbox-width-md;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +61,10 @@ ul.bookmark-list {
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.actions .date-label a {
|
||||
color: $gray-color;
|
||||
}
|
||||
|
||||
.actions .btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
@@ -142,13 +153,13 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
/* Bulk edit */
|
||||
/* Bookmark actions / bulk edit */
|
||||
$bulk-edit-toggle-width: 16px;
|
||||
$bulk-edit-toggle-offset: 8px;
|
||||
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
||||
$bulk-edit-transition-duration: 400ms;
|
||||
|
||||
.bulk-edit-form {
|
||||
.bookmarks-page form.bookmark-actions {
|
||||
|
||||
.bulk-edit-bar {
|
||||
margin-top: -17px;
|
||||
|
@@ -18,7 +18,7 @@
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
</div>
|
||||
|
||||
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
|
||||
@@ -26,7 +26,7 @@
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% bookmark_list bookmarks return_url link_target %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
@@ -9,7 +9,7 @@
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title truncate">
|
||||
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a>
|
||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener">{{ bookmark.resolved_title }}</a>
|
||||
</div>
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
@@ -27,24 +27,44 @@
|
||||
</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 the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<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 the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<span>∞</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Unarchive</a>
|
||||
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive</button>
|
||||
{% else %}
|
||||
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Archive</a>
|
||||
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Archive</button>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove</a>
|
||||
<button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
|
@@ -4,7 +4,6 @@
|
||||
<div class="bookmarks-form">
|
||||
{% csrf_token %}
|
||||
{{ form.auto_close|attr:"type:hidden" }}
|
||||
{{ form.return_url|attr:"type:hidden" }}
|
||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
|
||||
|
@@ -18,7 +18,7 @@
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
</div>
|
||||
|
||||
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
|
||||
@@ -26,7 +26,7 @@
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% bookmark_list bookmarks return_url link_target %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
@@ -27,19 +27,31 @@
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar container grid-lg">
|
||||
<section class="navbar-section">
|
||||
<a href="/" class="navbar-brand text-bold">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>linkding</h1>
|
||||
</a>
|
||||
</section>
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="navbar-section">
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
</section>
|
||||
<header>
|
||||
{% if has_toasts %}
|
||||
<div class="toasts container grid-lg">
|
||||
{% for toast in toast_messages %}
|
||||
<div class="toast">
|
||||
{{ toast.message }}
|
||||
<a href="{% url 'bookmarks:toasts.acknowledge' toast.id %}?return_url={{ request.path | urlencode }}" class="btn btn-clear float-right"></a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="navbar container grid-lg">
|
||||
<section class="navbar-section">
|
||||
<a href="/" class="navbar-brand text-bold">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>linkding</h1>
|
||||
</a>
|
||||
</section>
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="navbar-section">
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="content container grid-lg">
|
||||
{% block content %}
|
||||
|
21
bookmarks/templates/registration/password_change_done.html
Normal file
21
bookmarks/templates/registration/password_change_done.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Password changed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Password Changed</h2>
|
||||
</div>
|
||||
<p class="text-success">
|
||||
Your password was changed successfully.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
55
bookmarks/templates/registration/password_change_form.html
Normal file
55
bookmarks/templates/registration/password_change_form.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'change_password' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
|
||||
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.old_password.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.old_password.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password1.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
|
||||
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password2.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="columns">
|
||||
<div class="column col-3">
|
||||
<input type="submit" value="Change Password" class="btn btn-primary">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@@ -1,25 +0,0 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
<section class="content-area">
|
||||
<h2>API Token</h2>
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="columns">
|
||||
<div class="column col-6 col-md-12">
|
||||
<input class="form-input" value="{{ api_token }}" disabled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
|
||||
token can access and manage all your bookmarks.</p>
|
||||
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@@ -9,15 +9,43 @@
|
||||
{# Profile section #}
|
||||
<section class="content-area">
|
||||
<h2>Profile</h2>
|
||||
<p>
|
||||
<a href="{% url 'change_password' %}">Change password</a>
|
||||
</p>
|
||||
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
|
||||
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can be hidden.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
||||
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to open bookmarks a new page or in the same page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
|
||||
<div class="form-input-hint">
|
||||
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
|
||||
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
|
||||
Machine</a>. This allows
|
||||
to preserve, and later access, the website as it was at the point in time it was bookmarked, in
|
||||
case it goes offline or its content is modified.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Save" class="btn btn-primary mt-2">
|
||||
@@ -69,6 +97,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 %}
|
||||
|
@@ -5,7 +5,6 @@
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
{# Integrations section #}
|
||||
<section class="content-area">
|
||||
<h2>Browser Extension</h2>
|
||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
|
||||
@@ -29,5 +28,19 @@
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
|
||||
<section class="content-area">
|
||||
<h2>REST API</h2>
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="columns">
|
||||
<div class="column col-6 col-md-12">
|
||||
<input class="form-input" value="{{ api_token }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
|
||||
token can access and manage all your bookmarks.</p>
|
||||
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
{% url 'bookmarks:settings.index' as index_url %}
|
||||
{% url 'bookmarks:settings.general' as general_url %}
|
||||
{% url 'bookmarks:settings.integrations' as integrations_url %}
|
||||
{% url 'bookmarks:settings.api' as api_url %}
|
||||
|
||||
<ul class="tab tab-block">
|
||||
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == general_url%}active{% endif %}">
|
||||
@@ -10,9 +9,6 @@
|
||||
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
||||
</li>
|
||||
<li class="tab-item {% if request.get_full_path == api_url %}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.api' %}">API</a>
|
||||
</li>
|
||||
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features 
 such as user management and bulk operations.">
|
||||
<a href="{% url 'admin:index' %}" target="_blank">
|
||||
<span>Admin</span>
|
||||
|
@@ -51,11 +51,12 @@ def tag_cloud(context, tags: List[Tag]):
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
|
||||
def bookmark_list(context, bookmarks: Page, return_url: str):
|
||||
def bookmark_list(context, bookmarks: Page, return_url: str, link_target: str = '_blank'):
|
||||
return {
|
||||
'request': context['request'],
|
||||
'bookmarks': bookmarks,
|
||||
'return_url': return_url
|
||||
'return_url': return_url,
|
||||
'link_target': link_target,
|
||||
}
|
||||
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import random
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
@@ -27,6 +28,7 @@ class BookmarkFactoryMixin:
|
||||
description: str = '',
|
||||
website_title: str = '',
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
):
|
||||
if tags is None:
|
||||
tags = []
|
||||
@@ -44,7 +46,8 @@ class BookmarkFactoryMixin:
|
||||
date_added=timezone.now(),
|
||||
date_modified=timezone.now(),
|
||||
owner=user,
|
||||
is_archived=is_archived
|
||||
is_archived=is_archived,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
@@ -117,3 +120,14 @@ def random_sentence(num_words: int = None, including_word: str = ''):
|
||||
random.shuffle(selected_words)
|
||||
|
||||
return ' '.join(selected_words)
|
||||
|
||||
|
||||
def disable_logging(f):
|
||||
def wrapper(*args):
|
||||
logging.disable(logging.CRITICAL)
|
||||
result = f(*args)
|
||||
logging.disable(logging.NOTSET)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
@@ -11,10 +11,10 @@
|
||||
<DT><A HREF="https://example.com/1" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag1">test title 1</A>
|
||||
<DD>test description 1
|
||||
|
||||
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
|
||||
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559000" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
|
||||
<DD>test description 2
|
||||
|
||||
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
|
||||
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559000000" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
|
||||
<DD>test description 3
|
||||
|
||||
</DL><p>
|
322
bookmarks/tests/test_bookmark_action_view.py
Normal file
322
bookmarks/tests/test_bookmark_action_view.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.forms import model_to_dict
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
|
||||
self.assertEqual(len(bookmarks), Bookmark.objects.count())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
|
||||
|
||||
def test_archive_should_archive_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertTrue(bookmark.is_archived)
|
||||
|
||||
def test_can_only_archive_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
def test_unarchive_should_unarchive_bookmark(self):
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
def test_unarchive_can_only_archive_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(bookmark.is_archived)
|
||||
|
||||
def test_delete_should_delete_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 0)
|
||||
|
||||
def test_delete_can_only_delete_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())
|
||||
|
||||
def test_bulk_archive(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_can_only_bulk_archive_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(user=other_user)
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_unarchive(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_unarchive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_can_only_bulk_unarchive_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_unarchive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_delete(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_delete': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_can_only_bulk_delete_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(user=other_user)
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_delete': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_bulk_tag(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_tag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_can_only_bulk_tag_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(user=other_user)
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_tag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_bulk_untag(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_untag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_can_only_bulk_untag_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_untag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_handles_empty_bookmark_id(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
def test_empty_action_does_not_modify_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
def test_should_redirect_to_return_url(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index')
|
||||
response = self.client.post(url, {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:settings.index'))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
url = reverse('bookmarks:action') + f'?return_url={return_url}'
|
||||
return self.client.post(url, {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
}, follow=follow)
|
||||
|
||||
response = post_with('https://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('//example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
|
||||
response = post_with('/foo//example.com', follow=True)
|
||||
self.assertEqual(response.status_code, 404)
|
@@ -1,35 +0,0 @@
|
||||
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'))
|
@@ -2,7 +2,7 @@ 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.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
@@ -12,22 +12,22 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark]):
|
||||
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'):
|
||||
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),
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark]):
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'):
|
||||
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),
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html,
|
||||
count=0
|
||||
)
|
||||
@@ -130,3 +130,29 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_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'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
|
||||
|
||||
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
user.profile.save()
|
||||
|
||||
visible_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'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
|
@@ -1,132 +0,0 @@
|
||||
from django.forms import model_to_dict
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
|
||||
self.assertEqual(len(bookmarks), Bookmark.objects.count())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
|
||||
|
||||
def test_bulk_archive(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_unarchive(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_unarchive': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_delete(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_delete': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertFalse(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertFalse(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_bulk_tag(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_tag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_bulk_untag(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_untag': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
bookmark3.refresh_from_db()
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_bulk_edit_handles_empty_bookmark_id(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_archive': [''],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bulk_archive': [''],
|
||||
'bookmark_id': [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
def test_empty_action_does_not_modify_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
@@ -1,3 +1,4 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -19,7 +20,6 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'tag_string': 'editedtag1 editedtag2',
|
||||
'title': 'edited title',
|
||||
'description': 'edited description',
|
||||
'return_url': reverse('bookmarks:index'),
|
||||
}
|
||||
return {**form_data, **overrides}
|
||||
|
||||
@@ -39,17 +39,6 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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()
|
||||
@@ -80,18 +69,48 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'</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')})
|
||||
form_data = self.create_form_data()
|
||||
|
||||
url = reverse('bookmarks:edit', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
|
||||
response = self.client.post(url, form_data)
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:close'))
|
||||
|
||||
def test_should_redirect_to_bookmark_index_by_default(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
|
||||
self.assertRedirects(response, form_data['return_url'])
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
form_data = self.create_form_data()
|
||||
url = reverse('bookmarks:edit', args=[bookmark.id]) + f'?return_url={return_url}'
|
||||
return self.client.post(url, form_data, follow=follow)
|
||||
|
||||
response = post_with('https://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('//example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
|
||||
response = post_with('/foo//example.com', follow=True)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_can_only_edit_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
form_data = self.create_form_data({'id': bookmark.id})
|
||||
|
||||
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertNotEqual(bookmark.url, form_data['url'])
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
@@ -2,7 +2,7 @@ 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.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
@@ -12,22 +12,22 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark]):
|
||||
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'):
|
||||
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),
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark]):
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'):
|
||||
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),
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html,
|
||||
count=0
|
||||
)
|
||||
@@ -130,3 +130,29 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark()
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
|
||||
|
||||
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
user.profile.save()
|
||||
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark(),
|
||||
self.setup_bookmark()
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
|
@@ -73,6 +73,13 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:new') + '?return_url=https://example.com', 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'})
|
||||
|
||||
|
@@ -1,35 +0,0 @@
|
||||
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'))
|
@@ -1,35 +0,0 @@
|
||||
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'))
|
@@ -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)
|
||||
|
@@ -60,6 +60,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
|
||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||
|
||||
def test_create_bookmark_replaces_whitespace_in_tag_names(self):
|
||||
data = {
|
||||
'url': 'https://example.com/',
|
||||
'title': 'Test title',
|
||||
'description': 'Test description',
|
||||
'tag_names': ['tag 1', 'tag 2']
|
||||
}
|
||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data['url'])
|
||||
tag_names = [tag.name for tag in bookmark.tags.all()]
|
||||
self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
|
||||
|
||||
def test_create_bookmark_minimal_payload(self):
|
||||
data = {'url': 'https://example.com/'}
|
||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||
|
@@ -4,13 +4,39 @@ from django.template import Template, RequestContext
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def render_template(self, bookmarks) -> str:
|
||||
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
|
||||
def assertDateLabel(self, html: str, label_content: str):
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<span>{label_content}</span>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
|
||||
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
<span>{label_content}</span>
|
||||
<span>∞</span>
|
||||
</a>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
|
||||
def render_template(self, bookmarks: [Bookmark], template: Template) -> str:
|
||||
rf = RequestFactory()
|
||||
request = rf.get('/test')
|
||||
request.user = self.get_or_create_test_user()
|
||||
@@ -18,15 +44,28 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
page = paginator.page(1)
|
||||
|
||||
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
|
||||
template_to_render = Template(
|
||||
return template.render(context)
|
||||
|
||||
def render_default_template(self, bookmarks: [Bookmark]) -> str:
|
||||
template = Template(
|
||||
'{% load bookmarks %}'
|
||||
'{% bookmark_list bookmarks return_url %}'
|
||||
)
|
||||
return template_to_render.render(context)
|
||||
return self.render_template(bookmarks, template)
|
||||
|
||||
def setup_date_format_test(self, date_display_setting):
|
||||
def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str:
|
||||
template = Template(
|
||||
f'''
|
||||
{{% load bookmarks %}}
|
||||
{{% bookmark_list bookmarks return_url '{link_target}' %}}
|
||||
'''
|
||||
)
|
||||
return self.render_template(bookmarks, template)
|
||||
|
||||
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
|
||||
@@ -35,17 +74,62 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_should_respect_absolute_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
|
||||
html = self.render_template([bookmark])
|
||||
html = self.render_default_template([bookmark])
|
||||
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<span class="text-gray text-sm">{formatted_date}</span>
|
||||
''', html)
|
||||
self.assertDateLabel(html, formatted_date)
|
||||
|
||||
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_default_template([bookmark])
|
||||
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
|
||||
|
||||
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_should_respect_relative_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
|
||||
html = self.render_template([bookmark])
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertInHTML('''
|
||||
<span class="text-gray text-sm">1 week ago</span>
|
||||
''', html)
|
||||
self.assertDateLabel(html, '1 week ago')
|
||||
|
||||
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_default_template([bookmark])
|
||||
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_bookmark_link_target_should_be_blank_by_default(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarksLink(html, bookmark, link_target='_blank')
|
||||
|
||||
def test_bookmark_link_target_should_respect_link_target_parameter(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
html = self.render_template_with_link_target([bookmark], '_self')
|
||||
|
||||
self.assertBookmarksLink(html, bookmark, link_target='_self')
|
||||
|
||||
def test_web_archive_link_target_should_be_blank_by_default(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.date_added = timezone.now() - relativedelta(days=8)
|
||||
bookmark.web_archive_snapshot_url = 'https://example.com'
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
|
||||
|
||||
def test_web_archive_link_target_respect_link_target_parameter(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.date_added = timezone.now() - relativedelta(days=8)
|
||||
bookmark.web_archive_snapshot_url = 'https://example.com'
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_template_with_link_target([bookmark], '_self')
|
||||
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
|
||||
|
@@ -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(self.user, bookmark, False)
|
||||
|
||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
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(self.user, bookmark, True)
|
||||
|
||||
def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
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(
|
||||
@@ -190,7 +216,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
@@ -206,7 +232,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user())
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1,tag2', self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -231,7 +257,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
|
||||
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -249,7 +275,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
|
||||
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
@@ -267,7 +293,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
|
||||
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||
@@ -281,7 +307,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
|
||||
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
@@ -299,7 +325,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
|
||||
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -317,7 +343,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
|
||||
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
@@ -335,7 +361,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
|
||||
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
|
||||
self.get_or_create_test_user())
|
||||
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
|
174
bookmarks/tests/test_bookmarks_tasks.py
Normal file
174
bookmarks/tests/test_bookmarks_tasks.py
Normal file
@@ -0,0 +1,174 @@
|
||||
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, UserProfile
|
||||
from bookmarks.services import tasks
|
||||
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):
|
||||
|
||||
def setUp(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
user.profile.save()
|
||||
|
||||
@disable_logging
|
||||
def run_pending_task(self, task_function):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
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')):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')) as mock_wayback_url:
|
||||
tasks._create_web_archive_snapshot_task(123, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
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):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
def test_create_web_archive_snapshot_should_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')):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
|
||||
|
||||
@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()
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(self):
|
||||
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
|
||||
self.user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
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')):
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
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')):
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
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')):
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
for bookmark in Bookmark.objects.all().filter(owner=user):
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
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):
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(self):
|
||||
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
|
||||
self.user.profile.save()
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
58
bookmarks/tests/test_importer.py
Normal file
58
bookmarks/tests/test_importer.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.models import Tag
|
||||
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>
|
||||
'''
|
||||
|
||||
def test_replace_whitespace_in_tag_names(self):
|
||||
test_html = self.create_import_html(f'''
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag 1, tag 2, tag 3">Example.com</A>
|
||||
<DD>Example.com
|
||||
''')
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
tags = Tag.objects.all()
|
||||
tag_names = [tag.name for tag in tags]
|
||||
|
||||
self.assertListEqual(tag_names, ['tag-1', 'tag-2', 'tag-3'])
|
||||
|
||||
@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)
|
55
bookmarks/tests/test_password_change_view.py
Normal file
55
bookmarks/tests/test_password_change_view.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'initial_password')
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_change_password(self):
|
||||
form_data = {
|
||||
'old_password': 'initial_password',
|
||||
'new_password1': 'new_password',
|
||||
'new_password2': 'new_password',
|
||||
}
|
||||
|
||||
response = self.client.post(reverse('change_password'), form_data)
|
||||
|
||||
self.assertRedirects(response, reverse('password_change_done'))
|
||||
|
||||
def test_change_password_done(self):
|
||||
form_data = {
|
||||
'old_password': 'initial_password',
|
||||
'new_password1': 'new_password',
|
||||
'new_password2': 'new_password',
|
||||
}
|
||||
|
||||
response = self.client.post(reverse('change_password'), form_data, follow=True)
|
||||
|
||||
self.assertContains(response, 'Your password was changed successfully')
|
||||
|
||||
def test_should_return_error_for_invalid_old_password(self):
|
||||
form_data = {
|
||||
'old_password': 'wrong_password',
|
||||
'new_password1': 'new_password',
|
||||
'new_password2': 'new_password',
|
||||
}
|
||||
|
||||
response = self.client.post(reverse('change_password'), form_data)
|
||||
|
||||
self.assertIn('old_password', response.context_data['form'].errors)
|
||||
|
||||
def test_should_return_error_for_mismatching_new_password(self):
|
||||
form_data = {
|
||||
'old_password': 'initial_password',
|
||||
'new_password1': 'new_password',
|
||||
'new_password2': 'wrong_password',
|
||||
}
|
||||
|
||||
response = self.client.post(reverse('change_password'), form_data)
|
||||
|
||||
self.assertIn('new_password2', response.context_data['form'].errors)
|
@@ -1,40 +0,0 @@
|
||||
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)
|
@@ -26,6 +26,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
form_data = {
|
||||
'theme': UserProfile.THEME_DARK,
|
||||
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
|
||||
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
|
||||
@@ -34,3 +36,5 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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'])
|
||||
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
|
||||
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
|
||||
|
@@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -52,6 +52,7 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertNoFormSuccessHint(response)
|
||||
self.assertFormErrorHint(response, 'Please select a file to import.')
|
||||
|
||||
@disable_logging
|
||||
def test_should_show_hint_if_import_raises_exception(self):
|
||||
with open('bookmarks/tests/resources/invalid_import_file.png', 'rb') as import_file:
|
||||
response = self.client.post(
|
||||
@@ -64,6 +65,7 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertNoFormSuccessHint(response)
|
||||
self.assertFormErrorHint(response, 'An error occurred during bookmark import.')
|
||||
|
||||
@disable_logging
|
||||
def test_should_show_respective_hints_if_not_all_bookmarks_were_imported_successfully(self):
|
||||
with open('bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html') as import_file:
|
||||
response = self.client.post(
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
@@ -20,3 +21,20 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse('bookmarks:settings.integrations'), follow=True)
|
||||
|
||||
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.integrations'))
|
||||
|
||||
def test_should_generate_api_token_if_not_exists(self):
|
||||
self.assertEqual(Token.objects.count(), 0)
|
||||
|
||||
self.client.get(reverse('bookmarks:settings.integrations'))
|
||||
|
||||
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.integrations'))
|
||||
|
||||
self.assertEqual(Token.objects.count(), 1)
|
||||
|
15
bookmarks/tests/test_signals.py
Normal file
15
bookmarks/tests/test_signals.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class SignalsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_login_should_schedule_snapshot_creation(self):
|
||||
user = self.get_or_create_test_user()
|
||||
|
||||
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
|
||||
self.client.force_login(user)
|
||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
@@ -25,3 +25,9 @@ class TagTestCase(TestCase):
|
||||
def test_parse_tag_string_deduplicates_tag_names(self):
|
||||
self.assertEqual(len(parse_tag_string('book,book,Book,BOOK')), 1)
|
||||
|
||||
def test_parse_tag_string_handles_duplicate_separators(self):
|
||||
self.assertCountEqual(parse_tag_string('book,,movie,,,album'), ['album', 'book', 'movie'])
|
||||
|
||||
def test_parse_tag_string_replaces_whitespace_within_names(self):
|
||||
self.assertCountEqual(parse_tag_string('travel guide, book recommendations'),
|
||||
['travel-guide', 'book-recommendations'])
|
||||
|
108
bookmarks/tests/test_toasts_view.py
Normal file
108
bookmarks/tests/test_toasts_view.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Toast
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence, disable_logging
|
||||
|
||||
|
||||
class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def create_toast(self, user: User = None, message: str = None, acknowledged: bool = False):
|
||||
if not user:
|
||||
user = self.user
|
||||
if not message:
|
||||
message = random_sentence()
|
||||
|
||||
toast = Toast(owner=user, key='test', message=message, acknowledged=acknowledged)
|
||||
toast.save()
|
||||
return toast
|
||||
|
||||
def test_should_render_unacknowledged_toasts(self):
|
||||
self.create_toast()
|
||||
self.create_toast()
|
||||
self.create_toast(acknowledged=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
# Should render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">')
|
||||
# Should render two toasts
|
||||
self.assertContains(response, '<div class="toast">', count=2)
|
||||
|
||||
def test_should_not_render_acknowledged_toasts(self):
|
||||
self.create_toast(acknowledged=True)
|
||||
self.create_toast(acknowledged=True)
|
||||
self.create_toast(acknowledged=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
# Should not render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
|
||||
# Should not render toasts
|
||||
self.assertContains(response, '<div class="toast">', count=0)
|
||||
|
||||
def test_should_not_render_toasts_of_other_users(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
|
||||
self.create_toast(user=other_user)
|
||||
self.create_toast(user=other_user)
|
||||
self.create_toast(user=other_user)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
# Should not render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
|
||||
# Should not render toasts
|
||||
self.assertContains(response, '<div class="toast">', count=0)
|
||||
|
||||
def test_toast_content(self):
|
||||
toast = self.create_toast()
|
||||
expected_toast = f'''
|
||||
<div class="toast">
|
||||
{toast.message}
|
||||
<a href="{reverse('bookmarks:toasts.acknowledge', args=[toast.id])}?return_url={reverse('bookmarks:index')}" class="btn btn-clear float-right"></a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(expected_toast, html)
|
||||
|
||||
def test_acknowledge_toast(self):
|
||||
toast = self.create_toast()
|
||||
|
||||
self.client.get(reverse('bookmarks:toasts.acknowledge', args=[toast.id]))
|
||||
|
||||
toast.refresh_from_db()
|
||||
self.assertTrue(toast.acknowledged)
|
||||
|
||||
def test_acknowledge_toast_should_redirect_to_return_url(self):
|
||||
toast = self.create_toast()
|
||||
return_url = reverse('bookmarks:settings.general')
|
||||
acknowledge_url = reverse('bookmarks:toasts.acknowledge', args=[toast.id])
|
||||
acknowledge_url = acknowledge_url + '?return_url=' + return_url
|
||||
|
||||
response = self.client.get(acknowledge_url)
|
||||
|
||||
self.assertRedirects(response, return_url)
|
||||
|
||||
def test_acknowledge_toast_should_redirect_to_index_by_default(self):
|
||||
toast = self.create_toast()
|
||||
|
||||
response = self.client.get(reverse('bookmarks:toasts.acknowledge', args=[toast.id]))
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
|
||||
@disable_logging
|
||||
def test_acknowledge_toast_should_not_acknowledge_other_users_toast(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
toast = self.create_toast(user=other_user)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:toasts.acknowledge', args=[toast.id]))
|
||||
self.assertEqual(response.status_code, 404)
|
@@ -1,9 +1,10 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.utils import humanize_absolute_date, humanize_relative_date
|
||||
from bookmarks.utils import humanize_absolute_date, humanize_relative_date, parse_timestamp
|
||||
|
||||
|
||||
class UtilsTestCase(TestCase):
|
||||
@@ -63,3 +64,45 @@ class UtilsTestCase(TestCase):
|
||||
# Regression: Test that subsequent calls use current date instead of cached date (#107)
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
|
||||
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 13)), 'Today')
|
||||
|
||||
def verify_timestamp(self, date, factor=1):
|
||||
timestamp_string = str(int(date.timestamp() * factor))
|
||||
parsed_date = parse_timestamp(timestamp_string)
|
||||
self.assertEqual(date, parsed_date)
|
||||
|
||||
def test_parse_timestamp_fails_for_invalid_timestamps(self):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_timestamp('invalid')
|
||||
|
||||
def test_parse_timestamp_parses_millisecond_timestamps(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
fifty_years_ago = now - relativedelta(year=50)
|
||||
fifty_years_from_now = now + relativedelta(year=50)
|
||||
|
||||
self.verify_timestamp(now)
|
||||
self.verify_timestamp(fifty_years_ago)
|
||||
self.verify_timestamp(fifty_years_from_now)
|
||||
|
||||
def test_parse_timestamp_parses_microsecond_timestamps(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
fifty_years_ago = now - relativedelta(year=50)
|
||||
fifty_years_from_now = now + relativedelta(year=50)
|
||||
|
||||
self.verify_timestamp(now, 1000)
|
||||
self.verify_timestamp(fifty_years_ago, 1000)
|
||||
self.verify_timestamp(fifty_years_from_now, 1000)
|
||||
|
||||
def test_parse_timestamp_parses_nanosecond_timestamps(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
fifty_years_ago = now - relativedelta(year=50)
|
||||
fifty_years_from_now = now + relativedelta(year=50)
|
||||
|
||||
self.verify_timestamp(now, 1000000)
|
||||
self.verify_timestamp(fifty_years_ago, 1000000)
|
||||
self.verify_timestamp(fifty_years_from_now, 1000000)
|
||||
|
||||
def test_parse_timestamp_fails_for_out_of_range_timestamp(self):
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.verify_timestamp(now, 1000000000)
|
||||
|
@@ -15,17 +15,15 @@ urlpatterns = [
|
||||
path('bookmarks/new', views.bookmarks.new, name='new'),
|
||||
path('bookmarks/close', views.bookmarks.close, name='close'),
|
||||
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
|
||||
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
|
||||
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
||||
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
||||
path('bookmarks/bulkedit', views.bookmarks.bulk_edit, name='bulk_edit'),
|
||||
path('bookmarks/action', views.bookmarks.action, name='action'),
|
||||
# Settings
|
||||
path('settings', views.settings.general, name='settings.index'),
|
||||
path('settings/general', views.settings.general, name='settings.general'),
|
||||
path('settings/integrations', views.settings.integrations, name='settings.integrations'),
|
||||
path('settings/api', views.settings.api, name='settings.api'),
|
||||
path('settings/import', views.settings.bookmark_import, name='settings.import'),
|
||||
path('settings/export', views.settings.bookmark_export, name='settings.export'),
|
||||
# Toasts
|
||||
path('toasts/<int:toast_id>/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'),
|
||||
# API
|
||||
path('api/', include(router.urls), name='api')
|
||||
]
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -58,3 +59,47 @@ def humanize_relative_date(value: datetime, now: Optional[datetime] = None):
|
||||
return 'Yesterday'
|
||||
else:
|
||||
return weekday_names[value.isoweekday()]
|
||||
|
||||
|
||||
def parse_timestamp(value: str):
|
||||
"""
|
||||
Parses a string timestamp into a datetime value
|
||||
First tries to parse the timestamp as milliseconds.
|
||||
If that fails with an error indicating that the timestamp exceeds the maximum,
|
||||
it tries to parse the timestamp as microseconds, and then as nanoseconds
|
||||
:param value:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
timestamp = int(value)
|
||||
except ValueError:
|
||||
raise ValueError(f'{value} is not a valid timestamp')
|
||||
|
||||
try:
|
||||
return datetime.utcfromtimestamp(timestamp).astimezone()
|
||||
except (OverflowError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# Value exceeds the max. allowed timestamp
|
||||
# Try parsing as microseconds
|
||||
try:
|
||||
return datetime.utcfromtimestamp(timestamp / 1000).astimezone()
|
||||
except (OverflowError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# Value exceeds the max. allowed timestamp
|
||||
# Try parsing as nanoseconds
|
||||
try:
|
||||
return datetime.utcfromtimestamp(timestamp / 1000000).astimezone()
|
||||
except (OverflowError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
# Timestamp is out of range
|
||||
raise ValueError(f'{value} exceeds maximum value for a timestamp')
|
||||
|
||||
|
||||
def get_safe_return_url(return_url: str, fallback_url: str):
|
||||
# Use fallback if URL is none or URL is not on same domain
|
||||
if not return_url or not re.match(r'^/[a-z]+', return_url):
|
||||
return fallback_url
|
||||
return return_url
|
||||
|
@@ -1,2 +1,3 @@
|
||||
from .bookmarks import *
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
|
@@ -2,7 +2,7 @@ import urllib.parse
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -10,6 +10,7 @@ from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
|
||||
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.utils import get_safe_return_url
|
||||
|
||||
_default_page_size = 30
|
||||
|
||||
@@ -40,6 +41,7 @@ def get_bookmark_view_context(request, query_set, tags, base_url):
|
||||
paginator = Paginator(query_set, _default_page_size)
|
||||
bookmarks = paginator.get_page(page)
|
||||
return_url = generate_return_url(base_url, page, query_string)
|
||||
link_target = request.user.profile.bookmark_link_target
|
||||
|
||||
if request.GET.get('tag'):
|
||||
mod = request.GET.copy()
|
||||
@@ -51,7 +53,8 @@ def get_bookmark_view_context(request, query_set, tags, base_url):
|
||||
'tags': tags,
|
||||
'query': query_string if query_string else '',
|
||||
'empty': paginator.count == 0,
|
||||
'return_url': return_url
|
||||
'return_url': return_url,
|
||||
'link_target': link_target,
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +69,12 @@ def generate_return_url(base_url, page, query_string):
|
||||
return urllib.parse.quote_plus(return_url)
|
||||
|
||||
|
||||
def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
return tag_string.replace(' ', ',')
|
||||
|
||||
|
||||
@login_required
|
||||
def new(request):
|
||||
initial_url = request.GET.get('url')
|
||||
@@ -76,7 +85,8 @@ def new(request):
|
||||
auto_close = form.data['auto_close']
|
||||
if form.is_valid():
|
||||
current_user = request.user
|
||||
create_bookmark(form.save(commit=False), form.data['tag_string'], current_user)
|
||||
tag_string = convert_tag_string(form.data['tag_string'])
|
||||
create_bookmark(form.save(commit=False), tag_string, current_user)
|
||||
if auto_close:
|
||||
return HttpResponseRedirect(reverse('bookmarks:close'))
|
||||
else:
|
||||
@@ -99,22 +109,22 @@ def new(request):
|
||||
|
||||
@login_required
|
||||
def edit(request, bookmark_id: int):
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
raise Http404('Bookmark does not exist')
|
||||
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
form = BookmarkForm(request.POST, instance=bookmark)
|
||||
return_url = form.data['return_url']
|
||||
if form.is_valid():
|
||||
update_bookmark(form.save(commit=False), form.data['tag_string'], request.user)
|
||||
tag_string = convert_tag_string(form.data['tag_string'])
|
||||
update_bookmark(form.save(commit=False), tag_string, request.user)
|
||||
return HttpResponseRedirect(return_url)
|
||||
else:
|
||||
return_url = request.GET.get('return_url')
|
||||
form = BookmarkForm(instance=bookmark)
|
||||
|
||||
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||
|
||||
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
|
||||
form.initial['return_url'] = return_url
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
@@ -125,53 +135,61 @@ def edit(request, bookmark_id: int):
|
||||
return render(request, 'bookmarks/edit.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def remove(request, bookmark_id: int):
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
raise Http404('Bookmark does not exist')
|
||||
|
||||
bookmark.delete()
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def archive(request, bookmark_id: int):
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
raise Http404('Bookmark does not exist')
|
||||
|
||||
archive_bookmark(bookmark)
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def unarchive(request, bookmark_id: int):
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
raise Http404('Bookmark does not exist')
|
||||
|
||||
unarchive_bookmark(bookmark)
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:archived')
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def bulk_edit(request):
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
|
||||
def action(request):
|
||||
# Determine action
|
||||
if 'archive' in request.POST:
|
||||
archive(request, request.POST['archive'])
|
||||
if 'unarchive' in request.POST:
|
||||
unarchive(request, request.POST['unarchive'])
|
||||
if 'remove' in request.POST:
|
||||
remove(request, request.POST['remove'])
|
||||
if 'bulk_archive' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unarchive' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_delete' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_tag' in request.POST:
|
||||
tag_string = request.POST['bulk_tag_string']
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_untag' in request.POST:
|
||||
tag_string = request.POST['bulk_tag_string']
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
|
@@ -14,6 +14,12 @@ from bookmarks.services import importer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
app_version = f.read().strip("\n")
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
pass
|
||||
|
||||
@login_required
|
||||
def general(request):
|
||||
@@ -30,21 +36,16 @@ def general(request):
|
||||
'form': form,
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
'app_version': app_version
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def integrations(request):
|
||||
application_url = request.build_absolute_uri("/bookmarks/new")
|
||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||
return render(request, 'settings/integrations.html', {
|
||||
'application_url': application_url,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def api(request):
|
||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||
return render(request, 'settings/api.html', {
|
||||
'api_token': api_token.key
|
||||
})
|
||||
|
||||
|
19
bookmarks/views/toasts.py
Normal file
19
bookmarks/views/toasts.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Toast
|
||||
from bookmarks.utils import get_safe_return_url
|
||||
|
||||
|
||||
@login_required
|
||||
def acknowledge(request, toast_id: int):
|
||||
try:
|
||||
toast = Toast.objects.get(pk=toast_id, owner=request.user)
|
||||
except Toast.DoesNotExist:
|
||||
raise Http404('Toast does not exist')
|
||||
toast.acknowledged = True
|
||||
toast.save()
|
||||
|
||||
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
|
||||
return HttpResponseRedirect(return_url)
|
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bootstrap script that gets executed in new Docker containers
|
||||
|
||||
LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
|
||||
|
||||
# Create data folder if it does not exist
|
||||
mkdir -p data
|
||||
|
||||
@@ -12,5 +14,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
|
||||
uwsgi --http :$LD_SERVER_PORT uwsgi.ini
|
||||
|
@@ -8,4 +8,6 @@ services:
|
||||
- "${LD_HOST_PORT:-9090}:9090"
|
||||
volumes:
|
||||
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
|
@@ -25,14 +25,29 @@ 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 [the Internet Archive Wayback Machine](https://archive.org/web/).
|
||||
Enabling this flag will prevent the background task processor from starting up, and prevents scheduling tasks.
|
||||
This might be useful if you are experiencing performance issues or other problematic behaviour due to background task processing.
|
||||
|
||||
### `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`
|
||||
|
||||
Values: `Integer` as seconds | Default = `60`
|
||||
|
||||
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
|
||||
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
|
||||
|
||||
### `LD_SERVER_PORT`
|
||||
|
||||
Values: Valid port number | Default = `9090`
|
||||
|
||||
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.
|
@@ -20,6 +20,22 @@ Now when you are browsing the web and you want to save the current page as a boo
|
||||
|
||||
For more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-android/
|
||||
|
||||
## Using HTTP Shortcuts app on Android
|
||||
|
||||
**Note** This allows you to share URL from any app to bookmark it to linkding
|
||||
|
||||
- Install HTTP Shortcuts from [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts) or [F-Droid](https://f-droid.org/en/packages/ch.rmy.android.http_shortcuts/).
|
||||
|
||||
- Download [linkding_shortcut.json](/docs/linkding_shortcut.json) from this repository.
|
||||
|
||||
- Open HTTP Shortcuts, tap the 3-dot-button at the top-right corner, tap `Import/Export`, then tap `Import from file`.
|
||||
|
||||
- Select the json file you downloaded earlier, go back, tap the 3-dot-button again, then tap `Variables`.
|
||||
|
||||
- Edit the `values` of `linkding_instance`, `linkding_tag` and `linkding_api_token`.
|
||||
|
||||
Try using share button on an app, a new item `Send to...` should appear on the share sheet. You can also manually share by tapping the shortcut inside the HTTP Shortcuts app itself.
|
||||
|
||||
## 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.
|
||||
|
59
docs/linkding_shortcut.json
Normal file
59
docs/linkding_shortcut.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": "8f4299d4-4c30-4a8e-a3f9-c90694011713",
|
||||
"name": "Shortcuts",
|
||||
"shortcuts": [
|
||||
{
|
||||
"bodyContent": "{ \"url\": \"{{b2953f61-b302-4c79-b90d-39858a06d9a6}}\", \"tag_names\": [ \"{{c360f61f-ce17-47b4-bea3-1d8c3913ca52}}\" ] }",
|
||||
"contentType": "application/json",
|
||||
"description": "Bookmark to linkding",
|
||||
"headers": [
|
||||
{
|
||||
"id": "d235f7b4-fce2-41f4-a00f-72d5fde9e4b9",
|
||||
"key": "Authorization",
|
||||
"value": "Token {{6a739a16-d16d-4a06-93a5-3457da3c3d20}}"
|
||||
}
|
||||
],
|
||||
"iconName": "flat_grey_ribbon",
|
||||
"id": "1e047d02-a4a3-4cad-b4cc-123cc16c8398",
|
||||
"launcherShortcut": true,
|
||||
"method": "POST",
|
||||
"name": "Linkding",
|
||||
"quickSettingsTileShortcut": true,
|
||||
"responseHandling": {
|
||||
"failureOutput": "simple",
|
||||
"id": "61fa9fc3-8b7a-47ce-b43c-f24618a65e1e",
|
||||
"uiType": "toast"
|
||||
},
|
||||
"url": "{{ea2db14b-b9ca-45d8-8555-403271a38f5a}}/api/bookmarks/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{
|
||||
"id": "ea2db14b-b9ca-45d8-8555-403271a38f5a",
|
||||
"key": "linkding_instance",
|
||||
"value": "https://your.instance.tld.without.slashed.end"
|
||||
},
|
||||
{
|
||||
"flags": 1,
|
||||
"id": "b2953f61-b302-4c79-b90d-39858a06d9a6",
|
||||
"key": "linkding_add_url",
|
||||
"title": "Enter URL",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"id": "c360f61f-ce17-47b4-bea3-1d8c3913ca52",
|
||||
"key": "linkding_tag",
|
||||
"value": "single-tag"
|
||||
},
|
||||
{
|
||||
"id": "6a739a16-d16d-4a06-93a5-3457da3c3d20",
|
||||
"key": "linkding_api_token",
|
||||
"value": "your_token_from_integrations_tab"
|
||||
}
|
||||
],
|
||||
"version": 45
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="django" name="Django">
|
||||
<configuration>
|
||||
<option name="rootFolder" value="$MODULE_DIR$" />
|
||||
<option name="settingsModule" value="siteroot/settings/dev.py" />
|
||||
<option name="manageScript" value="manage.py" />
|
||||
<option name="environment" value="<map/>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
<option name="trackFilePattern" value="migrations" />
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||
</component>
|
||||
</module>
|
644
package-lock.json
generated
644
package-lock.json
generated
@@ -1,8 +1,604 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"version": "1.8.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkding",
|
||||
"version": "1.8.6",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^21.0.2",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"rollup": "^2.70.1",
|
||||
"rollup-plugin-svelte": "^7.1.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"spectre.css": "^0.5.8",
|
||||
"svelte": "^3.46.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
|
||||
"integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz",
|
||||
"integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw=="
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
|
||||
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.10.4",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "21.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
|
||||
"integrity": "sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg==",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"commondir": "^1.0.1",
|
||||
"estree-walker": "^2.0.1",
|
||||
"glob": "^7.1.6",
|
||||
"is-reference": "^1.2.1",
|
||||
"magic-string": "^0.25.7",
|
||||
"resolve": "^1.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.38.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.3.tgz",
|
||||
"integrity": "sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ==",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"@types/resolve": "1.17.1",
|
||||
"builtin-modules": "^3.1.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"is-module": "^1.0.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.42.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
|
||||
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
|
||||
"dependencies": {
|
||||
"@types/estree": "0.0.39",
|
||||
"estree-walker": "^1.0.1",
|
||||
"picomatch": "^2.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/estree-walker": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
|
||||
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "0.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
|
||||
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "13.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.1.tgz",
|
||||
"integrity": "sha512-hx6zWtudh3Arsbl3cXay+JnkvVgCKzCWKv42C9J01N2T2np4h8w5X8u6Tpz5mj38kE3M9FM0Pazx8vKFFMnjLQ=="
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
||||
"integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz",
|
||||
"integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
|
||||
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
|
||||
"integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
|
||||
"dependencies": {
|
||||
"has": "^1.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-module": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||
"integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE="
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
|
||||
"integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"merge-stream": "^2.0.0",
|
||||
"supports-color": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
|
||||
"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
|
||||
"dependencies": {
|
||||
"sourcemap-codec": "^1.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
||||
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-relative": {
|
||||
"version": "0.8.7",
|
||||
"resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
|
||||
"integrity": "sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4="
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz",
|
||||
"integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.1.0",
|
||||
"path-parse": "^1.0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "2.70.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.1.tgz",
|
||||
"integrity": "sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==",
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-plugin-svelte": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.0.tgz",
|
||||
"integrity": "sha512-vopCUq3G+25sKjwF5VilIbiY6KCuMNHP1PFvx2Vr3REBNMDllKHFZN2B9jwwC+MqNc3UPKkjXnceLPEjTjXGXg==",
|
||||
"dependencies": {
|
||||
"require-relative": "^0.8.7",
|
||||
"rollup-pluginutils": "^2.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": ">=2.0.0",
|
||||
"svelte": ">=3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-plugin-terser": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
|
||||
"integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"jest-worker": "^26.2.1",
|
||||
"serialize-javascript": "^4.0.0",
|
||||
"terser": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-pluginutils": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz",
|
||||
"integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==",
|
||||
"dependencies": {
|
||||
"estree-walker": "^0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-pluginutils/node_modules/estree-walker": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
|
||||
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
|
||||
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
||||
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
|
||||
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sourcemap-codec": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
|
||||
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
|
||||
},
|
||||
"node_modules/spectre.css": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/spectre.css/-/spectre.css-0.5.8.tgz",
|
||||
"integrity": "sha512-3N4WocWY+Dl6b3e5v3nsZYyp+VSDcBfGDzyyHw/H78ie9BoAhHkxmrhLxo9y8RadxYzVrPjfPdlev3hXEUzR2w=="
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "3.46.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
|
||||
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg==",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
|
||||
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
|
||||
"dependencies": {
|
||||
"commander": "^2.20.0",
|
||||
"source-map": "~0.7.2",
|
||||
"source-map-support": "~0.5.19"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.12.11",
|
||||
@@ -28,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"@rollup/plugin-commonjs": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.0.0.tgz",
|
||||
"integrity": "sha512-/omBIJG1nHQc+bgkYDuLpb/V08QyutP9amOrJRUSlYJZP+b/68gM//D8sxJe3Yry2QnYIr3QjR3x4AlxJEN3GA==",
|
||||
"version": "21.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz",
|
||||
"integrity": "sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg==",
|
||||
"requires": {
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"commondir": "^1.0.1",
|
||||
@@ -42,9 +638,9 @@
|
||||
}
|
||||
},
|
||||
"@rollup/plugin-node-resolve": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.0.1.tgz",
|
||||
"integrity": "sha512-ltlsj/4Bhwwhb+Nb5xCz/6vieuEj2/BAkkqVIKmZwC7pIdl8srmgmglE4S0jFlZa32K4qvdQ6NHdmpRKD/LwoQ==",
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.3.tgz",
|
||||
"integrity": "sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ==",
|
||||
"requires": {
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"@types/resolve": "1.17.1",
|
||||
@@ -180,9 +776,9 @@
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
|
||||
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
@@ -316,9 +912,9 @@
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.2.2",
|
||||
@@ -348,17 +944,17 @@
|
||||
}
|
||||
},
|
||||
"rollup": {
|
||||
"version": "2.35.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.35.1.tgz",
|
||||
"integrity": "sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==",
|
||||
"version": "2.70.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.70.1.tgz",
|
||||
"integrity": "sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==",
|
||||
"requires": {
|
||||
"fsevents": "~2.1.2"
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"rollup-plugin-svelte": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.0.0.tgz",
|
||||
"integrity": "sha512-cw4yv/5v1NQV3nPbpOJtikgkB+9mfSJaqKUdq7x5fVQJnwLtcdc2JOszBs5pBY+SemTs5pmJbdEMseEavbUtjQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.0.tgz",
|
||||
"integrity": "sha512-vopCUq3G+25sKjwF5VilIbiY6KCuMNHP1PFvx2Vr3REBNMDllKHFZN2B9jwwC+MqNc3UPKkjXnceLPEjTjXGXg==",
|
||||
"requires": {
|
||||
"require-relative": "^0.8.7",
|
||||
"rollup-pluginutils": "^2.8.2"
|
||||
@@ -443,9 +1039,9 @@
|
||||
}
|
||||
},
|
||||
"svelte": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.31.0.tgz",
|
||||
"integrity": "sha512-r+n8UJkDqoQm1b+3tA3Lh6mHXKpcfOSOuEuIo5gE2W9wQYi64RYX/qE6CZBDDsP/H4M+N426JwY7XGH4xASvGQ=="
|
||||
"version": "3.46.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.46.4.tgz",
|
||||
"integrity": "sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg=="
|
||||
},
|
||||
"terser": {
|
||||
"version": "5.5.1",
|
||||
|
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.6.5",
|
||||
"version": "1.9.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -19,12 +19,12 @@
|
||||
},
|
||||
"homepage": "https://github.com/sissbruecker/linkding#readme",
|
||||
"dependencies": {
|
||||
"spectre.css": "^0.5.8",
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-node-resolve": "^11.0.1",
|
||||
"rollup": "^2.35.1",
|
||||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"@rollup/plugin-commonjs": "^21.0.2",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"rollup": "^2.70.1",
|
||||
"rollup-plugin-svelte": "^7.1.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"svelte": "^3.31.0"
|
||||
"spectre.css": "^0.5.8",
|
||||
"svelte": "^3.46.4"
|
||||
}
|
||||
}
|
||||
|
@@ -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.20
|
||||
Django==3.2.13
|
||||
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.1.2
|
||||
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.8
|
||||
sqlparse==0.4.2
|
||||
supervisor==4.2.2
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
uWSGI==2.0.18
|
||||
waybackpy==2.4.3
|
||||
|
@@ -1,27 +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
|
||||
coverage==5.5
|
||||
Django==2.2.20
|
||||
django-appconf==1.0.3
|
||||
django-compressor==2.3
|
||||
Django==3.2.13
|
||||
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.1.2
|
||||
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.8
|
||||
sqlparse==0.4.2
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
waybackpy==2.4.3
|
||||
|
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||
'django_generate_secret_key',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'background_task',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -68,11 +69,14 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'bookmarks.context_processors.toasts',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
|
||||
WSGI_APPLICATION = 'siteroot.wsgi.application'
|
||||
|
||||
# Database
|
||||
@@ -164,3 +168,14 @@ ALLOW_REGISTRATION = False
|
||||
|
||||
# URL validation flag
|
||||
LD_DISABLE_URL_VALIDATION = os.getenv('LD_DISABLE_URL_VALIDATION', False) in (True, 'True', '1')
|
||||
|
||||
# Background task enabled setting
|
||||
LD_DISABLE_BACKGROUND_TASKS = os.getenv('LD_DISABLE_BACKGROUND_TASKS', False) in (True, 'True', '1')
|
||||
|
||||
# django-background-tasks
|
||||
MAX_ATTEMPTS = 5
|
||||
# How many tasks will run in parallel
|
||||
# We want to keep this low to prevent SQLite lock errors and in general not to consume too much resources on smaller
|
||||
# specced systems like Raspberries. Should be OK as tasks are not time critical.
|
||||
BACKGROUND_TASK_RUN_ASYNC = True
|
||||
BACKGROUND_TASK_ASYNC_THREADS = 2
|
||||
|
@@ -43,6 +43,11 @@ LOGGING = {
|
||||
'django.db.backends': {
|
||||
'level': 'ERROR', # Set to DEBUG to log all SQL calls
|
||||
'handlers': ['console'],
|
||||
},
|
||||
'bookmarks.services.tasks': { # Log task output
|
||||
'level': 'DEBUG',
|
||||
'handlers': ['console'],
|
||||
'propagate': False,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -25,11 +25,14 @@ urlpatterns = [
|
||||
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
|
||||
name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('change-password/', auth_views.PasswordChangeView.as_view(), name='change_password'),
|
||||
path('password-change-done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
|
||||
path('', include('bookmarks.urls')),
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
import debug_toolbar
|
||||
|
||||
urlpatterns.append(path('__debug__/', include(debug_toolbar.urls)))
|
||||
|
||||
if ALLOW_REGISTRATION:
|
||||
|
10
supervisord.conf
Normal file
10
supervisord.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
[supervisord]
|
||||
user=root
|
||||
loglevel=info
|
||||
|
||||
[program:jobs]
|
||||
user=www-data
|
||||
command=sh background-tasks-wrapper.sh
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
@@ -1,5 +1,4 @@
|
||||
[uwsgi]
|
||||
http = :9090
|
||||
chdir = /etc/linkding
|
||||
module = siteroot.wsgi:application
|
||||
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
||||
@@ -11,6 +10,7 @@ vacuum=True
|
||||
stats = 127.0.0.1:9191
|
||||
uid = www-data
|
||||
gid = www-data
|
||||
buffer-size = 8192
|
||||
|
||||
if-env = LD_REQUEST_TIMEOUT
|
||||
http-timeout = %(_)
|
||||
|
@@ -1 +1 @@
|
||||
1.6.5
|
||||
1.9.0
|
||||
|
Reference in New Issue
Block a user