mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
78 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
aac8bf39b8 | ||
![]() |
49f648a908 | ||
![]() |
68c3c27b38 | ||
![]() |
792a19d15e | ||
![]() |
2de6d8151b | ||
![]() |
9e9d7ae7d2 | ||
![]() |
4e8a183082 | ||
![]() |
7719c5b1ba | ||
![]() |
e08bf9fd03 | ||
![]() |
a9bf111ff1 | ||
![]() |
54b0b32b80 | ||
![]() |
bd7a937430 | ||
![]() |
138dfe392c | ||
![]() |
d7f257b3c6 | ||
![]() |
ebbf0022bc | ||
![]() |
f4e3d724f0 | ||
![]() |
117160ea87 | ||
![]() |
e14458f5cd | ||
![]() |
179d0c26ca | ||
![]() |
5f5f470f52 | ||
![]() |
3e521493b9 | ||
![]() |
bbaa1669cd | ||
![]() |
fb779cf6d6 | ||
![]() |
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 |
@@ -8,6 +8,7 @@
|
||||
/static
|
||||
/build
|
||||
/out
|
||||
/.git
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
|
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>
|
119
CHANGELOG.md
119
CHANGELOG.md
@@ -1,5 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
## v1.10.1 (21/05/2022)
|
||||
### What's Changed
|
||||
* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1
|
||||
|
||||
---
|
||||
|
||||
## v1.10.0 (21/05/2022)
|
||||
### What's Changed
|
||||
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
|
||||
* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259
|
||||
* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261
|
||||
* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260
|
||||
* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226
|
||||
|
||||
### New Contributors
|
||||
* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253
|
||||
* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259
|
||||
* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0
|
||||
|
||||
---
|
||||
|
||||
## v1.9.0 (14/05/2022)
|
||||
### What's Changed
|
||||
* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248
|
||||
* Add whitespace after auto-completed tag by @sissbruecker in https://github.com/sissbruecker/linkding/pull/249
|
||||
* Bump django from 3.2.12 to 3.2.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/244
|
||||
* Add community helm chart reference to readme by @pascaliske in https://github.com/sissbruecker/linkding/pull/242
|
||||
* Feature: Shortcut key for new bookmark by @rithask in https://github.com/sissbruecker/linkding/pull/241
|
||||
* Clarify archive.org feature by @clach04 in https://github.com/sissbruecker/linkding/pull/229
|
||||
* Make Internet Archive integration opt-in by @sissbruecker in https://github.com/sissbruecker/linkding/pull/250
|
||||
|
||||
### New Contributors
|
||||
* @pascaliske made their first contribution in https://github.com/sissbruecker/linkding/pull/242
|
||||
* @rithask made their first contribution in https://github.com/sissbruecker/linkding/pull/241
|
||||
* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -100,25 +197,3 @@
|
||||
## v1.2.0 (09/01/2021)
|
||||
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
|
||||
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
|
||||
|
||||
---
|
||||
|
||||
## v1.1.1 (01/01/2021)
|
||||
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
||||
---
|
||||
|
||||
## v1.1.0 (31/12/2020)
|
||||
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
|
||||
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
|
||||
---
|
||||
|
||||
## v1.0.0 (31/12/2020)
|
||||
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
|
||||
- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)
|
||||
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
|
||||
- [**enhancement**] API for app development [#24](https://github.com/sissbruecker/linkding/issues/24)
|
||||
- [**enhancement**] Enhancement: detect duplicates at entry time [#23](https://github.com/sissbruecker/linkding/issues/23)
|
||||
- [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18)
|
||||
- [**enhancement**] Enhancement: better administration page [#4](https://github.com/sissbruecker/linkding/issues/4)
|
||||
- [**enhancement**] Bug: Navigation bar active link stays on add bookmark [#3](https://github.com/sissbruecker/linkding/issues/3)
|
||||
- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)
|
||||
|
126
README.md
126
README.md
@@ -1,12 +1,32 @@
|
||||
# linkding
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://github.com/sissbruecker/linkding">
|
||||
<img src="docs/header.svg" height="50">
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
*linkding* is a simple bookmark service that you can host yourself.
|
||||
It's designed be to be minimal, fast and easy to set up using Docker.
|
||||
## Overview
|
||||
- [Introduction](#introduction)
|
||||
- [Installation](#installation)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Docker Compose](#using-docker-compose)
|
||||
- [User Setup](#user-setup)
|
||||
- [Managed Hosting Options](#managed-hosting-options)
|
||||
- [Documentation](#documentation)
|
||||
- [Browser Extension](#browser-extension)
|
||||
- [Community](#community)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
|
||||
linkding is a simple bookmark service that you can host yourself.
|
||||
It's designed be to be minimal, fast, and easy to set up using Docker.
|
||||
|
||||
The name comes from:
|
||||
- *link* which is often used as a synonym for URLs and bookmarks in common language
|
||||
- *Ding* which is german for *thing*
|
||||
- ...so basically some thing for managing your links
|
||||
- *Ding* which is German for thing
|
||||
- ...so basically something for managing your links
|
||||
|
||||
**Feature Overview:**
|
||||
- Tags for organizing bookmarks
|
||||
@@ -14,8 +34,8 @@ The name comes from:
|
||||
- Bulk editing
|
||||
- Bookmark archive
|
||||
- Dark mode
|
||||
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
|
||||
- Automatically provides titles and descriptions of bookmarked websites
|
||||
- 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
|
||||
@@ -31,95 +51,85 @@ The name comes from:
|
||||
|
||||
## Installation
|
||||
|
||||
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
|
||||
There is also the option to set up the installation manually which I do not support, but can give some pointers on below.
|
||||
### Using Docker
|
||||
|
||||
### Docker setup
|
||||
|
||||
To install linkding using Docker you can just run the image from the Docker registry:
|
||||
```
|
||||
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
|
||||
```
|
||||
By default the application runs on port `9090`, but you can map it to a different host port by modifying the command above.
|
||||
By default, the application runs on port `9090`, you can map it to a different host port by modifying the port mapping in the command above. If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
|
||||
|
||||
However for **production use** you also want to mount a data folder on your system, so that the applications database is not stored in the container, but on your hosts file system. This is safer in case something happens to the container and makes it easier to update the container later on, or to run backups. To do so you can use the following extended command, where you replace `{host-data-folder}` with the absolute path to a folder on your system where you want to store the data:
|
||||
Note that the command above will store the linkding SQLite database in the container, which means that deleting the container, for example when upgrading the installation, will also remove the database. For hosting an actual installation you usually want to store the database on the host system, rather than in the container. To do so, run the following command, and replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database:
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
If everything completed successfully the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
|
||||
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
|
||||
|
||||
### Automated Docker setup
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
If you are using a Linux system you can use the following [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) for an automated setup. The script does basically everything described above, but also handles updating an existing container to a new application version (technically replaces the existing container with a new container built from a newer image, while mounting the same data folder).
|
||||
### Using Docker Compose
|
||||
|
||||
The script can be configured using shell variables - for more details have a look at the script itself.
|
||||
|
||||
### Docker-compose setup
|
||||
|
||||
To install linkding using docker-compose you can use the `docker-compose.yml` file. Copy the `.env.sample` file to `.env` and set your parameters, then run:
|
||||
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### User setup
|
||||
|
||||
Finally you need to create a user so that you can access the application. Replace the credentials in the following command and run it:
|
||||
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
**Docker-compose**
|
||||
**Docker Compose**
|
||||
```shell
|
||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
### Manual setup
|
||||
### Managed Hosting Options
|
||||
|
||||
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
|
||||
Self-hosting web applications on your own hardware (unfortunately) still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting linkding, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a (usually monthly) fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
|
||||
|
||||
### Hosting
|
||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding)
|
||||
|
||||
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
|
||||
## Documentation
|
||||
|
||||
## Options
|
||||
| Document | Description |
|
||||
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
|
||||
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
||||
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
|
||||
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
|
||||
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
|
||||
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||
|
||||
Check the [options document](docs/Options.md) on how to configure your linkding installation.
|
||||
## Browser Extension
|
||||
|
||||
## Administration
|
||||
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
|
||||
- [Mozilla Addon Store](https://addons.mozilla.org/de/firefox/addon/linkding-extension/)
|
||||
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
|
||||
Check the [administration document](docs/Admin.md) on how to use the admin app that is bundled with linkding.
|
||||
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
||||
|
||||
## Backups
|
||||
## Community
|
||||
|
||||
Check the [backups document](docs/backup.md) for options on how to create backups.
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||
|
||||
## How To
|
||||
|
||||
Check the [how-to document](docs/how-to.md) for tips and tricks around using linkding.
|
||||
|
||||
## API
|
||||
|
||||
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Import fails with `502 Bad Gateway`**
|
||||
|
||||
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.
|
||||
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds.
|
||||
|
||||
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
|
||||
|
||||
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.
|
||||
- [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)
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
|
||||
## Development
|
||||
|
||||
@@ -165,7 +175,3 @@ Start the Django development server with:
|
||||
python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
## Community
|
||||
|
||||
- [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)
|
||||
|
13
SECURITY.md
Normal file
13
SECURITY.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.10.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.
|
@@ -1,3 +1,5 @@
|
||||
from background_task.admin import TaskAdmin, CompletedTaskAdmin
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
@@ -7,7 +9,7 @@ from django.utils.translation import ngettext, gettext
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import TokenProxy
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile, Toast
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -59,6 +61,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 +97,17 @@ class AdminCustomUser(UserAdmin):
|
||||
return super(AdminCustomUser, self).get_inline_instances(request, obj)
|
||||
|
||||
|
||||
class AdminToast(admin.ModelAdmin):
|
||||
list_display = ('key', 'message', 'owner', 'acknowledged')
|
||||
search_fields = ('key', 'message')
|
||||
list_filter = ('owner__username',)
|
||||
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
linkding_admin_site.register(Task, TaskAdmin)
|
||||
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)
|
||||
|
@@ -19,6 +19,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
'description',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'is_archived',
|
||||
'tag_names',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
@@ -33,6 +34,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
# Override optional char fields to provide default value
|
||||
title = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
description = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
is_archived = serializers.BooleanField(required=False, default=False)
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
|
||||
@@ -41,14 +43,21 @@ 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'], ' ')
|
||||
bookmark.is_archived = validated_data['is_archived']
|
||||
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'], ' ')
|
||||
# Update fields if they were provided in the payload
|
||||
for key in ['url', 'title', 'description']:
|
||||
if key in validated_data:
|
||||
setattr(instance, key, validated_data[key])
|
||||
|
||||
# Use tag string from payload, or use bookmark's current tags as fallback
|
||||
tag_string = build_tag_string(instance.tag_names)
|
||||
if 'tag_names' in validated_data:
|
||||
tag_string = build_tag_string(validated_data['tag_names'])
|
||||
|
||||
return update_bookmark(instance, tag_string, self.context['user'])
|
||||
|
||||
|
||||
|
@@ -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,
|
||||
}
|
@@ -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)
|
||||
|
||||
@@ -91,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):
|
||||
@@ -116,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())
|
||||
@@ -137,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)
|
||||
|
@@ -55,6 +55,12 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
|
||||
tags__name__iexact=tag_name
|
||||
)
|
||||
|
||||
# Untagged bookmarks
|
||||
if query['untagged']:
|
||||
query_set = query_set.filter(
|
||||
tags=None
|
||||
)
|
||||
|
||||
# Sort by date added
|
||||
query_set = query_set.order_by('-date_added')
|
||||
|
||||
@@ -90,11 +96,15 @@ def _parse_query_string(query_string):
|
||||
keywords = query_string.strip().split(' ')
|
||||
keywords = [word for word in keywords if word]
|
||||
|
||||
search_terms = [word for word in keywords if word[0] != '#']
|
||||
search_terms = [word for word in keywords if word[0] != '#' and word[0] != '!']
|
||||
tag_names = [word[1:] for word in keywords if word[0] == '#']
|
||||
tag_names = unique(tag_names, str.lower)
|
||||
|
||||
# Special search commands
|
||||
untagged = '!untagged' in keywords
|
||||
|
||||
return {
|
||||
'search_terms': search_terms,
|
||||
'tag_names': tag_names,
|
||||
'untagged': untagged,
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
_update_bookmark_tags(bookmark, tag_string, current_user)
|
||||
bookmark.save()
|
||||
# Create snapshot on web archive
|
||||
tasks.create_web_archive_snapshot(bookmark.id, False)
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -47,7 +47,7 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
bookmark.save()
|
||||
# Update web archive snapshot, if URL changed
|
||||
if has_url_changed:
|
||||
tasks.create_web_archive_snapshot(bookmark.id, True)
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -90,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:
|
||||
@@ -103,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:
|
||||
@@ -125,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,13 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.models import Bookmark, Tag, 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__)
|
||||
@@ -20,8 +20,39 @@ class ImportResult:
|
||||
failed: int = 0
|
||||
|
||||
|
||||
class TagCache:
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
self.cache = dict()
|
||||
# Init cache with all existing tags for that user
|
||||
tags = Tag.objects.filter(owner=user)
|
||||
for tag in tags:
|
||||
self.put(tag)
|
||||
|
||||
def get(self, tag_name: str):
|
||||
tag_name_lowercase = tag_name.lower()
|
||||
if tag_name_lowercase in self.cache:
|
||||
return self.cache[tag_name_lowercase]
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_all(self, tag_names: List[str]):
|
||||
result = []
|
||||
for tag_name in tag_names:
|
||||
tag = self.get(tag_name)
|
||||
# Prevent returning duplicates
|
||||
if not (tag in result):
|
||||
result.append(tag)
|
||||
|
||||
return result
|
||||
|
||||
def put(self, tag: Tag):
|
||||
self.cache[tag.name.lower()] = tag
|
||||
|
||||
|
||||
def import_netscape_html(html: str, user: User):
|
||||
result = ImportResult()
|
||||
import_start = timezone.now()
|
||||
|
||||
try:
|
||||
netscape_bookmarks = parse(html)
|
||||
@@ -29,26 +60,130 @@ def import_netscape_html(html: str, user: User):
|
||||
logging.exception('Could not read bookmarks file.')
|
||||
raise
|
||||
|
||||
parse_end = timezone.now()
|
||||
logger.debug(f'Parse duration: {parse_end - import_start}')
|
||||
|
||||
# Create and cache all tags beforehand
|
||||
_create_missing_tags(netscape_bookmarks, user)
|
||||
tag_cache = TagCache(user)
|
||||
|
||||
# Split bookmarks to import into batches, to keep memory usage for bulk operations manageable
|
||||
batches = _get_batches(netscape_bookmarks, 200)
|
||||
for batch in batches:
|
||||
_import_batch(batch, user, tag_cache, result)
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f'Import duration: {end - import_start}')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User):
|
||||
tag_cache = TagCache(user)
|
||||
tags_to_create = []
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
for tag_name in tag_names:
|
||||
tag = tag_cache.get(tag_name)
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name, owner=user)
|
||||
tag.date_added = timezone.now()
|
||||
tags_to_create.append(tag)
|
||||
|
||||
Tag.objects.bulk_create(tags_to_create)
|
||||
|
||||
|
||||
def _get_batches(items: List, batch_size: int):
|
||||
batches = []
|
||||
offset = 0
|
||||
num_items = len(items)
|
||||
|
||||
while offset < num_items:
|
||||
batch = items[offset:min(offset + batch_size, num_items)]
|
||||
if len(batch) > 0:
|
||||
batches.append(batch)
|
||||
offset = offset + batch_size
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_cache: TagCache, result: ImportResult):
|
||||
# Query existing bookmarks
|
||||
batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
|
||||
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
||||
|
||||
# Create or update bookmarks from parsed Netscape bookmarks
|
||||
bookmarks_to_create = []
|
||||
bookmarks_to_update = []
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
result.total = result.total + 1
|
||||
try:
|
||||
_import_bookmark_tag(netscape_bookmark, user)
|
||||
# Lookup existing bookmark by URL, or create new bookmark if there is no bookmark for that URL yet
|
||||
bookmark = next(
|
||||
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(owner=user)
|
||||
is_update = False
|
||||
else:
|
||||
is_update = True
|
||||
# Copy data from parsed bookmark
|
||||
_copy_bookmark_data(netscape_bookmark, bookmark)
|
||||
# Validate bookmark fields, exclude owner to prevent n+1 database query,
|
||||
# also there is no specific validation on owner
|
||||
bookmark.clean_fields(exclude=['owner'])
|
||||
# Schedule for update or insert
|
||||
if is_update:
|
||||
bookmarks_to_update.append(bookmark)
|
||||
else:
|
||||
bookmarks_to_create.append(bookmark)
|
||||
|
||||
result.success = result.success + 1
|
||||
except:
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
||||
result.failed = result.failed + 1
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user.id)
|
||||
# Bulk update bookmarks in DB
|
||||
Bookmark.objects.bulk_update(bookmarks_to_update,
|
||||
['url', 'date_added', 'date_modified', 'unread', 'title', 'description', 'owner'])
|
||||
# Bulk insert new bookmarks into DB
|
||||
Bookmark.objects.bulk_create(bookmarks_to_create)
|
||||
|
||||
return result
|
||||
# Bulk assign tags
|
||||
# In Django 3, bulk_create does not return the auto-generated IDs when bulk inserting,
|
||||
# so we have to reload the inserted bookmarks, and match them to the parsed bookmarks by URL
|
||||
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
||||
|
||||
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||
relationships = []
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
# Lookup bookmark by URL again
|
||||
bookmark = next(
|
||||
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
|
||||
|
||||
if not bookmark:
|
||||
# Something is wrong, we should have just created this bookmark
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||
logging.warning(
|
||||
f'Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL.')
|
||||
|
||||
# Get tag models by string, schedule inserts for bookmark -> tag associations
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
tags = tag_cache.get_all(tag_names)
|
||||
for tag in tags:
|
||||
relationships.append(BookmarkToTagRelationShip(bookmark=bookmark, tag=tag))
|
||||
|
||||
# Insert all bookmark -> tag associations at once, should ignore errors if association already exists
|
||||
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
||||
|
||||
|
||||
def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
# Either modify existing bookmark for the URL or create new one
|
||||
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
|
||||
|
||||
def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark):
|
||||
bookmark.url = netscape_bookmark.href
|
||||
if netscape_bookmark.date_added:
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
@@ -56,24 +191,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
bookmark.date_added = timezone.now()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
bookmark.unread = False
|
||||
bookmark.title = netscape_bookmark.title
|
||||
if netscape_bookmark.title:
|
||||
bookmark.title = netscape_bookmark.title
|
||||
if netscape_bookmark.description:
|
||||
bookmark.description = netscape_bookmark.description
|
||||
bookmark.owner = user
|
||||
|
||||
bookmark.full_clean()
|
||||
bookmark.save()
|
||||
|
||||
# Set tags
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
|
||||
bookmark.tags.set(tags)
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def _get_or_create_bookmark(url: str, user: User):
|
||||
try:
|
||||
return Bookmark.objects.get(url=url, owner=user)
|
||||
except Bookmark.DoesNotExist:
|
||||
return Bookmark()
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pyparsing as pp
|
||||
from html.parser import HTMLParser
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -12,60 +12,72 @@ class NetscapeBookmark:
|
||||
tag_string: str
|
||||
|
||||
|
||||
def extract_bookmark_link(tag):
|
||||
href = tag[0].href
|
||||
title = tag[0].text
|
||||
tag_string = tag[0].tags
|
||||
date_added = tag[0].add_date
|
||||
class BookmarkParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.bookmarks = []
|
||||
|
||||
return {
|
||||
'href': href,
|
||||
'title': title,
|
||||
'tag_string': tag_string,
|
||||
'date_added': date_added
|
||||
}
|
||||
self.current_tag = None
|
||||
self.bookmark = None
|
||||
self.href = ''
|
||||
self.add_date = ''
|
||||
self.tags = ''
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list):
|
||||
name = 'handle_start_' + tag.lower()
|
||||
if name in dir(self):
|
||||
getattr(self, name)({k.lower(): v for k, v in attrs})
|
||||
self.current_tag = tag
|
||||
|
||||
def extract_bookmark(tag):
|
||||
link = tag[0].link
|
||||
description = tag[0].description
|
||||
description = description[0] if description else ''
|
||||
def handle_endtag(self, tag: str):
|
||||
name = 'handle_end_' + tag.lower()
|
||||
if name in dir(self):
|
||||
getattr(self, name)()
|
||||
self.current_tag = None
|
||||
|
||||
return {
|
||||
'link': link,
|
||||
'description': description,
|
||||
}
|
||||
def handle_data(self, data):
|
||||
name = f'handle_{self.current_tag}_data'
|
||||
if name in dir(self):
|
||||
getattr(self, name)(data)
|
||||
|
||||
def handle_end_dl(self):
|
||||
self.add_bookmark()
|
||||
|
||||
def extract_description(tag):
|
||||
return tag[0].strip()
|
||||
def handle_start_dt(self, attrs: Dict[str, str]):
|
||||
self.add_bookmark()
|
||||
|
||||
|
||||
# define grammar
|
||||
dt_start, _ = pp.makeHTMLTags("DT")
|
||||
dd_start, _ = pp.makeHTMLTags("DD")
|
||||
a_start, a_end = pp.makeHTMLTags("A")
|
||||
bookmark_link_tag = pp.Group(a_start + a_start.tag_body("text") + a_end.suppress())
|
||||
bookmark_link_tag.addParseAction(extract_bookmark_link)
|
||||
bookmark_description_tag = dd_start.suppress() + pp.SkipTo(pp.anyOpenTag | pp.anyCloseTag)("description")
|
||||
bookmark_description_tag.addParseAction(extract_description)
|
||||
bookmark_tag = pp.Group(dt_start + bookmark_link_tag("link") + pp.ZeroOrMore(bookmark_description_tag)("description"))
|
||||
bookmark_tag.addParseAction(extract_bookmark)
|
||||
|
||||
|
||||
def parse(html: str) -> [NetscapeBookmark]:
|
||||
matches = bookmark_tag.searchString(html)
|
||||
bookmarks = []
|
||||
|
||||
for match in matches:
|
||||
bookmark_match = match[0]
|
||||
bookmark = NetscapeBookmark(
|
||||
href=bookmark_match['link']['href'],
|
||||
title=bookmark_match['link']['title'],
|
||||
description=bookmark_match['description'],
|
||||
tag_string=bookmark_match['link']['tag_string'],
|
||||
date_added=bookmark_match['link']['date_added'],
|
||||
def handle_start_a(self, attrs: Dict[str, str]):
|
||||
vars(self).update(attrs)
|
||||
self.bookmark = NetscapeBookmark(
|
||||
href=self.href,
|
||||
title='',
|
||||
description='',
|
||||
date_added=self.add_date,
|
||||
tag_string=self.tags,
|
||||
)
|
||||
bookmarks.append(bookmark)
|
||||
|
||||
return bookmarks
|
||||
def handle_a_data(self, data):
|
||||
self.title = data.strip()
|
||||
|
||||
def handle_dd_data(self, data):
|
||||
self.description = data.strip()
|
||||
|
||||
def add_bookmark(self):
|
||||
if self.bookmark:
|
||||
self.bookmark.title = self.title
|
||||
self.bookmark.description = self.description
|
||||
self.bookmarks.append(self.bookmark)
|
||||
self.bookmark = None
|
||||
self.href = ''
|
||||
self.add_date = ''
|
||||
self.tags = ''
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
|
||||
|
||||
def parse(html: str) -> List[NetscapeBookmark]:
|
||||
parser = BookmarkParser()
|
||||
parser.feed(html)
|
||||
return parser.bookmarks
|
||||
|
@@ -4,30 +4,29 @@ import waybackpy
|
||||
from background_task import background
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def when_background_tasks_enabled(fn):
|
||||
def wrapper(*args, **kwargs):
|
||||
if settings.LD_DISABLE_BACKGROUND_TASKS:
|
||||
return
|
||||
return fn(*args, **kwargs)
|
||||
def is_web_archive_integration_active(user: User) -> bool:
|
||||
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
web_archive_integration_enabled = \
|
||||
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
|
||||
# Expose attributes from wrapped TaskProxy function
|
||||
attrs = vars(fn)
|
||||
for key, value in attrs.items():
|
||||
setattr(wrapper, key, value)
|
||||
|
||||
return wrapper
|
||||
return background_tasks_enabled and web_archive_integration_enabled
|
||||
|
||||
|
||||
def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):
|
||||
if is_web_archive_integration_active(user):
|
||||
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
||||
|
||||
|
||||
@when_background_tasks_enabled
|
||||
@background()
|
||||
def create_web_archive_snapshot(bookmark_id: int, force_update: bool):
|
||||
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
@@ -52,11 +51,15 @@ def create_web_archive_snapshot(bookmark_id: int, force_update: bool):
|
||||
logger.debug(f'Successfully created web archive link for bookmark: {bookmark}...')
|
||||
|
||||
|
||||
@when_background_tasks_enabled
|
||||
def schedule_bookmarks_without_snapshots(user: User):
|
||||
if is_web_archive_integration_active(user):
|
||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
def schedule_bookmarks_without_snapshots(user_id: int):
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
|
||||
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
create_web_archive_snapshot(bookmark.id, False)
|
||||
_create_web_archive_snapshot_task(bookmark.id, False)
|
||||
|
@@ -34,7 +34,8 @@ def load_website_metadata(url: str):
|
||||
|
||||
|
||||
def load_page(url: str):
|
||||
r = requests.get(url, timeout=10)
|
||||
headers = fake_request_headers()
|
||||
r = requests.get(url, timeout=10, headers=headers)
|
||||
|
||||
# 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
|
||||
@@ -42,3 +43,13 @@ def load_page(url: str):
|
||||
# before trying to determine one
|
||||
results = from_bytes(r.content)
|
||||
return str(results.best())
|
||||
|
||||
|
||||
def fake_request_headers():
|
||||
return {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Dnt": "1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
@@ -5,4 +5,4 @@ from bookmarks.services import tasks
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def user_logged_in(sender, request, user, **kwargs):
|
||||
tasks.schedule_bookmarks_without_snapshots(user.id)
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,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;
|
||||
|
@@ -11,4 +11,8 @@
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
section.about table {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
@@ -15,3 +15,7 @@
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
@@ -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,15 +26,17 @@
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% bookmark_list bookmarks return_url link_target %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Tag list #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<div class="content-area-header">
|
||||
<div class="content-area-header align-baseline">
|
||||
<h2>Tags</h2>
|
||||
<div class="spacer"></div>
|
||||
<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>
|
||||
</div>
|
||||
{% tag_cloud tags %}
|
||||
</section>
|
||||
|
@@ -9,7 +9,7 @@
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title truncate">
|
||||
<a href="{{ bookmark.url }}" target="_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 %}
|
||||
@@ -30,7 +30,7 @@
|
||||
<span class="date-label text-gray text-sm">
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on web archive" target="_blank" rel="noopener">
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
@@ -44,7 +44,7 @@
|
||||
<span class="date-label text-gray text-sm">
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on web archive" target="_blank" rel="noopener">
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
@@ -57,14 +57,14 @@
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Unarchive</a>
|
||||
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive</button>
|
||||
{% else %}
|
||||
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Archive</a>
|
||||
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Archive</button>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove</a>
|
||||
<button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@@ -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,15 +26,17 @@
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% bookmark_list bookmarks return_url link_target %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Tag list #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<div class="content-area-header">
|
||||
<div class="content-area-header align-baseline">
|
||||
<h2>Tags</h2>
|
||||
<div class="spacer"></div>
|
||||
<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>
|
||||
</div>
|
||||
{% tag_cloud tags %}
|
||||
</section>
|
||||
|
@@ -27,19 +27,34 @@
|
||||
{% 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">
|
||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
<div class="toast">
|
||||
{{ toast.message }}
|
||||
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</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_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 %}
|
@@ -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">
|
||||
@@ -70,13 +98,29 @@
|
||||
</section>
|
||||
|
||||
{# About section #}
|
||||
<section class="content-area">
|
||||
<section class="content-area about">
|
||||
<h2>About</h2>
|
||||
<p>Version: {{ app_version }}</p>
|
||||
<p>
|
||||
Code: <a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a>
|
||||
</p>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
<td>{{ version_info }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3" style="vertical-align: top">Links</td>
|
||||
<td><a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/sissbruecker/linkding#documentation"
|
||||
target="_blank">Documentation</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||
target="_blank">Changelog</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
@@ -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,5 +1,7 @@
|
||||
import random
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
@@ -81,12 +83,53 @@ class LinkdingApiTestCase(APITestCase):
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
def patch(self, url, data=None, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.patch(url, data, format='json')
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
def delete(self, url, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
|
||||
class BookmarkHtmlTag:
|
||||
def __init__(self, href: str = '', title: str = '', description: str = '', add_date: str = '', tags: str = ''):
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.add_date = add_date
|
||||
self.tags = tags
|
||||
|
||||
|
||||
class ImportTestMixin:
|
||||
def render_tag(self, tag: BookmarkHtmlTag):
|
||||
return f'''
|
||||
<DT>
|
||||
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
||||
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
||||
{f'TAGS="{tag.tags}"' if tag.tags else ''}>
|
||||
{tag.title if tag.title else ''}
|
||||
</A>
|
||||
{f'<DD>{tag.description}' if tag.description else ''}
|
||||
'''
|
||||
|
||||
def render_html(self, tags: List[BookmarkHtmlTag] = None, tags_html: str = ''):
|
||||
if tags:
|
||||
rendered_tags = [self.render_tag(tag) for tag in tags]
|
||||
tags_html = '\n'.join(rendered_tags)
|
||||
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>
|
||||
{tags_html}
|
||||
</DL><p>
|
||||
'''
|
||||
|
||||
|
||||
_words = [
|
||||
'quasi',
|
||||
'consequatur',
|
||||
|
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,34 @@ 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')
|
||||
|
||||
def test_should_show_link_for_untagged_bookmarks(self):
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
self.assertContains(response, '<a href="?q=!untagged" class="text-gray text-sm">Show Untagged</a>')
|
||||
|
@@ -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,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
expectation['description'] = bookmark.description
|
||||
expectation['website_title'] = bookmark.website_title
|
||||
expectation['website_description'] = bookmark.website_description
|
||||
expectation['is_archived'] = bookmark.is_archived
|
||||
expectation['tag_names'] = tag_names
|
||||
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
|
||||
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
|
||||
@@ -49,6 +50,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
'url': 'https://example.com/',
|
||||
'title': 'Test title',
|
||||
'description': 'Test description',
|
||||
'is_archived': False,
|
||||
'tag_names': ['tag1', 'tag2']
|
||||
}
|
||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||
@@ -56,10 +58,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.url, data['url'])
|
||||
self.assertEqual(bookmark.title, data['title'])
|
||||
self.assertEqual(bookmark.description, data['description'])
|
||||
self.assertFalse(bookmark.is_archived, data['is_archived'])
|
||||
self.assertEqual(bookmark.tags.count(), 2)
|
||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
|
||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||
|
||||
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)
|
||||
@@ -80,6 +95,30 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
|
||||
|
||||
def test_create_archived_bookmark(self):
|
||||
data = {
|
||||
'url': 'https://example.com/',
|
||||
'title': 'Test title',
|
||||
'description': 'Test description',
|
||||
'is_archived': True,
|
||||
'tag_names': ['tag1', 'tag2']
|
||||
}
|
||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data['url'])
|
||||
self.assertEqual(bookmark.url, data['url'])
|
||||
self.assertEqual(bookmark.title, data['title'])
|
||||
self.assertEqual(bookmark.description, data['description'])
|
||||
self.assertTrue(bookmark.is_archived)
|
||||
self.assertEqual(bookmark.tags.count(), 2)
|
||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
|
||||
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||
|
||||
def test_create_bookmark_minimal_payload_does_not_archive(self):
|
||||
data = {'url': 'https://example.com/'}
|
||||
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data['url'])
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
def test_get_bookmark(self):
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
@@ -92,6 +131,56 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||
self.assertEqual(updated_bookmark.url, data['url'])
|
||||
|
||||
def test_update_bookmark_fails_without_required_fields(self):
|
||||
data = {'title': 'https://example.com/'}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
|
||||
data = {'url': 'https://example.com/'}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||
self.assertEqual(updated_bookmark.url, data['url'])
|
||||
self.assertEqual(updated_bookmark.title, '')
|
||||
self.assertEqual(updated_bookmark.description, '')
|
||||
self.assertEqual(updated_bookmark.tag_names, [])
|
||||
|
||||
def test_patch_bookmark(self):
|
||||
data = {'url': 'https://example.com'}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
self.bookmark1.refresh_from_db()
|
||||
self.assertEqual(self.bookmark1.url, data['url'])
|
||||
|
||||
data = {'title': 'Updated title'}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
self.bookmark1.refresh_from_db()
|
||||
self.assertEqual(self.bookmark1.title, data['title'])
|
||||
|
||||
data = {'description': 'Updated description'}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
self.bookmark1.refresh_from_db()
|
||||
self.assertEqual(self.bookmark1.description, data['description'])
|
||||
|
||||
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
self.bookmark1.refresh_from_db()
|
||||
tag_names = [tag.name for tag in self.bookmark1.tags.all()]
|
||||
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
|
||||
|
||||
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||
self.assertEqual(updated_bookmark.url, self.bookmark1.url)
|
||||
self.assertEqual(updated_bookmark.title, self.bookmark1.title)
|
||||
self.assertEqual(updated_bookmark.description, self.bookmark1.description)
|
||||
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
|
||||
|
||||
def test_delete_bookmark(self):
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
@@ -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,11 +44,23 @@ 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 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()
|
||||
@@ -36,55 +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="date-label text-gray text-sm">
|
||||
<span>{formatted_date}</span>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</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_template([bookmark])
|
||||
html = self.render_default_template([bookmark])
|
||||
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<a href="{bookmark.web_archive_snapshot_url}"
|
||||
title="Show snapshot on web archive" target="_blank" rel="noopener">
|
||||
<span>{formatted_date}</span>
|
||||
<span>∞</span>
|
||||
</a>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
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="date-label text-gray text-sm">
|
||||
<span>1 week ago</span>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</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_template([bookmark])
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<a href="{bookmark.web_archive_snapshot_url}"
|
||||
title="Show snapshot on web archive" target="_blank" rel="noopener">
|
||||
<span>1 week ago</span>
|
||||
<span>∞</span>
|
||||
</a>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
''', html)
|
||||
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')
|
||||
|
@@ -21,23 +21,23 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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)
|
||||
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
|
||||
|
||||
mock_create_web_archive_snapshot.assert_called_once_with(bookmark.id, False)
|
||||
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
|
||||
|
||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.url = 'https://example.com/updated'
|
||||
update_bookmark(bookmark, 'tag1 tag2', self.user)
|
||||
update_bookmark(bookmark, 'tag1,tag2', self.user)
|
||||
|
||||
mock_create_web_archive_snapshot.assert_called_once_with(bookmark.id, True)
|
||||
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, True)
|
||||
|
||||
def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.title = 'updated title'
|
||||
update_bookmark(bookmark, 'tag1 tag2', self.user)
|
||||
update_bookmark(bookmark, 'tag1,tag2', self.user)
|
||||
|
||||
mock_create_web_archive_snapshot.assert_not_called()
|
||||
|
||||
@@ -216,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()
|
||||
@@ -232,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()
|
||||
@@ -257,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()
|
||||
@@ -275,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()
|
||||
@@ -293,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])
|
||||
@@ -307,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()
|
||||
@@ -325,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()
|
||||
@@ -343,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()
|
||||
@@ -361,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(), [])
|
||||
|
@@ -5,8 +5,8 @@ from background_task.models import Task
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.services.tasks import create_web_archive_snapshot, schedule_bookmarks_without_snapshots
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ class MockWaybackUrlWithSaveError:
|
||||
|
||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
user.profile.save()
|
||||
|
||||
@disable_logging
|
||||
def run_pending_task(self, task_function):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
@@ -48,16 +53,16 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
|
||||
create_web_archive_snapshot(bookmark.id, False)
|
||||
self.run_pending_task(create_web_archive_snapshot)
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
|
||||
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')) as mock_wayback_url:
|
||||
create_web_archive_snapshot(123, False)
|
||||
self.run_pending_task(create_web_archive_snapshot)
|
||||
tasks._create_web_archive_snapshot_task(123, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
mock_wayback_url.assert_not_called()
|
||||
|
||||
@@ -67,15 +72,15 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
with patch.object(waybackpy, 'Url',
|
||||
return_value=MockWaybackUrlWithSaveError()):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
create_web_archive_snapshot(bookmark.id, False)
|
||||
self.run_pending_task(create_web_archive_snapshot)
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
|
||||
create_web_archive_snapshot(bookmark.id, False)
|
||||
self.run_pending_task(create_web_archive_snapshot)
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
@@ -84,8 +89,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
|
||||
create_web_archive_snapshot(bookmark.id, True)
|
||||
self.run_pending_task(create_web_archive_snapshot)
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
|
||||
@@ -93,7 +98,16 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
create_web_archive_snapshot(bookmark.id, False)
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(self):
|
||||
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
|
||||
self.user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
@@ -104,9 +118,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
|
||||
schedule_bookmarks_without_snapshots(user.id)
|
||||
self.run_pending_task(schedule_bookmarks_without_snapshots)
|
||||
self.run_all_pending_tasks(create_web_archive_snapshot)
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
@@ -118,9 +132,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
|
||||
schedule_bookmarks_without_snapshots(user.id)
|
||||
self.run_pending_task(schedule_bookmarks_without_snapshots)
|
||||
self.run_all_pending_tasks(create_web_archive_snapshot)
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
for bookmark in Bookmark.objects.all():
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
@@ -136,9 +150,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
|
||||
schedule_bookmarks_without_snapshots(user.id)
|
||||
self.run_pending_task(schedule_bookmarks_without_snapshots)
|
||||
self.run_all_pending_tasks(create_web_archive_snapshot)
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
for bookmark in Bookmark.objects.all().filter(owner=user):
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
|
||||
@@ -148,7 +162,13 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(self):
|
||||
user = self.get_or_create_test_user()
|
||||
schedule_bookmarks_without_snapshots(user.id)
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(self):
|
||||
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
|
||||
self.user.profile.save()
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
@@ -1,45 +1,233 @@
|
||||
from typing import List
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, parse_tag_string
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services.importer import import_netscape_html
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, ImportTestMixin, BookmarkHtmlTag, disable_logging
|
||||
from bookmarks.utils import parse_timestamp
|
||||
|
||||
|
||||
class ImporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
|
||||
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 assertBookmarksImported(self, html_tags: List[BookmarkHtmlTag]):
|
||||
for html_tag in html_tags:
|
||||
bookmark = Bookmark.objects.get(url=html_tag.href)
|
||||
self.assertIsNotNone(bookmark)
|
||||
|
||||
self.assertEqual(bookmark.title, html_tag.title)
|
||||
self.assertEqual(bookmark.description, html_tag.description)
|
||||
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
|
||||
|
||||
tag_names = parse_tag_string(html_tag.tags)
|
||||
|
||||
# Check assigned tags
|
||||
for tag_name in tag_names:
|
||||
tag = next(
|
||||
(tag for tag in bookmark.tags.all() if tag.name == tag_name), None)
|
||||
self.assertIsNotNone(tag)
|
||||
|
||||
def test_import(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
|
||||
add_date='2', tags=''),
|
||||
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
|
||||
add_date='3', tags='bar-tag, other-tag'),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
result = import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
# Check result
|
||||
self.assertEqual(result.total, 3)
|
||||
self.assertEqual(result.success, 3)
|
||||
self.assertEqual(result.failed, 0)
|
||||
|
||||
# Check bookmarks
|
||||
bookmarks = Bookmark.objects.all()
|
||||
self.assertEqual(len(bookmarks), 3)
|
||||
self.assertBookmarksImported(html_tags)
|
||||
|
||||
def test_synchronize(self):
|
||||
# Initial import
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
|
||||
add_date='2', tags=''),
|
||||
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
|
||||
add_date='3', tags='bar-tag, other-tag'),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
# Change data, add some new data
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Updated Example title',
|
||||
description='Updated Example description', add_date='111', tags='updated-example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Updated Foo title', description='Updated Foo description',
|
||||
add_date='222', tags='new-tag'),
|
||||
BookmarkHtmlTag(href='https://bar.com', title='Updated Bar title', description='Updated Bar description',
|
||||
add_date='333', tags='updated-bar-tag, updated-other-tag'),
|
||||
BookmarkHtmlTag(href='https://baz.com', add_date='444', tags='baz-tag')
|
||||
]
|
||||
|
||||
# Import updated data
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
result = import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
# Check result
|
||||
self.assertEqual(result.total, 4)
|
||||
self.assertEqual(result.success, 4)
|
||||
self.assertEqual(result.failed, 0)
|
||||
|
||||
# Check bookmarks
|
||||
bookmarks = Bookmark.objects.all()
|
||||
self.assertEqual(len(bookmarks), 4)
|
||||
self.assertBookmarksImported(html_tags)
|
||||
|
||||
def test_import_with_some_invalid_bookmarks(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com'),
|
||||
# Invalid URL
|
||||
BookmarkHtmlTag(href='foo.com'),
|
||||
# No URL
|
||||
BookmarkHtmlTag(),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
result = import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
# Check result
|
||||
self.assertEqual(result.total, 3)
|
||||
self.assertEqual(result.success, 1)
|
||||
self.assertEqual(result.failed, 2)
|
||||
|
||||
# Check bookmarks
|
||||
bookmarks = Bookmark.objects.all()
|
||||
self.assertEqual(len(bookmarks), 1)
|
||||
self.assertBookmarksImported(html_tags[1:1])
|
||||
|
||||
def test_import_tags(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
|
||||
BookmarkHtmlTag(href='https://foo.com', tags='tag2'),
|
||||
BookmarkHtmlTag(href='https://bar.com', tags='tag3'),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Tag.objects.count(), 3)
|
||||
|
||||
def test_create_missing_tags(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
|
||||
BookmarkHtmlTag(href='https://foo.com', tags='tag2'),
|
||||
BookmarkHtmlTag(href='https://bar.com', tags='tag3'),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
html_tags.append(
|
||||
BookmarkHtmlTag(href='https://baz.com', tags='tag4')
|
||||
)
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Tag.objects.count(), 4)
|
||||
|
||||
def test_should_append_tags_to_bookmark_when_reimporting_with_different_tags(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', tags='tag1'),
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
html_tags.append(
|
||||
BookmarkHtmlTag(href='https://example.com', tags='tag2, tag3')
|
||||
)
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(Bookmark.objects.all()[0].tags.all().count(), 3)
|
||||
|
||||
@override_settings(USE_TZ=False)
|
||||
def test_use_current_date_when_no_add_date(self):
|
||||
test_html = self.render_html(tags_html=f'''
|
||||
<DT><A HREF="https://example.com">Example.com</A>
|
||||
<DD>Example.com
|
||||
''')
|
||||
|
||||
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1))
|
||||
|
||||
def test_keep_title_if_imported_bookmark_has_empty_title(self):
|
||||
test_html = self.render_html(tags=[
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example.com')
|
||||
])
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
test_html = self.render_html(tags=[
|
||||
BookmarkHtmlTag(href='https://example.com')
|
||||
])
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(Bookmark.objects.all()[0].title, 'Example.com')
|
||||
|
||||
def test_keep_description_if_imported_bookmark_has_empty_description(self):
|
||||
test_html = self.render_html(tags=[
|
||||
BookmarkHtmlTag(href='https://example.com', description='Example.com')
|
||||
])
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
test_html = self.render_html(tags=[
|
||||
BookmarkHtmlTag(href='https://example.com')
|
||||
])
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(Bookmark.objects.all()[0].description, 'Example.com')
|
||||
|
||||
def test_replace_whitespace_in_tag_names(self):
|
||||
test_html = self.render_html(tags_html=f'''
|
||||
<DT><A HREF="https://example.com" 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>
|
||||
test_html = self.render_html(tags_html=f'''
|
||||
<DT><A HREF="">Empty URL</A>
|
||||
<DD>Empty URL
|
||||
<!-- Missing URL -->
|
||||
<DT><A ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Missing URL</A>
|
||||
<DT><A>Missing URL</A>
|
||||
<DD>Missing URL
|
||||
''')
|
||||
|
||||
import_result = import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 0)
|
||||
self.assertEqual(import_result.success, 0)
|
||||
self.assertEqual(import_result.failed, 2)
|
||||
|
||||
def test_schedule_snapshot_creation(self):
|
||||
user = self.get_or_create_test_user()
|
||||
test_html = self.create_import_html('')
|
||||
test_html = self.render_html(tags_html='')
|
||||
|
||||
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
|
||||
import_netscape_html(test_html, user)
|
||||
|
||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user.id)
|
||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
||||
|
122
bookmarks/tests/test_parser.py
Normal file
122
bookmarks/tests/test_parser.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from typing import List
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services.parser import NetscapeBookmark
|
||||
from bookmarks.services.parser import parse
|
||||
from bookmarks.tests.helpers import ImportTestMixin, BookmarkHtmlTag
|
||||
|
||||
|
||||
class ParserTestCase(TestCase, ImportTestMixin):
|
||||
def assertTagsEqual(self, bookmarks: List[NetscapeBookmark], html_tags: List[BookmarkHtmlTag]):
|
||||
self.assertEqual(len(bookmarks), len(html_tags))
|
||||
for bookmark in bookmarks:
|
||||
html_tag = html_tags[bookmarks.index(bookmark)]
|
||||
self.assertEqual(bookmark.href, html_tag.href)
|
||||
self.assertEqual(bookmark.title, html_tag.title)
|
||||
self.assertEqual(bookmark.date_added, html_tag.add_date)
|
||||
self.assertEqual(bookmark.description, html_tag.description)
|
||||
self.assertEqual(bookmark.tag_string, html_tag.tags)
|
||||
|
||||
def test_parse_bookmarks(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
|
||||
add_date='2', tags=''),
|
||||
BookmarkHtmlTag(href='https://bar.com', title='Bar title', description='Bar description',
|
||||
add_date='3', tags='bar-tag, other-tag'),
|
||||
]
|
||||
html = self.render_html(html_tags)
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertTagsEqual(bookmarks, html_tags)
|
||||
|
||||
def test_no_bookmarks(self):
|
||||
html = self.render_html()
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertEqual(bookmarks, [])
|
||||
|
||||
def test_reset_properties_after_adding_bookmark(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='', title='', description='',
|
||||
add_date='', tags='')
|
||||
]
|
||||
html = self.render_html(html_tags)
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertTagsEqual(bookmarks, html_tags)
|
||||
|
||||
def test_empty_title(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
]
|
||||
html = self.render_html(tags_html='''
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1" TAGS="example-tag"></A>
|
||||
<DD>Example description
|
||||
''')
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertTagsEqual(bookmarks, html_tags)
|
||||
|
||||
def test_with_closing_description_tag(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
|
||||
add_date='2', tags=''),
|
||||
]
|
||||
html = self.render_html(tags_html='''
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1" TAGS="example-tag">Example title</A>
|
||||
<DD>Example description</DD>
|
||||
<DT><A HREF="https://foo.com" ADD_DATE="2">Foo title</A>
|
||||
<DD></DD>
|
||||
''')
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertTagsEqual(bookmarks, html_tags)
|
||||
|
||||
def test_description_tag_before_anchor_tag(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
|
||||
add_date='2', tags=''),
|
||||
]
|
||||
html = self.render_html(tags_html='''
|
||||
<DT><DD>Example description</DD>
|
||||
<A HREF="https://example.com" ADD_DATE="1" TAGS="example-tag">Example title</A>
|
||||
<DT><DD></DD>
|
||||
<A HREF="https://foo.com" ADD_DATE="2">Foo title</A>
|
||||
''')
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertTagsEqual(bookmarks, html_tags)
|
||||
|
||||
def test_with_folders(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href='https://example.com', title='Example title', description='Example description',
|
||||
add_date='1', tags='example-tag'),
|
||||
BookmarkHtmlTag(href='https://foo.com', title='Foo title', description='',
|
||||
add_date='2', tags=''),
|
||||
]
|
||||
html = self.render_html(tags_html='''
|
||||
<DL><p>
|
||||
<DT><H3>Folder 1</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1" TAGS="example-tag">Example title</A>
|
||||
<DD>Example description
|
||||
</DL><p>
|
||||
<DT><H3>Folder 2</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://foo.com" ADD_DATE="2">Foo title</A>
|
||||
</DL><p>
|
||||
</DL><p>
|
||||
''')
|
||||
bookmarks = parse(html)
|
||||
|
||||
self.assertTagsEqual(bookmarks, html_tags)
|
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)
|
@@ -289,6 +289,60 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.tag_string, None)
|
||||
self.assertTrue(bookmark.tag_projection)
|
||||
|
||||
def test_query_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
|
||||
tag = self.setup_tag()
|
||||
untagged_bookmark = self.setup_bookmark()
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!untagged')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):
|
||||
tag = self.setup_tag()
|
||||
untagged_bookmark = self.setup_bookmark(title='term1')
|
||||
self.setup_bookmark(title='term2')
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):
|
||||
tag = self.setup_tag()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
|
||||
tag = self.setup_tag()
|
||||
untagged_bookmark = self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!untagged')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self):
|
||||
tag = self.setup_tag()
|
||||
untagged_bookmark = self.setup_bookmark(is_archived=True, title='term1')
|
||||
self.setup_bookmark(is_archived=True, title='term2')
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):
|
||||
tag = self.setup_tag()
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
@@ -460,3 +514,35 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
|
||||
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
|
||||
|
||||
def test_query_bookmark_tags_untagged_should_never_return_any_tags(self):
|
||||
tag = self.setup_tag()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(title='term1')
|
||||
self.setup_bookmark(title='term1', tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '!untagged')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):
|
||||
tag = self.setup_tag()
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True, title='term1')
|
||||
self.setup_bookmark(is_archived=True, title='term1', tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '!untagged')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
@@ -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)
|
@@ -1,8 +1,14 @@
|
||||
import random
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from unittest.mock import patch, Mock
|
||||
import requests
|
||||
from requests import RequestException
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.views.settings import app_version, get_version_info
|
||||
|
||||
|
||||
class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -26,6 +32,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 +42,33 @@ 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'])
|
||||
|
||||
def test_about_shows_version_info(self):
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
<td>{get_version_info(random.random())}</td>
|
||||
</tr>
|
||||
''', html)
|
||||
|
||||
def test_get_version_info_just_displays_latest_when_versions_are_equal(self):
|
||||
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v{app_version}'})
|
||||
with patch.object(requests, 'get', return_value=latest_version_response_mock):
|
||||
version_info = get_version_info(random.random())
|
||||
self.assertEqual(version_info, f'{app_version} (latest)')
|
||||
|
||||
def test_get_version_info_shows_latest_version_when_versions_are_not_equal(self):
|
||||
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v123.0.1'})
|
||||
with patch.object(requests, 'get', return_value=latest_version_response_mock):
|
||||
version_info = get_version_info(random.random())
|
||||
self.assertEqual(version_info, f'{app_version} (latest: 123.0.1)')
|
||||
|
||||
def test_get_version_info_silently_ignores_request_errors(self):
|
||||
with patch.object(requests, 'get', side_effect=RequestException()):
|
||||
version_info = get_version_info(random.random())
|
||||
self.assertEqual(version_info, f'{app_version}')
|
||||
|
@@ -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)
|
||||
|
@@ -12,4 +12,4 @@ class SignalsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
|
||||
self.client.force_login(user)
|
||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user.id)
|
||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
||||
|
@@ -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'])
|
||||
|
124
bookmarks/tests/test_toasts_view.py
Normal file
124
bookmarks/tests/test_toasts_view.py
Normal file
@@ -0,0 +1,124 @@
|
||||
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_form_tag(self):
|
||||
self.create_toast()
|
||||
expected_form_tag = f'<form action="{reverse("bookmarks:toasts.acknowledge")}?return_url={reverse("bookmarks:index")}" method="post">'
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
self.assertContains(response, expected_form_tag)
|
||||
|
||||
def test_toast_content(self):
|
||||
toast = self.create_toast()
|
||||
expected_toast = f'''
|
||||
<div class="toast">
|
||||
{toast.message}
|
||||
<button type="submit" name="toast" value="{toast.id}" class="btn btn-clear float-right"></button>
|
||||
</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.post(reverse('bookmarks:toasts.acknowledge'), {
|
||||
'toast': [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')
|
||||
acknowledge_url = acknowledge_url + '?return_url=' + return_url
|
||||
|
||||
response = self.client.post(acknowledge_url, {
|
||||
'toast': [toast.id],
|
||||
})
|
||||
|
||||
self.assertRedirects(response, return_url)
|
||||
|
||||
def test_acknowledge_toast_should_redirect_to_index_by_default(self):
|
||||
toast = self.create_toast()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:toasts.acknowledge'), {
|
||||
'toast': [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.post(reverse('bookmarks:toasts.acknowledge'), {
|
||||
'toast': [toast.id],
|
||||
})
|
||||
self.assertEqual(response.status_code, 404)
|
@@ -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/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
|
||||
|
||||
@@ -95,3 +96,10 @@ def parse_timestamp(value: str):
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
@@ -17,10 +20,11 @@ logger = logging.getLogger(__name__)
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
app_version = f.read().strip("\n")
|
||||
except Exception as exc:
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
pass
|
||||
|
||||
|
||||
@login_required
|
||||
def general(request):
|
||||
if request.method == 'POST':
|
||||
@@ -32,26 +36,47 @@ def general(request):
|
||||
|
||||
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
|
||||
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
|
||||
version_info = get_version_info(get_ttl_hash())
|
||||
return render(request, 'settings/general.html', {
|
||||
'form': form,
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
'app_version': app_version
|
||||
'version_info': version_info,
|
||||
})
|
||||
|
||||
|
||||
# Cache API call response, for one hour when using get_ttl_hash with default params
|
||||
@lru_cache(maxsize=1)
|
||||
def get_version_info(ttl_hash=None):
|
||||
latest_version = None
|
||||
try:
|
||||
latest_version_url = 'https://api.github.com/repos/sissbruecker/linkding/releases/latest'
|
||||
response = requests.get(latest_version_url, timeout=5)
|
||||
json = response.json()
|
||||
latest_version = json['name'][1:]
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
latest_version_info = ''
|
||||
if latest_version == app_version:
|
||||
latest_version_info = ' (latest)'
|
||||
elif latest_version is not None:
|
||||
latest_version_info = f' (latest: {latest_version})'
|
||||
|
||||
return f'{app_version}{latest_version_info}'
|
||||
|
||||
|
||||
def get_ttl_hash(seconds=3600):
|
||||
"""Return the same value within `seconds` time period"""
|
||||
return round(time.time() / seconds)
|
||||
|
||||
|
||||
@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
|
||||
})
|
||||
|
||||
|
20
bookmarks/views/toasts.py
Normal file
20
bookmarks/views/toasts.py
Normal file
@@ -0,0 +1,20 @@
|
||||
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 = request.POST['toast']
|
||||
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
|
||||
|
||||
@@ -18,4 +20,4 @@ if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then
|
||||
fi
|
||||
|
||||
# Start uwsgi server
|
||||
uwsgi uwsgi.ini
|
||||
uwsgi --http :$LD_SERVER_PORT uwsgi.ini
|
||||
|
@@ -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
|
||||
|
@@ -24,7 +24,7 @@ The following resources are available:
|
||||
GET /api/bookmarks/
|
||||
```
|
||||
|
||||
List bookmarks.
|
||||
List bookmarks.
|
||||
|
||||
Parameters:
|
||||
|
||||
@@ -65,7 +65,7 @@ Example response:
|
||||
GET /api/bookmarks/archived/
|
||||
```
|
||||
|
||||
List archived bookmarks.
|
||||
List archived bookmarks.
|
||||
|
||||
Parameters and response are the same as for the regular list endpoint.
|
||||
|
||||
@@ -83,7 +83,8 @@ Retrieves a single bookmark by ID.
|
||||
POST /api/bookmarks/
|
||||
```
|
||||
|
||||
Creates a new bookmark. Tags are simply assigned using their names.
|
||||
Creates a new bookmark. Tags are simply assigned using their names. Including
|
||||
`is_archived: true` saves a bookmark directly to the archive.
|
||||
|
||||
Example payload:
|
||||
|
||||
@@ -92,6 +93,7 @@ Example payload:
|
||||
"url": "https://example.com",
|
||||
"title": "Example title",
|
||||
"description": "Example description",
|
||||
"is_archived": false,
|
||||
"tag_names": [
|
||||
"tag1",
|
||||
"tag2"
|
||||
@@ -201,4 +203,3 @@ Example payload:
|
||||
"name": "example"
|
||||
}
|
||||
```
|
||||
|
||||
|
@@ -29,7 +29,7 @@ All options need to be defined as environment variables in the environment that
|
||||
|
||||
Values: `True`, `False` | Default = `False`
|
||||
|
||||
Disables background tasks, such as creating snapshots for bookmarks on the web archive.
|
||||
Disables background tasks, such as creating snapshots for bookmarks on the [the Internet Archive Wayback Machine](https://archive.org/web/).
|
||||
Enabling this flag will prevent the background task processor from starting up, and prevents scheduling tasks.
|
||||
This might be useful if you are experiencing performance issues or other problematic behaviour due to background task processing.
|
||||
|
||||
@@ -44,4 +44,10 @@ This can be useful if you intend to store non fully qualified domain name URLs,
|
||||
|
||||
Values: `Integer` as seconds | Default = `60`
|
||||
|
||||
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
|
||||
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
|
||||
|
||||
### `LD_SERVER_PORT`
|
||||
|
||||
Values: Valid port number | Default = `9090`
|
||||
|
||||
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.
|
BIN
docs/header.afdesign
Normal file
BIN
docs/header.afdesign
Normal file
Binary file not shown.
41
docs/header.svg
Normal file
41
docs/header.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 2599 591" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1265.31,-1395.82)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-1017.49,-1140.55)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:31.25px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:31.25px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||
<g transform="matrix(50,0,0,50,770.835,299.13)">
|
||||
<path d="M0.078,-0.714L0.078,-0L0.551,-0L0.551,-0.08L0.173,-0.08L0.173,-0.714L0.078,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,798.635,299.13)">
|
||||
<rect x="0.082" y="-0.714" width="0.095" height="0.714" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,811.585,299.13)">
|
||||
<path d="M0.077,-0.714L0.077,-0L0.167,-0L0.167,-0.573L0.169,-0.573L0.542,-0L0.646,-0L0.646,-0.714L0.556,-0.714L0.556,-0.135L0.554,-0.135L0.178,-0.714L0.077,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,847.685,299.13)">
|
||||
<path d="M0.078,-0.714L0.078,-0L0.173,-0L0.173,-0.25L0.292,-0.361L0.55,-0L0.67,-0L0.357,-0.426L0.658,-0.714L0.535,-0.714L0.173,-0.358L0.173,-0.714L0.078,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,881.035,299.13)">
|
||||
<path d="M0.173,-0.08L0.173,-0.634L0.333,-0.634C0.377,-0.634 0.414,-0.628 0.444,-0.616C0.474,-0.603 0.498,-0.585 0.518,-0.562C0.537,-0.538 0.55,-0.509 0.559,-0.476C0.567,-0.442 0.571,-0.404 0.571,-0.361C0.571,-0.317 0.567,-0.28 0.558,-0.249C0.549,-0.218 0.537,-0.192 0.523,-0.171C0.509,-0.15 0.493,-0.134 0.476,-0.122C0.458,-0.11 0.44,-0.101 0.422,-0.095C0.404,-0.088 0.387,-0.084 0.371,-0.083C0.355,-0.081 0.342,-0.08 0.331,-0.08L0.173,-0.08ZM0.078,-0.714L0.078,-0L0.323,-0C0.382,-0 0.434,-0.008 0.477,-0.025C0.52,-0.042 0.556,-0.066 0.584,-0.098C0.612,-0.129 0.633,-0.168 0.646,-0.215C0.659,-0.261 0.666,-0.314 0.666,-0.374C0.666,-0.489 0.636,-0.574 0.577,-0.63C0.518,-0.686 0.433,-0.714 0.323,-0.714L0.078,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,916.235,299.13)">
|
||||
<rect x="0.082" y="-0.714" width="0.095" height="0.714" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,929.185,299.13)">
|
||||
<path d="M0.077,-0.714L0.077,-0L0.167,-0L0.167,-0.573L0.169,-0.573L0.542,-0L0.646,-0L0.646,-0.714L0.556,-0.714L0.556,-0.135L0.554,-0.135L0.178,-0.714L0.077,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,965.285,299.13)">
|
||||
<path d="M0.612,-0.089L0.637,-0L0.697,-0L0.697,-0.376L0.384,-0.376L0.384,-0.296L0.612,-0.296C0.613,-0.263 0.609,-0.233 0.599,-0.205C0.589,-0.176 0.574,-0.152 0.555,-0.131C0.535,-0.11 0.511,-0.093 0.482,-0.081C0.453,-0.069 0.42,-0.063 0.383,-0.063C0.343,-0.063 0.308,-0.071 0.278,-0.087C0.247,-0.102 0.222,-0.123 0.201,-0.15C0.18,-0.176 0.165,-0.206 0.154,-0.241C0.143,-0.275 0.138,-0.311 0.138,-0.348C0.138,-0.386 0.143,-0.423 0.152,-0.46C0.161,-0.496 0.176,-0.528 0.196,-0.557C0.215,-0.585 0.241,-0.608 0.272,-0.625C0.303,-0.642 0.34,-0.651 0.383,-0.651C0.41,-0.651 0.435,-0.648 0.459,-0.642C0.482,-0.635 0.503,-0.626 0.522,-0.613C0.541,-0.6 0.556,-0.584 0.569,-0.565C0.582,-0.545 0.59,-0.521 0.595,-0.494L0.69,-0.494C0.683,-0.536 0.671,-0.572 0.653,-0.602C0.634,-0.631 0.612,-0.656 0.585,-0.675C0.558,-0.694 0.527,-0.708 0.493,-0.718C0.458,-0.727 0.422,-0.731 0.383,-0.731C0.326,-0.731 0.277,-0.721 0.235,-0.7C0.192,-0.679 0.157,-0.65 0.129,-0.615C0.1,-0.58 0.079,-0.539 0.065,-0.492C0.05,-0.444 0.043,-0.395 0.043,-0.343C0.043,-0.296 0.051,-0.251 0.066,-0.208C0.081,-0.165 0.104,-0.126 0.133,-0.093C0.162,-0.06 0.198,-0.033 0.24,-0.014C0.282,0.006 0.33,0.016 0.383,0.016C0.425,0.016 0.467,0.008 0.508,-0.009C0.549,-0.025 0.584,-0.052 0.612,-0.089Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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.
|
||||
@@ -28,7 +44,7 @@ This how-to explains how to make use of the app shortcuts iOS app to create a sh
|
||||
- create new shortcut
|
||||
- go to shortcut details, enable to option to show the shortcut in share menu
|
||||
- from the available share input types only select "URL"
|
||||
- add Safari action "Display website in Safari" (paraphrasing, not sure how it's called in english)
|
||||
- add Safari action "Show Web Page At"
|
||||
- for URL enter your linkding instance URL and specifically point to the new bookmark form, then add the shortcut input variable from the list of suggested variables after the URL parameter. Visually it should look something like this: `https://linkding.mydomain.com/bookmarks/new?url=[Shortcut input]`, where `[Shortcut input]` is a visual block that was inserted after selecting the shortcut input variable suggestion. This is basically a placeholder that will get replaced with the actual URL that you want to bookmark. See screenshot at the end for an example on how this looks.
|
||||
- save, give the shortcut a nice name + glyph
|
||||
|
||||
|
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
|
||||
}
|
10
docs/troubleshooting.md
Normal file
10
docs/troubleshooting.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Import fails with `502 Bad Gateway`
|
||||
|
||||
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.
|
||||
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds.
|
||||
|
||||
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
|
||||
|
||||
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.
|
@@ -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.8.0",
|
||||
"version": "1.11.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"
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ beautifulsoup4==4.7.1
|
||||
certifi==2019.6.16
|
||||
charset-normalizer==2.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==3.2.6
|
||||
Django==3.2.13
|
||||
django-background-tasks==1.2.5
|
||||
django-compat==1.0.15
|
||||
django-generate-secret-key==1.0.2
|
||||
@@ -13,12 +13,11 @@ 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==2021.1
|
||||
requests==2.26.0
|
||||
soupsieve==1.9.2
|
||||
sqlparse==0.4.1
|
||||
sqlparse==0.4.2
|
||||
supervisor==4.2.2
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
|
@@ -4,7 +4,7 @@ certifi==2019.6.16
|
||||
charset-normalizer==2.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
coverage==5.5
|
||||
Django==3.2.6
|
||||
Django==3.2.13
|
||||
django-appconf==1.0.4
|
||||
django-background-tasks==1.2.5
|
||||
django-compat==1.0.15
|
||||
@@ -18,7 +18,6 @@ django-widget-tweaks==1.4.8
|
||||
djangorestframework==3.12.4
|
||||
idna==2.8
|
||||
libsass==0.21.0
|
||||
pyparsing==2.4.7
|
||||
python-dateutil==2.8.1
|
||||
pytz==2021.1
|
||||
rcssmin==1.0.6
|
||||
@@ -26,7 +25,7 @@ requests==2.26.0
|
||||
rjsmin==1.1.0
|
||||
six==1.16.0
|
||||
soupsieve==1.9.2
|
||||
sqlparse==0.4.1
|
||||
sqlparse==0.4.2
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.6
|
||||
waybackpy==2.4.3
|
||||
|
@@ -69,6 +69,7 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'bookmarks.context_processors.toasts',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@@ -48,6 +48,11 @@ LOGGING = {
|
||||
'level': 'DEBUG',
|
||||
'handlers': ['console'],
|
||||
'propagate': False,
|
||||
},
|
||||
'bookmarks.services.importer': { # Log importer debug 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:
|
||||
|
@@ -1,16 +1,16 @@
|
||||
[uwsgi]
|
||||
http = :9090
|
||||
chdir = /etc/linkding
|
||||
module = siteroot.wsgi:application
|
||||
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
||||
static-map = /static=static
|
||||
processes = 4
|
||||
processes = 2
|
||||
threads = 2
|
||||
pidfile = /tmp/linkding.pid
|
||||
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.8.0
|
||||
1.11.0
|
||||
|
Reference in New Issue
Block a user