Compare commits

...

103 Commits

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

* Add toast message about web archive integration opt-in

* Improve wording for web archive setting

* Add toast admin

* Fix toast clear button visited styles

* Add test for redirect

* Improve wording

* Ensure redirects to same domain

* Improve wording

* Fix snapshot test

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

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

* Simplify

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

* Use location.assign to keep history

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

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

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

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

Co-authored-by: Christoph Schmatzler <christoph@medium.place>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-03-26 10:24:38 +01:00
elmodor
673466ab28 Increase buffer size (#189)
Handles requests with a larger block size than the default 4096
2022-03-26 10:02:36 +01:00
Sascha Ißbrücker
13f27f5412 Update CHANGELOG.md 2022-03-25 19:57:59 +01:00
Sascha Ißbrücker
530c4b74c4 Update CHANGELOG.md 2022-03-25 19:49:43 +01:00
Sascha Ißbrücker
3eb8cfe45e Bump version 2022-03-25 18:55:58 +01:00
dependabot[bot]
f5b07eebba Bump sqlparse from 0.4.1 to 0.4.2 (#159)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.1 to 0.4.2.
- [Release notes](https://github.com/andialbrecht/sqlparse/releases)
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.1...0.4.2)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-25 18:54:07 +01:00
dependabot[bot]
3ba8f7e30b Bump django from 3.2.6 to 3.2.12 (#197)
Bumps [django](https://github.com/django/django) from 3.2.6 to 3.2.12.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.6...3.2.12)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-25 18:51:59 +01:00
Sascha Ißbrücker
9a63c367a8 Move shortcut to docs 2022-03-25 18:35:05 +01:00
Sascha Ißbrücker
edb71286e7 Prevent external redirects 2022-03-25 18:29:54 +01:00
Sascha Ißbrücker
1ffc3e0266 Fix bookmark access restrictions 2022-03-22 02:24:21 +01:00
Sascha Ißbrücker
66995cfab2 Create SECURITY.md 2022-03-20 09:11:58 +01:00
Kazi
68143de992 How-to guide for HTTP Shortcuts app on Android (#201)
* How-to guide for HTTP Shortcuts app on Android

* refinements

* link fix
2022-02-28 08:12:37 +01:00
Fivefold
b93a9fadb6 Add linkding-injector community extension to README (#190) 2022-01-17 13:39:45 +01:00
Sascha Ißbrücker
77fea02f77 Update CHANGELOG.md 2021-12-13 00:12:51 +01:00
Sascha Ißbrücker
fcc0b6f591 Update CHANGELOG.md 2021-12-13 00:09:14 +01:00
Sascha Ißbrücker
e1c9a7add6 Bump version 2021-12-12 22:57:22 +01:00
Sascha Ißbrücker
82b4268a26 Ensure tag names don't contain spaces (#184) 2021-12-12 22:54:22 +01:00
Fivefold
5287eb3f8b Make bookmarks count column in admin sortable (#183)
The Tag view in django admin has a calculated bookmark count column that is unsortable. This fixes it
2021-12-12 22:52:22 +01:00
QiaoHao
d298260122 exclude .git in docker container (#176) 2021-12-05 21:11:49 +01:00
Sascha Ißbrücker
12e5810aee Fix docker-compose.yaml to import variables from env file 2021-11-10 12:19:56 +01:00
Sascha Ißbrücker
1dabd0266b Update CHANGELOG.md 2021-10-16 05:49:56 +02:00
Sascha Ißbrücker
7390fc3f4f Bump version 2021-10-16 05:44:05 +02:00
Sascha Ißbrücker
5e003ede92 Change api token field to readonly 2021-10-16 05:43:35 +02:00
Sascha Ißbrücker
984eef92e2 Add password change view (#168) 2021-10-16 05:42:04 +02:00
Sascha Ißbrücker
eae6ca6e07 Merge API view with integrations view (#165) 2021-10-03 15:13:45 +02:00
Sascha Ißbrücker
a6bfaa7c78 Update CHANGELOG.md 2021-10-03 09:54:10 +02:00
Sascha Ißbrücker
7aa1630be2 Bump version 2021-10-03 09:49:50 +02:00
Sascha Ißbrücker
4f9fcb41bd Add bookmark link target setting (#164) 2021-10-03 09:35:59 +02:00
Sascha Ißbrücker
da4a81305a Bump version 2021-10-02 23:57:21 +02:00
Sascha Ißbrücker
df33144dd0 Update CHANGELOG.md 2021-10-02 23:55:16 +02:00
Sascha Ißbrücker
123fa54d5a Fix jumping search box (#163) 2021-10-02 23:49:59 +02:00
Sascha Ißbrücker
2ab4aa5566 Update CHANGELOG.md 2021-10-01 18:10:58 +02:00
Sascha Ißbrücker
d4cba7d5fa Update CHANGELOG.md 2021-10-01 18:08:24 +02:00
Sascha Ißbrücker
3d8fd66e50 Bump version 2021-10-01 18:03:28 +02:00
Sascha Ißbrücker
3ff7a5ba91 Add global search shortcut (#161) 2021-10-01 18:02:34 +02:00
Sascha Ißbrücker
88c109c9a4 Update CHANGELOG.md 2021-09-04 22:44:42 +02:00
Sascha Ißbrücker
a1d5ff6532 Update CHANGELOG.md 2021-09-04 22:39:32 +02:00
Sascha Ißbrücker
e7c55cd318 Bump version 2021-09-04 22:31:55 +02:00
Sascha Ißbrücker
d87dde6bae Create snapshots on web.archive.org for bookmarks (#150)
* Implement initial background tasks concept

* fix property reference

* update requirements.txt

* simplify bookmark null check

* improve web archive url display

* add background tasks test

* add basic supervisor setup

* schedule missing snapshot creation on login

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

* batch create snapshots after import

* fix script reference in supervisord.conf

* add option to disable background tasks

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

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

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

* Avoid character corruption of scraping some Japanese sites

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

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

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

* use charset_normalizer to determine response encoding

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

* Added about section in settings tab

* fix code style

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

* Configure default auto field implementation

* fix admin to use token proxy model

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

* Improve comment wording

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

* fix tests and only conditionally append tag filter

* add bookmark tags query tests

* reuse bookmark queries for tag queries

* fix tag query test setup

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

* Small correction

* Polish admin docs and reference from README.md

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

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

* Add how-to for creating share action in Safari

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

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

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

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

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

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

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-13 17:13:36 +02:00
Sascha Ißbrücker
3a05666680 Update CHANGELOG.md 2021-04-07 00:43:39 +02:00
Sascha Ißbrücker
dbe92b4b84 Bump version 2021-04-06 23:39:02 +02:00
Sascha Ißbrücker
90f62d3482 Fix relative date formatting (#107) 2021-04-06 23:38:15 +02:00
Sascha Ißbrücker
847f9644f4 Update CHANGELOG.md 2021-04-04 10:31:48 +02:00
Sascha Ißbrücker
bf84b3ddfd Bump version 2021-04-04 10:17:12 +02:00
Sascha Ißbrücker
2d19e97212 Allow editing of scraped values (#80)
* Allow editing scraped title + description (#80)

* Fix edit button hijacking form submit
2021-04-04 10:16:40 +02:00
Sascha Ißbrücker
c083997399 Update CHANGELOG.md 2021-03-31 09:23:06 +02:00
Sascha Ißbrücker
36f134db9a Update CHANGELOG.md 2021-03-31 09:11:59 +02:00
Sascha Ißbrücker
593d90d8e2 Bump version 2021-03-31 09:09:08 +02:00
Sascha Ißbrücker
7a68a4abed Display date_added in bookmark list (#85)
* Display date_added in bookmark list (#85)

* Allow switching between different types of date formats

* Improve date formatting

* Use pluralize

* Fix comment

* Fix styles

Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-03-31 09:08:19 +02:00
Sascha Ißbrücker
8dd1575dc6 Update CHANGELOG.md 2021-03-29 01:04:00 +02:00
Sascha Ißbrücker
d4d23daebc Update CHANGELOG.md 2021-03-29 00:59:56 +02:00
111 changed files with 4300 additions and 584 deletions

3
.coveragerc Normal file
View File

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

View File

@@ -7,6 +7,8 @@
/docs
/static
/build
/out
/.git
/.dockerignore
/.gitignore
@@ -17,10 +19,13 @@
/*.patch
/*.md
/*.js
/*.log
/*.pid
# Whitelist files needed in build or prod image
!/rollup.config.js
!/bootstrap.sh
!/background-tasks-wrapper.sh
# Remove development settings
/siteroot/settings/dev.py

View File

@@ -5,5 +5,7 @@ LD_HOST_PORT=9090
# Directory on the host system that should be mounted as data dir into the Docker container
LD_HOST_DATA_DIR=./data
# Option to disable background tasks
LD_DISABLE_BACKGROUND_TASKS=False
# Option to disable URL validation for bookmarks completely
LD_DISABLE_URL_VALIDATION=False
LD_DISABLE_URL_VALIDATION=False

View File

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

45
.gitignore vendored
View File

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

15
.idea/compiler.xml generated
View File

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

11
.idea/dataSources.xml generated
View File

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

4
.idea/encodings.xml generated
View File

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

View File

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

13
.idea/misc.xml generated
View File

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

8
.idea/modules.xml generated
View File

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

6
.idea/vcs.xml generated
View File

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

View File

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

View File

@@ -1,5 +1,106 @@
# Changelog
## v1.8.8 (27/03/2022)
- [**bug**] Prevent bookmark actions through get requests
- [**bug**] Prevent external redirects
---
## v1.8.7 (26/03/2022)
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
---
## v1.8.6 (25/03/2022)
- [bug] fix bookmark access restrictions
- [bug] prevent external redirects
- [chore] bump dependencies
---
## v1.8.5 (13/12/2021)
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
---
## v1.8.4 (16/10/2021)
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
---
## v1.8.3 (03/10/2021)
- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)
---
## v1.8.2 (02/10/2021)
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
---
## v1.8.1 (01/10/2021)
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
- allows to press `s` to focus the search input
---
## v1.8.0 (04/09/2021)
- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
- This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option
---
## v1.7.2 (26/08/2021)
- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)
---
## v1.7.1 (25/08/2021)
- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)
- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)
- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)
---
## v1.7.0 (17/08/2021)
- Upgrade to Django 3
- Bump other dependencies
---
## v1.6.5 (15/08/2021)
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
---
## v1.6.4 (13/05/2021)
- Update dependencies for security fixes
---
## v1.6.3 (07/04/2021)
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
---
## v1.6.2 (04/04/2021)
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
---
## v1.6.1 (31/03/2021)
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
---
## v1.6.0 (29/03/2021)
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
---
## v1.5.0 (28/03/2021)
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
---

View File

@@ -9,7 +9,7 @@ COPY . .
RUN npm run build
FROM python:3.9-slim AS python-base
FROM python:3.9.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential
WORKDIR /etc/linkding
@@ -33,7 +33,7 @@ RUN mkdir /opt/venv && \
/opt/venv/bin/pip install -Ur requirements.txt
FROM python:3.9-slim as final
FROM python:3.9.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support
WORKDIR /etc/linkding
# copy prod dependencies
@@ -47,6 +47,8 @@ EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
RUN ["chmod", "g+w", "."]
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"]

View File

@@ -13,17 +13,14 @@ The name comes from:
- Search by text or tags
- Bulk editing
- Bookmark archive
- Automatically provides titles and descriptions from linked websites
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
- Bookmarklet that should work in most browsers
- Dark mode
- Easy to set up using Docker
- Uses SQLite as database
- Works without Javascript
- ...but has several UI enhancements when Javascript is enabled
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Automatically provides titles and descriptions of bookmarked websites
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), and a bookmarklet that should work in most browsers
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
- Easy to set up using Docker, uses SQLite as database
**Demo:** https://demo.linkding.link/ (configured with open registration)
@@ -88,7 +85,7 @@ If you can not or don't want to use Docker you can install the application manua
### Hosting
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support the process here, but I can give some pointers on what to search for:
- first get the app running (described in this document)
- open the port that the application is running on in your servers firewall
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
@@ -97,10 +94,18 @@ The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedo
Check the [options document](docs/Options.md) on how to configure your linkding installation.
## Administration
Check the [administration document](docs/Admin.md) on how to use the admin app that is bundled with linkding.
## Backups
Check the [backups document](docs/backup.md) for options on how to create backups.
## How To
Check the [how-to document](docs/how-to.md) for tips and tricks around using linkding.
## API
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information.
@@ -118,7 +123,7 @@ Note that any proxy servers that you are running in front of linkding may have t
## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.2/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3
@@ -163,4 +168,6 @@ The frontend is now available under http://localhost:8000
## Community
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)

13
SECURITY.md Normal file
View File

@@ -0,0 +1,13 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.8.x | :white_check_mark: |
## Reporting a Vulnerability
To report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com
I'll try to get back to you as soon as possible.

5
background-tasks-wrapper.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
python manage.py clean_tasks
exec python manage.py process_tasks

View File

@@ -5,9 +5,9 @@ from django.contrib.auth.models import User
from django.db.models import Count, QuerySet
from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.models import Bookmark, Tag, UserProfile, Toast
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -59,6 +59,8 @@ class AdminTag(admin.ModelAdmin):
def bookmarks_count(self, obj):
return obj.bookmarks_count
bookmarks_count.admin_order_field = 'bookmarks_count'
def delete_unused_tags(self, request, queryset: QuerySet):
unused_tags = queryset.filter(bookmark__isnull=True)
unused_tags_count = unused_tags.count()
@@ -93,8 +95,15 @@ class AdminCustomUser(UserAdmin):
return super(AdminCustomUser, self).get_inline_instances(request, obj)
class AdminToast(admin.ModelAdmin):
list_display = ('key', 'message', 'owner', 'acknowledged')
search_fields = ('key', 'message')
list_filter = ('owner__username',)
linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(Token, TokenAdmin)
linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast)

View File

@@ -41,14 +41,14 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url']
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'], ' ')
tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data):
instance.url = validated_data['url']
instance.title = validated_data['title']
instance.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'], ' ')
tag_string = build_tag_string(validated_data['tag_names'])
return update_bookmark(instance, tag_string, self.context['user'])

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
from background_task.models import Task, CompletedTask
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Remove task locks and clear completed task history"
def handle(self, *args, **options):
# Remove task locks
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
# even though no process is working on them, and would prevent the task processor from picking the next task in
# the queue
Task.objects.all().update(locked_by=None, locked_at=None)
# Clear task history to prevent them from bloating the DB
CompletedTask.objects.all().delete()

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.18 on 2021-03-30 10:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0007_userprofile'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='bookmark_date_display',
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.20 on 2021-05-16 14:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0008_userprofile_bookmark_date_display'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='web_archive_snapshot_url',
field=models.CharField(blank=True, max_length=2048),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,11 +20,19 @@ class Tag(models.Model):
return self.name
def sanitize_tag_name(tag_name: str):
# strip leading/trailing spaces
# replace inner spaces with replacement char
return tag_name.strip().replace(' ', '-')
def parse_tag_string(tag_string: str, delimiter: str = ','):
if not tag_string:
return []
names = tag_string.strip().split(delimiter)
names = [name.strip() for name in names if name]
# remove empty names, sanitize remaining names
names = [sanitize_tag_name(name) for name in names if name]
# remove duplicates
names = unique(names, str.lower)
names.sort(key=str.lower)
@@ -41,6 +49,7 @@ class Bookmark(models.Model):
description = models.TextField(blank=True)
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
unread = models.BooleanField(default=True)
is_archived = models.BooleanField(default=False)
date_added = models.DateTimeField()
@@ -90,12 +99,10 @@ class BookmarkForm(forms.ModelForm):
widget=forms.Textarea())
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
# Hidden field that determines where to redirect after saving the form
return_url = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url']
fields = ['url', 'tag_string', 'title', 'description', 'auto_close']
class UserProfile(models.Model):
@@ -107,14 +114,40 @@ class UserProfile(models.Model):
(THEME_LIGHT, 'Light'),
(THEME_DARK, 'Dark'),
]
BOOKMARK_DATE_DISPLAY_RELATIVE = 'relative'
BOOKMARK_DATE_DISPLAY_ABSOLUTE = 'absolute'
BOOKMARK_DATE_DISPLAY_HIDDEN = 'hidden'
BOOKMARK_DATE_DISPLAY_CHOICES = [
(BOOKMARK_DATE_DISPLAY_RELATIVE, 'Relative'),
(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']
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration']
@receiver(post_save, sender=get_user_model())
@@ -126,3 +159,10 @@ def create_user_profile(sender, instance, created, **kwargs):
@receiver(post_save, sender=get_user_model())
def save_user_profile(sender, instance, **kwargs):
instance.profile.save()
class Toast(models.Model):
key = models.CharField(max_length=50)
message = models.TextField()
acknowledged = models.BooleanField(default=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)

View File

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

View File

@@ -6,6 +6,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services.tags import get_or_create_tags
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.services import tasks
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
@@ -27,10 +28,16 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
# Update tag list
_update_bookmark_tags(bookmark, tag_string, current_user)
bookmark.save()
# Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False)
return bookmark
def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Detect URL change
original_bookmark = Bookmark.objects.get(id=bookmark.id)
has_url_changed = original_bookmark.url != bookmark.url
# Update website info
_update_website_metadata(bookmark)
# Update tag list
@@ -38,6 +45,10 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update dates
bookmark.date_modified = timezone.now()
bookmark.save()
# Update web archive snapshot, if URL changed
if has_url_changed:
tasks.create_web_archive_snapshot(current_user, bookmark, True)
return bookmark
@@ -79,7 +90,7 @@ def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
tag_names = parse_tag_string(tag_string, ' ')
tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks:
@@ -92,7 +103,7 @@ def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
tag_names = parse_tag_string(tag_string, ' ')
tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks:
@@ -114,7 +125,7 @@ def _update_website_metadata(bookmark: Bookmark):
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string, ' ')
tag_names = parse_tag_string(tag_string)
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)

View File

@@ -1,12 +1,14 @@
import logging
from dataclasses import dataclass
from datetime import datetime
from django.contrib.auth.models import User
from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services import tasks
from bookmarks.services.parser import parse, NetscapeBookmark
from bookmarks.services.tags import get_or_create_tags
from bookmarks.utils import parse_timestamp
logger = logging.getLogger(__name__)
@@ -37,6 +39,9 @@ def import_netscape_html(html: str, user: User):
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
result.failed = result.failed + 1
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user)
return result
@@ -45,7 +50,10 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
bookmark.url = netscape_bookmark.href
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
if netscape_bookmark.date_added:
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
bookmark.unread = False
bookmark.title = netscape_bookmark.title
@@ -53,6 +61,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
bookmark.description = netscape_bookmark.description
bookmark.owner = user
bookmark.full_clean()
bookmark.save()
# Set tags

View File

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

View File

@@ -0,0 +1,65 @@
import logging
import waybackpy
from background_task import background
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from waybackpy.exceptions import WaybackError
from bookmarks.models import Bookmark, UserProfile
logger = logging.getLogger(__name__)
def is_web_archive_integration_active(user: User) -> bool:
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
web_archive_integration_enabled = \
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
return background_tasks_enabled and web_archive_integration_enabled
def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):
if is_web_archive_integration_active(user):
_create_web_archive_snapshot_task(bookmark.id, force_update)
@background()
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
# Skip if snapshot exists and update is not explicitly requested
if bookmark.web_archive_snapshot_url and not force_update:
return
logger.debug(f'Create web archive link for bookmark: {bookmark}...')
wayback = waybackpy.Url(bookmark.url)
try:
archive = wayback.save()
except WaybackError as error:
logger.exception(f'Error creating web archive link for bookmark: {bookmark}...', exc_info=error)
raise
bookmark.web_archive_snapshot_url = archive.archive_url
bookmark.save()
logger.debug(f'Successfully created web archive link for bookmark: {bookmark}...')
def schedule_bookmarks_without_snapshots(user: User):
if is_web_archive_integration_active(user):
_schedule_bookmarks_without_snapshots_task(user.id)
@background()
def _schedule_bookmarks_without_snapshots_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
for bookmark in bookmarks_without_snapshots:
_create_web_archive_snapshot_task(bookmark.id, False)

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass
import requests
from bs4 import BeautifulSoup
from charset_normalizer import from_bytes
@dataclass
@@ -33,5 +34,11 @@ def load_website_metadata(url: str):
def load_page(url: str):
r = requests.get(url)
return r.text
r = requests.get(url, timeout=10)
# Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
# This is different from Response.text which does respect the encoding specified in the response first,
# before trying to determine one
results = from_bytes(r.content)
return str(results.best())

8
bookmarks/signals.py Normal file
View File

@@ -0,0 +1,8 @@
from django.contrib.auth import user_logged_in
from django.dispatch import receiver
from bookmarks.services import tasks
@receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs):
tasks.schedule_bookmarks_without_snapshots(user)

View File

@@ -19,6 +19,7 @@
if (buttonEl.nodeName === 'BUTTON') {
confirmEl.type = buttonEl.type;
confirmEl.name = buttonEl.name;
confirmEl.value = buttonEl.value;
}
if (buttonEl.nodeName === 'A') {
confirmEl.href = buttonEl.href;
@@ -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();
})()

View File

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

View File

@@ -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;
}
}
}
}
@@ -50,16 +57,26 @@ ul.bookmark-list {
}
}
.actions > *:not(:last-child) {
margin-right: 0.1rem;
}
.actions .date-label a {
color: $gray-color;
}
.actions .btn-link {
color: $gray-color;
padding-left: 0;
padding-right: 0;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
&:focus,
&:hover,
&:active,
&.active {
color: darken($gray-color, 10%);
color: $gray-color-dark;
}
}
@@ -91,12 +108,41 @@ ul.bookmark-list {
.bookmarks-form {
.btn.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
color: $gray-color;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
> svg {
width: 20px;
height: 20px;
}
}
.has-icon-right > input, .has-icon-right > textarea {
padding-right: 30px;
}
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
.form-icon.loading {
visibility: hidden;
}
.form-input-hint.bookmark-exists {
visibility: hidden;
display: none;
color: $warning-color;
a {
@@ -107,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;
@@ -157,6 +203,7 @@ $bulk-edit-transition-duration: 400ms;
span.confirmation {
display: flex;
}
span.confirmation button {
padding: 0;
}
@@ -202,4 +249,4 @@ $bulk-edit-transition-duration: 400ms;
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px $border-color;
}
}

View File

@@ -14,4 +14,4 @@
.text-gray-dark {
color: $gray-color-dark;
}
}

View File

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

View File

@@ -3,13 +3,13 @@
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
<li>
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</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 %}
@@ -26,17 +26,45 @@
{% endif %}
</div>
<div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Unarchive</a>
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive</button>
{% else %}
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Archive</a>
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive</button>
{% endif %}
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm btn-confirmation">Remove</a>
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
</div>
</li>
{% endfor %}
@@ -44,4 +72,4 @@
<div class="bookmark-pagination">
{% pagination bookmarks %}
</div>
</div>

View File

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

View File

@@ -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: " }}
@@ -14,12 +13,13 @@
</div>
{% endif %}
<div class="form-input-hint bookmark-exists">
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark by saving this form.
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark
by saving this form.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input" }}
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
<div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
exist it will be
@@ -30,8 +30,16 @@
<div class="form-group has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="has-icon-right">
{{ form.title|add_class:"form-input" }}
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit title from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use title from website.
@@ -43,6 +51,14 @@
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use description from website.
@@ -82,49 +98,72 @@
/**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
* - Show hint if URL is already bookmarked
* - Setup buttons that allow editing of scraped website values
*/
(function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }}
const editedBookmarkId = {{ bookmark_id }};
urlInput.addEventListener('input', checkUrl);
function toggleLoadingIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
function updatePlaceholder(input, value) {
if (value) {
input.setAttribute('placeholder', value);
} else {
input.removeAttribute('placeholder');
}
}
function checkUrl() {
toggleIcon(titleInput, true);
toggleIcon(descriptionInput, true);
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
updatePlaceholder(titleInput, null);
updatePlaceholder(descriptionInput, null);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {
const metadata = data.metadata
titleInput.setAttribute('placeholder', metadata.title || '');
descriptionInput.setAttribute('placeholder', metadata.description || '');
toggleIcon(titleInput, false);
toggleIcon(descriptionInput, false);
const metadata = data.metadata;
updatePlaceholder(titleInput, metadata.title);
updatePlaceholder(descriptionInput, metadata.description);
toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false);
// Display hint if URL is already bookmarked
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists')
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a')
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
if(data.bookmark && data.bookmark.id !== editedBookmarkId) {
bookmarkExistsHint.style['visibility'] = 'visible'
editExistingBookmarkLink.href = data.bookmark.edit_url
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block';
editExistingBookmarkLink.href = data.bookmark.edit_url;
} else {
bookmarkExistsHint.style['visibility'] = 'hidden'
bookmarkExistsHint.style['display'] = 'none';
}
});
}
function toggleIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
function setupEditAutoValueButton(input) {
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
if (!editAutoValueButton) return;
editAutoValueButton.addEventListener('click', function (event) {
event.preventDefault();
input.value = input.getAttribute('placeholder');
input.focus();
input.select();
});
}
if (urlInput.value) checkUrl();
urlInput.addEventListener('input', checkUrl);
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})();
</script>
</div>

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,25 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
<section class="content-area">
<h2>API Token</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
token can access and manage all your bookmarks.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
</section>
</div>
{% endblock %}

View File

@@ -9,11 +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">
@@ -65,6 +97,15 @@
{% endif %}
</section>
{# About section #}
<section class="content-area">
<h2>About</h2>
<p>Version: {{ app_version }}</p>
<p>
Code: <a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a>
</p>
</section>
</div>
{% endblock %}

View File

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

View File

@@ -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 &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>

View File

@@ -51,10 +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,
}

View File

@@ -1,5 +1,7 @@
from django import template
from bookmarks import utils
register = template.Library()
@@ -43,3 +45,17 @@ def first_char(text):
@register.filter(name='remaining_chars')
def remaining_chars(text, index):
return text[index:]
@register.filter(name='humanize_absolute_date')
def humanize_absolute_date(value):
if value in (None, ''):
return ''
return utils.humanize_absolute_date(value)
@register.filter(name='humanize_relative_date')
def humanize_relative_date(value):
if value in (None, ''):
return ''
return utils.humanize_relative_date(value)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,27 @@ class BookmarkValidationTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def test_bookmark_model_should_not_allow_missing_url(self):
bookmark = Bookmark(
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
with self.assertRaises(ValidationError):
bookmark.full_clean()
def test_bookmark_model_should_not_allow_empty_url(self):
bookmark = Bookmark(
url='',
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
with self.assertRaises(ValidationError):
bookmark.full_clean()
@override_settings(LD_DISABLE_URL_VALIDATION=False)
def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):
self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)

View File

@@ -60,6 +60,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_replaces_whitespace_in_tag_names(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'tag_names': ['tag 1', 'tag 2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ['tag-1', 'tag-2'])
def test_create_bookmark_minimal_payload(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)

View File

@@ -0,0 +1,135 @@
from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.utils import timezone, formats
from bookmarks.models import Bookmark, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
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()
paginator = Paginator(bookmarks, 10)
page = paginator.page(1)
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
return template.render(context)
def render_default_template(self, bookmarks: [Bookmark]) -> str:
template = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
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()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = web_archive_url
bookmark.save()
user = self.get_or_create_test_user()
user.profile.bookmark_date_display = date_display_setting
user.profile.save()
return bookmark
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_default_template([bookmark])
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertDateLabel(html, formatted_date)
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark])
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
def test_should_respect_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_default_template([bookmark])
self.assertDateLabel(html, '1 week ago')
def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
html = self.render_default_template([bookmark])
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
self.assertBookmarksLink(html, bookmark, link_target='_blank')
def test_bookmark_link_target_should_respect_link_target_parameter(self):
bookmark = self.setup_bookmark()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertBookmarksLink(html, bookmark, link_target='_self')
def test_web_archive_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save()
html = self.render_default_template([bookmark])
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
def test_web_archive_link_target_respect_link_target_parameter(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.save()
html = self.render_template_with_link_target([bookmark], '_self')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')

View File

@@ -1,11 +1,14 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark, Tag
from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \
delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
User = get_user_model()
@@ -13,7 +16,30 @@ User = get_user_model()
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
self.get_or_create_test_user()
def test_create_should_create_web_archive_snapshot(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, True)
def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_create_web_archive_snapshot.assert_not_called()
def test_archive_bookmark(self):
bookmark = Bookmark(
@@ -190,7 +216,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
@@ -206,7 +232,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user())
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1,tag2', self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -231,7 +257,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -249,7 +275,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
@@ -267,7 +293,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
@@ -281,7 +307,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
@@ -299,7 +325,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -317,7 +343,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
@@ -335,7 +361,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
self.assertCountEqual(bookmark1.tags.all(), [])

View File

@@ -0,0 +1,174 @@
from unittest.mock import patch
import waybackpy
from background_task.models import Task
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class MockWaybackUrl:
def __init__(self, archive_url: str):
self.archive_url = archive_url
def save(self):
return self
class MockWaybackUrlWithSaveError:
def save(self):
raise NotImplementedError
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.save()
@disable_logging
def run_pending_task(self, task_function):
func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0]
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@disable_logging
def run_all_pending_tasks(self, task_function):
func = getattr(task_function, 'task_function', None)
tasks = Task.objects.all()
for task in tasks:
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')) as mock_wayback_url:
tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_wayback_url.assert_not_called()
def test_create_web_archive_snapshot_should_handle_wayback_save_error(self):
bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'Url',
return_value=MockWaybackUrlWithSaveError()):
with self.assertRaises(NotImplementedError):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://other.com')
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.assertEqual(Task.objects.count(), 0)
def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(self):
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_snapshots_should_create_snapshot_task_for_all_bookmarks_without_snapshot(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_schedule_bookmarks_without_snapshots_should_not_update_bookmarks_with_existing_snapshot(self):
user = self.get_or_create_test_user()
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://other.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all():
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(self):
user = self.get_or_create_test_user()
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
with patch.object(waybackpy, 'Url', return_value=MockWaybackUrl('https://example.com')):
tasks.schedule_bookmarks_without_snapshots(user)
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
self.run_all_pending_tasks(tasks._create_web_archive_snapshot_task)
for bookmark in Bookmark.objects.all().filter(owner=user):
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com')
for bookmark in Bookmark.objects.all().filter(owner=other_user):
self.assertEqual(bookmark.web_archive_snapshot_url, '')
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(self):
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(self):
self.user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
self.user.profile.save()
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)

View File

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

View File

@@ -0,0 +1,58 @@
from unittest.mock import patch
from django.test import TestCase
from bookmarks.models import Tag
from bookmarks.services import tasks
from bookmarks.services.importer import import_netscape_html
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class ImporterTestCase(TestCase, BookmarkFactoryMixin):
def create_import_html(self, bookmark_tags_string: str):
return f'''
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
{bookmark_tags_string}
</DL><p>
'''
def test_replace_whitespace_in_tag_names(self):
test_html = self.create_import_html(f'''
<DT><A HREF="https://example.com" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag 1, tag 2, tag 3">Example.com</A>
<DD>Example.com
''')
import_netscape_html(test_html, self.get_or_create_test_user())
tags = Tag.objects.all()
tag_names = [tag.name for tag in tags]
self.assertListEqual(tag_names, ['tag-1', 'tag-2', 'tag-3'])
@disable_logging
def test_validate_empty_or_missing_bookmark_url(self):
test_html = self.create_import_html(f'''
<!-- Empty URL -->
<DT><A HREF="" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Empty URL</A>
<DD>Empty URL
<!-- Missing URL -->
<DT><A ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Missing URL</A>
<DD>Missing URL
''')
import_result = import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(import_result.success, 0)
def test_schedule_snapshot_creation(self):
user = self.get_or_create_test_user()
test_html = self.create_import_html('')
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
from unittest.mock import patch
from django.test import TestCase
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SignalsTestCase(TestCase, BookmarkFactoryMixin):
def test_login_should_schedule_snapshot_creation(self):
user = self.get_or_create_test_user()
with patch.object(tasks, 'schedule_bookmarks_without_snapshots') as mock_schedule_bookmarks_without_snapshots:
self.client.force_login(user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
from unittest.mock import patch
from dateutil.relativedelta import relativedelta
from django.test import TestCase
from django.utils import timezone
from bookmarks.utils import humanize_absolute_date, humanize_relative_date, parse_timestamp
class UtilsTestCase(TestCase):
def test_humanize_absolute_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 1, 1), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 2, 1), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 8), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7, 23, 59), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2, 23, 59), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), 'Today'),
]
for test_case in test_cases:
result = humanize_absolute_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_absolute_date_should_use_current_date_as_default(self):
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 1)), 'Today')
# Regression: Test that subsequent calls use current date instead of cached date (#107)
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 13)), 'Today')
def test_humanize_relative_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 1, 1), '1 year ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 12, 31), '1 year ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 1, 1), '2 years ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 12, 31), '2 years ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 12, 31), '11 months ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 2, 1), '1 month ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 31), '4 weeks ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 14), '1 week ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 8), '1 week ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7, 23, 59), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2, 23, 59), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), 'Today'),
]
for test_case in test_cases:
result = humanize_relative_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_relative_date_should_use_current_date_as_default(self):
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 1)), 'Today')
# Regression: Test that subsequent calls use current date instead of cached date (#107)
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 13)), 'Today')
def verify_timestamp(self, date, factor=1):
timestamp_string = str(int(date.timestamp() * factor))
parsed_date = parse_timestamp(timestamp_string)
self.assertEqual(date, parsed_date)
def test_parse_timestamp_fails_for_invalid_timestamps(self):
with self.assertRaises(ValueError):
parse_timestamp('invalid')
def test_parse_timestamp_parses_millisecond_timestamps(self):
now = timezone.now().replace(microsecond=0)
fifty_years_ago = now - relativedelta(year=50)
fifty_years_from_now = now + relativedelta(year=50)
self.verify_timestamp(now)
self.verify_timestamp(fifty_years_ago)
self.verify_timestamp(fifty_years_from_now)
def test_parse_timestamp_parses_microsecond_timestamps(self):
now = timezone.now().replace(microsecond=0)
fifty_years_ago = now - relativedelta(year=50)
fifty_years_from_now = now + relativedelta(year=50)
self.verify_timestamp(now, 1000)
self.verify_timestamp(fifty_years_ago, 1000)
self.verify_timestamp(fifty_years_from_now, 1000)
def test_parse_timestamp_parses_nanosecond_timestamps(self):
now = timezone.now().replace(microsecond=0)
fifty_years_ago = now - relativedelta(year=50)
fifty_years_from_now = now + relativedelta(year=50)
self.verify_timestamp(now, 1000000)
self.verify_timestamp(fifty_years_ago, 1000000)
self.verify_timestamp(fifty_years_from_now, 1000000)
def test_parse_timestamp_fails_for_out_of_range_timestamp(self):
now = timezone.now().replace(microsecond=0)
with self.assertRaises(ValueError):
self.verify_timestamp(now, 1000000000)

View File

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

View File

@@ -1,2 +1,105 @@
import re
from datetime import datetime
from typing import Optional
from dateutil.relativedelta import relativedelta
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
def unique(elements, key):
return list({key(element): element for element in elements}.values())
weekday_names = {
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
7: 'Sunday',
}
def humanize_absolute_date(value: datetime, now: Optional[datetime] = None):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
yesterday = now - relativedelta(days=1)
is_older_than_a_week = delta.years > 0 or delta.months > 0 or delta.weeks > 0
if is_older_than_a_week:
return formats.date_format(value, 'SHORT_DATE_FORMAT')
elif value.day == now.day:
return 'Today'
elif value.day == yesterday.day:
return 'Yesterday'
else:
return weekday_names[value.isoweekday()]
def humanize_relative_date(value: datetime, now: Optional[datetime] = None):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
if delta.years > 0:
return f'{delta.years} year{pluralize(delta.years)} ago'
elif delta.months > 0:
return f'{delta.months} month{pluralize(delta.months)} ago'
elif delta.weeks > 0:
return f'{delta.weeks} week{pluralize(delta.weeks)} ago'
else:
yesterday = now - relativedelta(days=1)
if value.day == now.day:
return 'Today'
elif value.day == yesterday.day:
return 'Yesterday'
else:
return weekday_names[value.isoweekday()]
def parse_timestamp(value: str):
"""
Parses a string timestamp into a datetime value
First tries to parse the timestamp as milliseconds.
If that fails with an error indicating that the timestamp exceeds the maximum,
it tries to parse the timestamp as microseconds, and then as nanoseconds
:param value:
:return:
"""
try:
timestamp = int(value)
except ValueError:
raise ValueError(f'{value} is not a valid timestamp')
try:
return datetime.utcfromtimestamp(timestamp).astimezone()
except (OverflowError, ValueError, OSError):
pass
# Value exceeds the max. allowed timestamp
# Try parsing as microseconds
try:
return datetime.utcfromtimestamp(timestamp / 1000).astimezone()
except (OverflowError, ValueError, OSError):
pass
# Value exceeds the max. allowed timestamp
# Try parsing as nanoseconds
try:
return datetime.utcfromtimestamp(timestamp / 1000000).astimezone()
except (OverflowError, ValueError, OSError):
pass
# Timestamp is out of range
raise ValueError(f'{value} exceeds maximum value for a timestamp')
def get_safe_return_url(return_url: str, fallback_url: str):
# Use fallback if URL is none or URL is not on same domain
if not return_url or not re.match(r'^/[a-z]+', return_url):
return fallback_url
return return_url

View File

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

View File

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

View File

@@ -9,11 +9,17 @@ from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm
from bookmarks.queries import query_bookmarks
from bookmarks.services.exporter import export_netscape_html
from bookmarks.services.importer import import_netscape_html
from bookmarks.services import exporter
from bookmarks.services import importer
logger = logging.getLogger(__name__)
try:
with open("version.txt", "r") as f:
app_version = f.read().strip("\n")
except Exception as exc:
logging.exception(exc)
pass
@login_required
def general(request):
@@ -30,21 +36,16 @@ def general(request):
'form': form,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'app_version': app_version
})
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/integrations.html', {
'application_url': application_url,
})
@login_required
def api(request):
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/api.html', {
'api_token': api_token.key
})
@@ -55,11 +56,11 @@ def bookmark_import(request):
if import_file is None:
messages.error(request, 'Please select a file to import.', 'bookmark_import_errors')
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
try:
content = import_file.read().decode()
result = import_netscape_html(content, request.user)
result = importer.import_netscape_html(content, request.user)
success_msg = str(result.success) + ' bookmarks were successfully imported.'
messages.success(request, success_msg, 'bookmark_import_success')
if result.failed > 0:
@@ -78,7 +79,7 @@ def bookmark_export(request):
# noinspection PyBroadException
try:
bookmarks = query_bookmarks(request.user, '')
file_content = export_netscape_html(bookmarks)
file_content = exporter.export_netscape_html(bookmarks)
response = HttpResponse(content_type='text/plain; charset=UTF-8')
response['Content-Disposition'] = 'attachment; filename="bookmarks.html"'

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

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

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Bootstrap script that gets executed in new Docker containers
LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
# Create data folder if it does not exist
mkdir -p data
@@ -12,5 +14,10 @@ python manage.py generate_secret_key
# Ensure the DB folder is owned by the right user
chown -R www-data: /etc/linkding/data
# Start background task processor using supervisord, unless explicitly disabled
if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then
supervisord -c supervisord.conf
fi
# Start uwsgi server
uwsgi uwsgi.ini
uwsgi --http :$LD_SERVER_PORT uwsgi.ini

5
coverage.sh Executable file
View File

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

View File

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

65
docs/Admin.md Normal file
View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

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

Some files were not shown because too many files have changed in this diff Show More