Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2a2c2b0d | ||
|
|
988468f3e5 | ||
|
|
3ac0503843 | ||
|
|
6d3755f46a | ||
|
|
25342e5fb6 | ||
|
|
be548a95a0 | ||
|
|
978fba4cf5 | ||
|
|
8a3572ba4b | ||
|
|
b21812c30a | ||
|
|
72fbf6a590 | ||
|
|
31ac796d6d | ||
|
|
2d81ea6f6e | ||
|
|
2e97b13bad | ||
|
|
30f85103cd | ||
|
|
cfe4ff113d | ||
|
|
757dc56277 | ||
|
|
dfbb367857 | ||
|
|
2276832465 | ||
|
|
9d61bdce52 | ||
|
|
1274a9ae0a | ||
|
|
5e7172d17e | ||
|
|
78608135d9 | ||
|
|
51acd1da3f | ||
|
|
016ff2da66 | ||
|
|
77d7e6e66a | ||
|
|
c5a300a435 | ||
|
|
0d4c47eb81 | ||
|
|
17442eeb9a | ||
|
|
2973812626 | ||
|
|
fc48b266a8 | ||
|
|
7b42241026 | ||
|
|
9c648dc67f | ||
|
|
1624128132 | ||
|
|
d1dd85538b | ||
|
|
c5aab3886e | ||
|
|
3f2739e5a6 | ||
|
|
f1ed89a0ba | ||
|
|
a59a7a777c | ||
|
|
9a5c535872 | ||
|
|
e6ebca1436 | ||
|
|
085d67e9f4 | ||
|
|
68825444fb | ||
|
|
b2ca16ec9c | ||
|
|
649f4154e5 | ||
|
|
d2e8a95e3c | ||
|
|
c3149409b0 | ||
|
|
4626fa1c67 | ||
|
|
6548e16baa | ||
|
|
c177de164a | ||
|
|
e9ecad38ac | ||
|
|
621aedd8eb | ||
|
|
4187141ac8 | ||
|
|
cf0cc32090 | ||
|
|
1f2cf21585 | ||
|
|
0dd05b9269 | ||
|
|
5cd6d773db | ||
|
|
d4c348cc5a | ||
|
|
791a5c73ca | ||
|
|
ebed0c050d | ||
|
|
f4dd2b53b5 | ||
|
|
b53fe09c39 | ||
|
|
ff88e726cc | ||
|
|
52400feacf | ||
|
|
c93709b549 | ||
|
|
ba904ed191 | ||
|
|
d1f81fee0e | ||
|
|
7b405c054d | ||
|
|
23ad52f75d | ||
|
|
c3a2305a5f | ||
|
|
d4006026db | ||
|
|
70bdf88791 |
74
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from file
|
||||
id: get_version
|
||||
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build latest
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest
|
||||
sissbruecker/linkding:${{ env.VERSION }}
|
||||
target: linkding
|
||||
push: true
|
||||
|
||||
- name: Build latest-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||
target: linkding
|
||||
push: true
|
||||
|
||||
- name: Build latest-plus
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-plus
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||
target: linkding-plus
|
||||
push: true
|
||||
|
||||
- name: Build latest-plus-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-plus-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
target: linkding-plus
|
||||
push: true
|
||||
2
.gitignore
vendored
@@ -192,7 +192,7 @@ typings/
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/uBOLite.chromium.mv3
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
||||
114
CHANGELOG.md
@@ -1,5 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
## v1.38.1 (22/02/2025)
|
||||
|
||||
### What's Changed
|
||||
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
|
||||
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
|
||||
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
|
||||
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
|
||||
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
|
||||
|
||||
### New Contributors
|
||||
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
|
||||
|
||||
---
|
||||
|
||||
## v1.38.0 (09/02/2025)
|
||||
|
||||
### What's Changed
|
||||
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
|
||||
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
|
||||
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
|
||||
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
|
||||
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
|
||||
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
|
||||
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
|
||||
|
||||
### New Contributors
|
||||
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
|
||||
|
||||
---
|
||||
|
||||
## v1.37.0 (26/01/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
|
||||
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
|
||||
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
|
||||
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
|
||||
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
|
||||
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
|
||||
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
|
||||
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
|
||||
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
|
||||
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
|
||||
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
|
||||
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
|
||||
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
|
||||
|
||||
### New Contributors
|
||||
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
|
||||
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
|
||||
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
|
||||
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
|
||||
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
|
||||
|
||||
---
|
||||
|
||||
## v1.36.0 (02/10/2024)
|
||||
|
||||
### What's Changed
|
||||
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
|
||||
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
|
||||
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
|
||||
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
|
||||
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
|
||||
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
|
||||
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
|
||||
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
|
||||
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
|
||||
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
|
||||
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
|
||||
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
|
||||
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
|
||||
|
||||
### New Contributors
|
||||
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
|
||||
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
|
||||
|
||||
---
|
||||
|
||||
## v1.35.0 (23/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
|
||||
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
|
||||
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
|
||||
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
|
||||
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
|
||||
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
|
||||
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
|
||||
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
|
||||
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
|
||||
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
|
||||
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
|
||||
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
|
||||
|
||||
### New Contributors
|
||||
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
|
||||
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
|
||||
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
|
||||
|
||||
---
|
||||
|
||||
## v1.34.0 (16/09/2024)
|
||||
|
||||
### What's Changed
|
||||
|
||||
@@ -13,6 +13,29 @@
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||
<text x="770.835px" y="299.13px" style="font-family:'HelveticaNeue', 'Helvetica Neue';font-size:50px;fill:rgb(94,94,219);">l<tspan x="782.685px 794.535px 823.085px 849.785px 880.185px 892.035px 920.585px " y="299.13px 299.13px 299.13px 299.13px 299.13px 299.13px 299.13px ">inkding</tspan></text>
|
||||
<g transform="matrix(50,0,0,50,770.835,299.13)">
|
||||
<rect x="0.064" y="-0.716" width="0.088" height="0.716" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,782.693,299.13)">
|
||||
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,794.552,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,823.109,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.716L0.154,-0.716L0.154,-0.308L0.362,-0.519L0.476,-0.519L0.278,-0.326L0.496,-0L0.388,-0L0.216,-0.265L0.154,-0.206L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,848.859,299.13)">
|
||||
<path d="M0.402,-0L0.402,-0.065C0.369,-0.014 0.321,0.012 0.257,0.012C0.216,0.012 0.178,0 0.143,-0.022C0.109,-0.045 0.082,-0.077 0.063,-0.118C0.044,-0.159 0.034,-0.206 0.034,-0.259C0.034,-0.311 0.043,-0.357 0.06,-0.399C0.077,-0.442 0.103,-0.474 0.138,-0.497C0.172,-0.519 0.211,-0.53 0.253,-0.53C0.285,-0.53 0.313,-0.524 0.337,-0.51C0.361,-0.497 0.381,-0.48 0.396,-0.459L0.396,-0.716L0.484,-0.716L0.484,-0L0.402,-0ZM0.125,-0.259C0.125,-0.192 0.139,-0.143 0.167,-0.11C0.194,-0.077 0.228,-0.061 0.266,-0.061C0.304,-0.061 0.337,-0.076 0.363,-0.107C0.39,-0.139 0.404,-0.187 0.404,-0.251C0.404,-0.322 0.39,-0.375 0.363,-0.408C0.335,-0.441 0.302,-0.458 0.262,-0.458C0.223,-0.458 0.19,-0.442 0.164,-0.41C0.138,-0.378 0.125,-0.327 0.125,-0.259Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,877.417,299.13)">
|
||||
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,889.275,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,917.833,299.13)">
|
||||
<path d="M0.05,0.043L0.135,0.056C0.139,0.082 0.149,0.101 0.165,0.113C0.187,0.13 0.217,0.138 0.254,0.138C0.295,0.138 0.326,0.13 0.349,0.113C0.371,0.097 0.386,0.074 0.394,0.045C0.398,0.027 0.4,-0.011 0.4,-0.068C0.361,-0.023 0.314,-0 0.256,-0C0.185,-0 0.13,-0.026 0.091,-0.077C0.052,-0.129 0.032,-0.19 0.032,-0.262C0.032,-0.312 0.041,-0.357 0.059,-0.399C0.077,-0.441 0.103,-0.473 0.137,-0.496C0.171,-0.519 0.211,-0.53 0.257,-0.53C0.318,-0.53 0.368,-0.506 0.408,-0.456L0.408,-0.519L0.489,-0.519L0.489,-0.07C0.489,0.01 0.481,0.068 0.464,0.101C0.448,0.135 0.422,0.162 0.386,0.181C0.351,0.201 0.307,0.21 0.255,0.21C0.193,0.21 0.143,0.196 0.105,0.168C0.067,0.141 0.049,0.099 0.05,0.043ZM0.123,-0.269C0.123,-0.201 0.136,-0.151 0.163,-0.12C0.19,-0.088 0.224,-0.073 0.265,-0.073C0.305,-0.073 0.339,-0.088 0.366,-0.119C0.394,-0.15 0.407,-0.199 0.407,-0.266C0.407,-0.329 0.393,-0.377 0.365,-0.409C0.337,-0.441 0.303,-0.458 0.263,-0.458C0.224,-0.458 0.191,-0.442 0.164,-0.41C0.136,-0.378 0.123,-0.331 0.123,-0.269Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 5.5 KiB |
34
bookmarks/api/auth.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
||||
|
||||
|
||||
class LinkdingTokenAuthentication(TokenAuthentication):
|
||||
"""
|
||||
Extends DRF TokenAuthentication to add support for multiple keywords
|
||||
"""
|
||||
|
||||
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
|
||||
|
||||
def authenticate(self, request):
|
||||
auth = get_authorization_header(request).split()
|
||||
|
||||
if not auth or auth[0].lower() not in self.keywords:
|
||||
return None
|
||||
|
||||
if len(auth) == 1:
|
||||
msg = _("Invalid token header. No credentials provided.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
elif len(auth) > 2:
|
||||
msg = _("Invalid token header. Token string should not contain spaces.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
token = auth[1].decode()
|
||||
except UnicodeError:
|
||||
msg = _(
|
||||
"Invalid token header. Token string should not contain invalid characters."
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
@@ -1,25 +1,24 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import SimpleRouter, DefaultRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import (
|
||||
BookmarkSerializer,
|
||||
BookmarkAssetSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
website_loader,
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,10 +56,12 @@ class BookmarkViewSet(
|
||||
|
||||
def get_serializer_context(self):
|
||||
disable_scraping = "disable_scraping" in self.request.GET
|
||||
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
|
||||
return {
|
||||
"request": self.request,
|
||||
"user": self.request.user,
|
||||
"disable_scraping": disable_scraping,
|
||||
"disable_html_snapshot": disable_html_snapshot,
|
||||
}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@@ -89,13 +90,13 @@ class BookmarkViewSet(
|
||||
@action(methods=["post"], detail=True)
|
||||
def archive(self, request, pk):
|
||||
bookmark = self.get_object()
|
||||
archive_bookmark(bookmark)
|
||||
bookmarks.archive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def unarchive(self, request, pk):
|
||||
bookmark = self.get_object()
|
||||
unarchive_bookmark(bookmark)
|
||||
bookmarks.unarchive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@@ -129,6 +130,118 @@ class BookmarkViewSet(
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def singlefile(self, request):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
url = request.data.get("url")
|
||||
file = request.FILES.get("file")
|
||||
|
||||
if not url or not file:
|
||||
return Response(
|
||||
{"error": "Both 'url' and 'file' parameters are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(url=url)
|
||||
bookmark = bookmarks.create_bookmark(
|
||||
bookmark, "", request.user, disable_html_snapshot=True
|
||||
)
|
||||
bookmarks.enhance_with_website_metadata(bookmark)
|
||||
|
||||
assets.upload_snapshot(bookmark, file.read())
|
||||
|
||||
return Response(
|
||||
{"message": "Snapshot uploaded successfully."},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkAssetViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
serializer_class = BookmarkAssetSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
bookmark_id = self.kwargs["bookmark_id"]
|
||||
if not Bookmark.objects.filter(id=bookmark_id, owner=user).exists():
|
||||
raise Http404("Bookmark does not exist")
|
||||
return BookmarkAsset.objects.filter(
|
||||
bookmark_id=bookmark_id, bookmark__owner=user
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="download")
|
||||
def download(self, request, bookmark_id, pk):
|
||||
asset = self.get_object()
|
||||
try:
|
||||
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
content_type = asset.content_type
|
||||
file_stream = (
|
||||
gzip.GzipFile(file_path, mode="rb")
|
||||
if asset.gzip
|
||||
else open(file_path, "rb")
|
||||
)
|
||||
file_name = (
|
||||
f"{asset.display_name}.html"
|
||||
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
||||
else asset.display_name
|
||||
)
|
||||
response = FileResponse(file_stream, content_type=content_type)
|
||||
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
||||
return response
|
||||
except FileNotFoundError:
|
||||
raise Http404("Asset file does not exist")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def upload(self, request, bookmark_id):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
bookmark = Bookmark.objects.filter(id=bookmark_id, owner=request.user).first()
|
||||
if not bookmark:
|
||||
raise Http404("Bookmark does not exist")
|
||||
|
||||
upload_file = request.FILES.get("file")
|
||||
if not upload_file:
|
||||
return Response(
|
||||
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
serializer = self.get_serializer(asset)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Failed to upload asset."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class TagViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
@@ -152,7 +265,19 @@ class UserViewSet(viewsets.GenericViewSet):
|
||||
return Response(UserProfileSerializer(request.user.profile).data)
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||
router.register(r"tags", TagViewSet, basename="tag")
|
||||
router.register(r"user", UserViewSet, basename="user")
|
||||
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
||||
# Instead create separate routers for each view set and manually register them in urls.py
|
||||
# The default router is only used to allow reversing a URL for the API root
|
||||
default_router = DefaultRouter()
|
||||
|
||||
bookmark_router = SimpleRouter()
|
||||
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
|
||||
|
||||
tag_router = SimpleRouter()
|
||||
tag_router.register("", TagViewSet, basename="tag")
|
||||
|
||||
user_router = SimpleRouter()
|
||||
user_router.register("", UserViewSet, basename="user")
|
||||
|
||||
bookmark_asset_router = SimpleRouter()
|
||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||
|
||||
@@ -3,13 +3,11 @@ from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services.bookmarks import (
|
||||
create_bookmark,
|
||||
update_bookmark,
|
||||
enhance_with_website_metadata,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services import bookmarks
|
||||
from bookmarks.services.tags import get_or_create_tag
|
||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||
from bookmarks.utils import app_version
|
||||
|
||||
|
||||
class TagListField(serializers.ListField):
|
||||
@@ -49,6 +47,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"website_title",
|
||||
@@ -56,17 +55,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
list_serializer_class = BookmarkListSerializer
|
||||
|
||||
# Override optional char fields to provide default value
|
||||
title = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
is_archived = serializers.BooleanField(required=False, default=False)
|
||||
unread = serializers.BooleanField(required=False, default=False)
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
# Custom tag_names field to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False)
|
||||
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
|
||||
favicon_url = serializers.SerializerMethodField()
|
||||
preview_image_url = serializers.SerializerMethodField()
|
||||
web_archive_snapshot_url = serializers.SerializerMethodField()
|
||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||
website_title = serializers.SerializerMethodField()
|
||||
website_description = serializers.SerializerMethodField()
|
||||
@@ -87,6 +81,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def get_web_archive_snapshot_url(self, obj: Bookmark):
|
||||
if obj.web_archive_snapshot_url:
|
||||
return obj.web_archive_snapshot_url
|
||||
|
||||
return generate_fallback_webarchive_url(obj.url, obj.date_added)
|
||||
|
||||
def get_website_title(self, obj: Bookmark):
|
||||
return None
|
||||
|
||||
@@ -94,36 +94,68 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
bookmark.url = validated_data["url"]
|
||||
bookmark.title = validated_data["title"]
|
||||
bookmark.description = validated_data["description"]
|
||||
bookmark.notes = validated_data["notes"]
|
||||
bookmark.is_archived = validated_data["is_archived"]
|
||||
bookmark.unread = validated_data["unread"]
|
||||
bookmark.shared = validated_data["shared"]
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
tag_names = validated_data.pop("tag_names", [])
|
||||
tag_string = build_tag_string(tag_names)
|
||||
bookmark = Bookmark(**validated_data)
|
||||
|
||||
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
disable_scraping = self.context.get("disable_scraping", False)
|
||||
disable_html_snapshot = self.context.get("disable_html_snapshot", False)
|
||||
|
||||
saved_bookmark = bookmarks.create_bookmark(
|
||||
bookmark,
|
||||
tag_string,
|
||||
self.context["user"],
|
||||
disable_html_snapshot=disable_html_snapshot,
|
||||
)
|
||||
# Unless scraping is explicitly disabled, enhance bookmark with website
|
||||
# metadata to preserve backwards compatibility with clients that expect
|
||||
# title and description to be populated automatically when left empty
|
||||
if not self.context.get("disable_scraping", False):
|
||||
enhance_with_website_metadata(saved_bookmark)
|
||||
if not disable_scraping:
|
||||
bookmarks.enhance_with_website_metadata(saved_bookmark)
|
||||
return saved_bookmark
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
# Update fields if they were provided in the payload
|
||||
for key in ["url", "title", "description", "notes", "unread", "shared"]:
|
||||
if key in validated_data:
|
||||
setattr(instance, key, validated_data[key])
|
||||
tag_names = validated_data.pop("tag_names", instance.tag_names)
|
||||
tag_string = build_tag_string(tag_names)
|
||||
|
||||
# Use tag string from payload, or use bookmark's current tags as fallback
|
||||
tag_string = build_tag_string(instance.tag_names)
|
||||
if "tag_names" in validated_data:
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
for field_name, field in self.fields.items():
|
||||
if not field.read_only and field_name in validated_data:
|
||||
setattr(instance, field_name, validated_data[field_name])
|
||||
|
||||
return update_bookmark(instance, tag_string, self.context["user"])
|
||||
return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
|
||||
|
||||
def validate(self, attrs):
|
||||
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||
# updating the existing bookmark instead. When editing a bookmark,
|
||||
# there is no assumption that it would update a different bookmark if
|
||||
# the URL is a duplicate, so raise a validation error in that case.
|
||||
if self.instance and "url" in attrs:
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
if is_duplicate:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "A bookmark with this URL already exists."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookmarkAssetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookmarkAsset
|
||||
fields = [
|
||||
"id",
|
||||
"bookmark",
|
||||
"date_created",
|
||||
"file_size",
|
||||
"asset_type",
|
||||
"content_type",
|
||||
"display_name",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
@@ -151,4 +183,11 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"search_preferences",
|
||||
"version",
|
||||
]
|
||||
read_only_fields = ["version"]
|
||||
|
||||
version = serializers.SerializerMethodField()
|
||||
|
||||
def get_version(self, obj: UserProfile):
|
||||
return app_version
|
||||
|
||||
48
bookmarks/e2e/e2e_test_collapse_side_panel.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
def assertSidePanelIsVisible(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
).not_to_be_visible()
|
||||
|
||||
def assertSidePanelIsHidden(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
).to_be_visible()
|
||||
|
||||
def test_side_panel_should_be_visible_by_default(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
self.assertSidePanelIsVisible()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
|
||||
self.assertSidePanelIsVisible()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
|
||||
self.assertSidePanelIsVisible()
|
||||
|
||||
def test_side_panel_should_be_hidden_when_collapsed(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.collapse_side_panel = True
|
||||
user.profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
self.assertSidePanelIsHidden()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
|
||||
self.assertSidePanelIsHidden()
|
||||
|
||||
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
|
||||
self.assertSidePanelIsHidden()
|
||||
@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_modal_close_modal(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
# use smaller viewport to make filter button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
# open drawer
|
||||
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Filters"
|
||||
)
|
||||
modal_trigger.click()
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator("h2")).to_have_text("Tags")
|
||||
# verify drawer is visible
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
expect(drawer).to_be_visible()
|
||||
expect(drawer.locator("h2")).to_have_text("Filters")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
expect(modal).to_be_hidden()
|
||||
drawer.locator("button.close").click()
|
||||
expect(drawer).to_be_hidden()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
# open drawer again
|
||||
drawer_trigger.click()
|
||||
|
||||
# close with backdrop
|
||||
backdrop = modal.locator(".modal-overlay")
|
||||
backdrop = drawer.locator(".modal-overlay")
|
||||
backdrop.click(position={"x": 0, "y": 0})
|
||||
expect(modal).to_be_hidden()
|
||||
expect(drawer).to_be_hidden()
|
||||
|
||||
def test_select_tag(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
# use smaller viewport to make filter button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Filters"
|
||||
)
|
||||
modal_trigger.click()
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
modal = page.locator(".modal")
|
||||
unselected_tags = modal.locator(".unselected-tags")
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
unselected_tags = drawer.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
# select tag
|
||||
unselected_tags.get_by_text("cooking").click()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
# open drawer again
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify tag is selected, other tag is not visible anymore
|
||||
selected_tags = modal.locator(".selected-tags")
|
||||
selected_tags = drawer.locator(".selected-tags")
|
||||
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||
|
||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
||||
@@ -1,61 +1,28 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
import { ModalBehavior } from "./modal";
|
||||
|
||||
class DetailsModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
class DetailsModalBehavior extends ModalBehavior {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.element.dataset.closeUrl;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
|
||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.element.dataset.bookmarkId;
|
||||
const restoreFocusElement =
|
||||
document.querySelector(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
) ||
|
||||
document.querySelector("ul.bookmark-list") ||
|
||||
document.body;
|
||||
|
||||
this.overlayLink.addEventListener("click", this.onClose);
|
||||
this.buttonLink.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlayLink.removeEventListener("click", this.onClose);
|
||||
this.buttonLink.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
|
||||
const closeUrl = this.overlayLink.href;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click then JS is enabled
|
||||
this.element.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.element.addEventListener("keydown", this.onEscape);
|
||||
this.element.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
this.element.removeEventListener("keydown", this.onEscape);
|
||||
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.element.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.element.classList.remove("active");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.element.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||
|
||||
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { ModalBehavior } from "./modal";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
|
||||
class FilterDrawerTriggerBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "drawer", "filter-drawer");
|
||||
modal.setAttribute("ld-filter-drawer", "");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<section class="content content-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDrawerBehavior extends ModalBehavior {
|
||||
init() {
|
||||
// Teleport content before creating focus trap, otherwise it will not detect
|
||||
// focusable content elements
|
||||
this.teleport();
|
||||
super.init();
|
||||
// Add active class to start slide-in animation
|
||||
this.element.classList.add("active");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
// Always close on destroy to restore drawer content to original location
|
||||
// before turbo caches DOM
|
||||
this.doClose();
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.element.querySelector(".content");
|
||||
const sidePanel = document.querySelector("section.side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector("section.side-panel");
|
||||
const content = this.element.querySelector(".content");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
this.teleportBack();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
||||
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
||||
59
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,59 @@
|
||||
let keyboardActive = false;
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
() => {
|
||||
keyboardActive = true;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"mousedown",
|
||||
() => {
|
||||
keyboardActive = false;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
export function isKeyboardActive() {
|
||||
return keyboardActive;
|
||||
}
|
||||
|
||||
export class FocusTrapController {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.focusableElements = this.element.querySelectorAll(
|
||||
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
this.firstFocusableElement = this.focusableElements[0];
|
||||
this.lastFocusableElement =
|
||||
this.focusableElements[this.focusableElements.length - 1];
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === this.firstFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.lastFocusableElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === this.lastFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
bookmarks/frontend/behaviors/modal.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Behavior } from "./index";
|
||||
import { FocusTrapController } from "./focus-utils";
|
||||
|
||||
export class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.overlay = element.querySelector(".modal-overlay");
|
||||
this.closeButton = element.querySelector(".modal-header .close");
|
||||
|
||||
this.overlay.addEventListener("click", this.onClose);
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlay.removeEventListener("click", this.onClose);
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.clearInert();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupInert() {
|
||||
// Inert all other elements on the page
|
||||
document
|
||||
.querySelectorAll("body > *:not(.modals)")
|
||||
.forEach((el) => el.setAttribute("inert", ""));
|
||||
// Lock scroll on the body
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.clearInert();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class TagModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>Tags</h2>
|
||||
<button class="close" aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tagCloud = document.querySelector(".tag-cloud");
|
||||
const tagCloudContainer = tagCloud.parentElement;
|
||||
|
||||
const content = modal.querySelector(".content");
|
||||
content.appendChild(tagCloud);
|
||||
|
||||
const overlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".close");
|
||||
overlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
this.modal = modal;
|
||||
this.tagCloud = tagCloud;
|
||||
this.tagCloudContainer = tagCloudContainer;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.remove();
|
||||
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-modal", TagModalBehavior);
|
||||
@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
|
||||
18
bookmarks/migrations/0042_userprofile_custom_css_hash.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-28 08:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0041_merge_metadata"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="custom_css_hash",
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-02 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0042_userprofile_custom_css_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="collapse_side_panel",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
@@ -92,6 +93,19 @@ class Bookmark(models.Model):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Bookmark)
|
||||
def bookmark_deleted(sender, instance, **kwargs):
|
||||
if instance.preview_image_file:
|
||||
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
|
||||
if os.path.isfile(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Failed to delete preview image: {filepath}", exc_info=error
|
||||
)
|
||||
|
||||
|
||||
class BookmarkAsset(models.Model):
|
||||
TYPE_SNAPSHOT = "snapshot"
|
||||
TYPE_UPLOAD = "upload"
|
||||
@@ -168,6 +182,24 @@ class BookmarkForm(forms.ModelForm):
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
|
||||
def clean_url(self):
|
||||
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||
# updating the existing bookmark instead, which is also communicated in
|
||||
# the form's UI. When editing a bookmark, there is no assumption that
|
||||
# it would update a different bookmark if the URL is a duplicate, so
|
||||
# raise a validation error in that case.
|
||||
url = self.cleaned_data["url"]
|
||||
if self.instance.pk:
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=url)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
if is_duplicate:
|
||||
raise forms.ValidationError("A bookmark with this URL already exists.")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
SORT_ADDED_ASC = "added_asc"
|
||||
@@ -412,6 +444,7 @@ class UserProfile(models.Model):
|
||||
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
permanent_notes = models.BooleanField(default=False, null=False)
|
||||
custom_css = models.TextField(blank=True, null=False)
|
||||
custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
|
||||
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
@@ -420,6 +453,16 @@ class UserProfile(models.Model):
|
||||
null=False, default=30, validators=[MinValueValidator(10)]
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
self.custom_css_hash = hashlib.md5(
|
||||
self.custom_css.encode("utf-8")
|
||||
).hexdigest()
|
||||
else:
|
||||
self.custom_css_hash = ""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -450,6 +493,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
]
|
||||
|
||||
|
||||
|
||||
128
bookmarks/services/assets.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset
|
||||
from bookmarks.services import singlefile
|
||||
|
||||
MAX_ASSET_FILENAME_LENGTH = 192
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
date_created = timezone.now()
|
||||
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
date_created=date_created,
|
||||
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
|
||||
display_name=f"HTML snapshot from {timestamp}",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def create_snapshot(asset: BookmarkAsset):
|
||||
try:
|
||||
# Create snapshot into temporary file
|
||||
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
|
||||
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
|
||||
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
|
||||
|
||||
# Store as gzip in asset folder
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(temp_filepath, "rb") as temp_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
shutil.copyfileobj(temp_file, gz_file)
|
||||
|
||||
# Remove temporary file
|
||||
os.remove(temp_filepath)
|
||||
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
except Exception as error:
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
raise error
|
||||
|
||||
|
||||
def upload_snapshot(bookmark: Bookmark, html: bytes):
|
||||
asset = create_snapshot_asset(bookmark)
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
|
||||
with gzip.open(filepath, "wb") as gz_file:
|
||||
gz_file.write(html)
|
||||
|
||||
# Only save the asset if the file was written successfully
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
||||
try:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
date_created=timezone.now(),
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
gzip=False,
|
||||
)
|
||||
name, extension = os.path.splitext(upload_file.name)
|
||||
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
asset.save()
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
return asset
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
def _generate_asset_filename(
|
||||
asset: BookmarkAsset, filename: str, extension: str
|
||||
) -> str:
|
||||
def sanitize_char(char):
|
||||
if char.isalnum() or char in ("-", "_", "."):
|
||||
return char
|
||||
else:
|
||||
return "_"
|
||||
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
sanitized_filename = "".join(sanitize_char(char) for char in filename)
|
||||
|
||||
# Calculate the length of fixed parts of the final filename
|
||||
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
|
||||
# Calculate the maximum length for the dynamic part of the filename
|
||||
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
|
||||
# Truncate the filename if necessary
|
||||
sanitized_filename = sanitized_filename[:max_filename_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"
|
||||
@@ -7,6 +7,9 @@ def get_tags(script: str, url: str):
|
||||
parsed_url = urlparse(url.lower())
|
||||
result = set()
|
||||
|
||||
if not parsed_url.hostname:
|
||||
return result
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
if "#" in line:
|
||||
i = line.index("#")
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
def create_bookmark(
|
||||
bookmark: Bookmark,
|
||||
tag_string: str,
|
||||
current_user: User,
|
||||
disable_html_snapshot: bool = False,
|
||||
):
|
||||
# If URL is already bookmarked, then update it
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||
owner=current_user, url=bookmark.url
|
||||
@@ -42,7 +44,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
if (
|
||||
current_user.profile.enable_automatic_html_snapshots
|
||||
and not disable_html_snapshot
|
||||
):
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
return bookmark
|
||||
@@ -65,7 +70,6 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||
bookmark.save()
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -194,46 +198,6 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
)
|
||||
|
||||
|
||||
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
gzip=False,
|
||||
)
|
||||
asset.save()
|
||||
|
||||
try:
|
||||
filename = _generate_upload_asset_filename(asset, upload_file.name)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
|
||||
asset.save()
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
|
||||
@@ -40,9 +40,10 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
toread = "1" if bookmark.unread else "0"
|
||||
private = "0" if bookmark.shared else "1"
|
||||
added = int(bookmark.date_added.timestamp())
|
||||
modified = int(bookmark.date_modified.timestamp())
|
||||
|
||||
doc.append(
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" LAST_MODIFIED="{modified}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
)
|
||||
|
||||
if desc:
|
||||
|
||||
@@ -231,7 +231,10 @@ def _copy_bookmark_data(
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
else:
|
||||
bookmark.date_added = timezone.now()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
if netscape_bookmark.date_modified:
|
||||
bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)
|
||||
else:
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
bookmark.unread = netscape_bookmark.to_read
|
||||
if netscape_bookmark.title:
|
||||
bookmark.title = netscape_bookmark.title
|
||||
|
||||
@@ -12,6 +12,7 @@ class NetscapeBookmark:
|
||||
description: str
|
||||
notes: str
|
||||
date_added: str
|
||||
date_modified: str
|
||||
tag_names: List[str]
|
||||
to_read: bool
|
||||
private: bool
|
||||
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark = None
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.last_modified = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
|
||||
description="",
|
||||
notes="",
|
||||
date_added=self.add_date,
|
||||
date_modified=self.last_modified,
|
||||
tag_names=tag_names,
|
||||
to_read=self.toread == "1",
|
||||
# Mark as private by default, also when attribute is not specified
|
||||
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark = None
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.last_modified = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_snapshot(url: str, filepath: str):
|
||||
singlefile_path = settings.LD_SINGLEFILE_PATH
|
||||
|
||||
# parse options to list of arguments
|
||||
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
|
||||
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
|
||||
temp_filepath = filepath + ".tmp"
|
||||
# concat lists
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
|
||||
try:
|
||||
# Use start_new_session=True to create a new process group
|
||||
process = subprocess.Popen(args, start_new_session=True)
|
||||
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
|
||||
|
||||
# check if the file was created
|
||||
if not os.path.exists(temp_filepath):
|
||||
if not os.path.exists(filepath):
|
||||
raise SingleFileError("Failed to create snapshot")
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
shutil.copyfileobj(raw_file, gz_file)
|
||||
|
||||
os.remove(temp_filepath)
|
||||
except subprocess.TimeoutExpired:
|
||||
# First try to terminate properly
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import waybackpy
|
||||
@@ -8,14 +7,13 @@ from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone, formats
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from huey.exceptions import TaskLockedException
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||
from bookmarks.services import assets, favicon_loader, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -236,7 +234,7 @@ def create_html_snapshot(bookmark: Bookmark):
|
||||
if not is_html_snapshot_feature_active():
|
||||
return
|
||||
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
|
||||
|
||||
@@ -246,47 +244,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
|
||||
|
||||
assets_to_create = []
|
||||
for bookmark in bookmark_list:
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
assets_to_create.append(asset)
|
||||
|
||||
BookmarkAsset.objects.bulk_create(assets_to_create)
|
||||
|
||||
|
||||
MAX_SNAPSHOT_FILENAME_LENGTH = 192
|
||||
|
||||
|
||||
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
content_type="text/html",
|
||||
display_name=f"HTML snapshot from {timestamp}",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
|
||||
def sanitize_char(char):
|
||||
if char.isalnum() or char in ("-", "_", "."):
|
||||
return char
|
||||
else:
|
||||
return "_"
|
||||
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
|
||||
|
||||
# Calculate the length of the non-URL parts of the filename
|
||||
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
|
||||
# Calculate the maximum length for the URL part
|
||||
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
|
||||
# Truncate the URL if necessary
|
||||
sanitized_url = sanitized_url[:max_url_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
|
||||
|
||||
|
||||
# singe-file does not support running multiple instances in parallel, so we can
|
||||
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic
|
||||
# task that grabs a number of pending assets and creates snapshots for them in
|
||||
@@ -313,13 +276,8 @@ def _create_html_snapshot_task(asset_id: int):
|
||||
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
|
||||
|
||||
try:
|
||||
filename = _generate_snapshot_filename(asset)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
singlefile.create_snapshot(asset.bookmark.url, filepath)
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
assets.create_snapshot(asset)
|
||||
|
||||
logger.info(
|
||||
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
|
||||
)
|
||||
@@ -328,8 +286,6 @@ def _create_html_snapshot_task(asset_id: int):
|
||||
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
|
||||
exc_info=error,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
|
||||
|
||||
def create_missing_html_snapshots(user: User) -> int:
|
||||
|
||||
2
bookmarks/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
margin: var(--unit-4 0);
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
@@ -36,8 +36,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
& .sections section {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
|
||||
& .sections h3 {
|
||||
margin-bottom: var(--unit-2);
|
||||
font-size: var(--font-size);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .assets {
|
||||
|
||||
@@ -10,8 +10,38 @@
|
||||
}
|
||||
|
||||
/* Bookmark page grid */
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
.bookmarks-page {
|
||||
&.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
section.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse-side-panel {
|
||||
section.main {
|
||||
grid-column: span var(--grid-columns);
|
||||
}
|
||||
|
||||
section.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
@@ -14,7 +15,8 @@ section.content-area {
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
|
||||
@@ -141,3 +141,10 @@
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--contrast-5);
|
||||
}
|
||||
|
||||
/* Try to force dark color scheme for all native elements (e.g. upload button
|
||||
in file inputs, native select dropdown). For the select dropdown some browsers
|
||||
ignore this and use whatever users have configured in their system settings. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ html {
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
--dropdown-focus-display: block;
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
@@ -20,9 +22,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu,
|
||||
.dropdown-toggle:focus + .menu,
|
||||
.menu:hover {
|
||||
&:focus-within .menu {
|
||||
/* Use custom CSS property to allow disabling opening on focus when using JS */
|
||||
display: var(--dropdown-focus-display);
|
||||
}
|
||||
|
||||
&.active .menu {
|
||||
/* Always show menu when class is added through JS */
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,16 @@ textarea.form-input {
|
||||
no-repeat right 0.35rem center / 0.4rem 0.5rem;
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
}
|
||||
|
||||
/* Options */
|
||||
& option {
|
||||
/* On Windows with Chrome / Edge, options seems to use the same
|
||||
background color as the select. However for the dark theme the
|
||||
background is a semi-transparent white, resulting in an opaque white
|
||||
background for the dropdown. Use the modal background color to force
|
||||
a dark background instead. */
|
||||
background: var(--modal-container-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Checkbox and Radio */
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
cursor: default;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
@@ -62,13 +62,14 @@
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-6);
|
||||
padding-bottom: 0;
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
@@ -78,7 +79,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& button.close {
|
||||
& .close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
@@ -95,10 +96,53 @@
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 0 var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-body:not(:has(+ .modal-footer)) {
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.modal.drawer {
|
||||
display: block;
|
||||
|
||||
& .modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border: none;
|
||||
border-left: solid 1px var(--modal-container-border-color);
|
||||
border-radius: 0;
|
||||
transform: translateX(100%);
|
||||
animation: fade-in 0.25s ease 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& .modal-container {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-container {
|
||||
animation: fade-out 0.25s ease 1;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-lock {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<section class="main content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<section class="side-panel content-area col-1">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
@@ -39,12 +39,14 @@
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
role="list" tabindex="-1"
|
||||
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
@@ -78,7 +80,8 @@
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
|
||||
@@ -33,12 +33,16 @@
|
||||
|
||||
{% if details.is_editable %}
|
||||
<div class="assets-actions">
|
||||
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
{% if details.snapshots_enabled %}
|
||||
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if details.uploads_enabled %}
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
{% endif %}
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -40,14 +40,14 @@
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||
<div class="sections grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<div class="status col-2">
|
||||
<dt>Status</dt>
|
||||
<dd class="d-flex" style="gap: .8rem">
|
||||
<section class="status col-2">
|
||||
<h3>Status</h3>
|
||||
<div class="d-flex" style="gap: .8rem">
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||
@@ -71,44 +71,42 @@
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.show_files %}
|
||||
<div class="files col-2">
|
||||
<dt>Files</dt>
|
||||
<dd>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</dd>
|
||||
<section class="files col-2">
|
||||
<h3>Files</h3>
|
||||
<div>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if details.bookmark.tag_names %}
|
||||
<div class="tags col-1">
|
||||
<dt>Tags</dt>
|
||||
<dd>
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div>
|
||||
{% for tag_name in details.bookmark.tag_names %}
|
||||
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div class="date-added col-1">
|
||||
<dt>Date added</dt>
|
||||
<dd>
|
||||
<section class="date-added col-1">
|
||||
<h3>Date added</h3>
|
||||
<div>
|
||||
<span>{{ details.bookmark.date_added }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<div class="description col-2">
|
||||
<dt>Description</dt>
|
||||
<dd>{{ details.bookmark.resolved_description }}</dd>
|
||||
</div>
|
||||
</section>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<section class="description col-2">
|
||||
<h3>Description</h3>
|
||||
<div>{{ details.bookmark.resolved_description }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.notes %}
|
||||
<div class="notes col-2">
|
||||
<dt>Notes</dt>
|
||||
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
||||
</div>
|
||||
<section class="notes col-2">
|
||||
<h3>Notes</h3>
|
||||
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
<div class="modal active bookmark-details"
|
||||
ld-details-modal>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
</a>
|
||||
<div class="modal-container">
|
||||
<div class="modal active bookmark-details" ld-details-modal
|
||||
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<button class="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content">
|
||||
|
||||
@@ -30,11 +30,14 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% endif %}
|
||||
{% if request.user_profile.custom_css %}
|
||||
<style>{{ request.user_profile.custom_css }}</style>
|
||||
<link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
|
||||
{% endif %}
|
||||
<meta name="turbo-cache-control" content="no-preview">
|
||||
{% if not request.global_settings.enable_link_prefetch %}
|
||||
<meta name="turbo-prefetch" content="false">
|
||||
{% endif %}
|
||||
{% if rss_feed_url %}
|
||||
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
|
||||
{% endif %}
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
</head>
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<section class="main content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +31,7 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<section class="side-panel content-area col-1">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
@@ -38,12 +39,14 @@
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
@@ -97,5 +97,9 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="modals">
|
||||
{% block overlays %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div class="dropdown">
|
||||
<div ld-dropdown class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Bookmarks
|
||||
</button>
|
||||
<ul class="menu">
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||
</li>
|
||||
@@ -28,28 +28,28 @@
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||
<a href="{% url 'bookmarks:new' %}" aria-label="Add bookmark" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||
</li>
|
||||
@@ -72,7 +72,7 @@
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link menu-link">Logout</button>
|
||||
</form>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
{% endfor %}
|
||||
</form>
|
||||
<div ld-dropdown class="search-options dropdown dropdown-right">
|
||||
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
|
||||
<button type="button" aria-label="Search preferences"
|
||||
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
@@ -41,8 +42,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'shared' in preferences_form.editable_fields %}
|
||||
<div class="form-group radio-group">
|
||||
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
|
||||
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
|
||||
<label id="search-shared-label"
|
||||
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
|
||||
Shared filter
|
||||
</label>
|
||||
{% for radio in preferences_form.shared %}
|
||||
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
||||
{{ radio.tag }}
|
||||
@@ -53,8 +57,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'unread' in preferences_form.editable_fields %}
|
||||
<div class="form-group radio-group">
|
||||
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
|
||||
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
|
||||
<label id="search-unread-label"
|
||||
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
|
||||
Unread filter
|
||||
</label>
|
||||
{% for radio in preferences_form.unread %}
|
||||
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
||||
{{ radio.tag }}
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page grid columns-md-1">
|
||||
<div
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<section class="main content-area col-2">
|
||||
<div class="content-area-header">
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</section>
|
||||
|
||||
{# Filters #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<section class="side-panel content-area col-1">
|
||||
<div class="content-area-header">
|
||||
<h2>User</h2>
|
||||
</div>
|
||||
@@ -43,12 +43,14 @@
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
||||
@@ -124,6 +124,16 @@
|
||||
visible without having to scroll to the end of the page first.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
|
||||
{{ form.collapse_side_panel }}
|
||||
<i class="form-icon"></i> Collapse side panel
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
|
||||
Instead, the tags are shown in an expandable drawer.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import random
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from unittest import TestCase
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag
|
||||
@@ -17,6 +24,16 @@ from bookmarks.models import Bookmark, BookmarkAsset, Tag
|
||||
class BookmarkFactoryMixin:
|
||||
user = None
|
||||
|
||||
def setup_temp_assets_dir(self):
|
||||
self.assets_dir = tempfile.mkdtemp()
|
||||
self.settings_override = override_settings(LD_ASSET_FOLDER=self.assets_dir)
|
||||
self.settings_override.enable()
|
||||
self.addCleanup(self.cleanup_temp_assets_dir)
|
||||
|
||||
def cleanup_temp_assets_dir(self):
|
||||
shutil.rmtree(self.assets_dir)
|
||||
self.settings_override.disable()
|
||||
|
||||
def get_or_create_test_user(self):
|
||||
if self.user is None:
|
||||
self.user = User.objects.create_user(
|
||||
@@ -45,6 +62,7 @@ class BookmarkFactoryMixin:
|
||||
favicon_file: str = "",
|
||||
preview_image_file: str = "",
|
||||
added: datetime = None,
|
||||
modified: datetime = None,
|
||||
):
|
||||
if title is None:
|
||||
title = get_random_string(length=32)
|
||||
@@ -57,13 +75,15 @@ class BookmarkFactoryMixin:
|
||||
url = "https://example.com/" + unique_id
|
||||
if added is None:
|
||||
added = timezone.now()
|
||||
if modified is None:
|
||||
modified = timezone.now()
|
||||
bookmark = Bookmark(
|
||||
url=url,
|
||||
title=title,
|
||||
description=description,
|
||||
notes=notes,
|
||||
date_added=added,
|
||||
date_modified=timezone.now(),
|
||||
date_modified=modified,
|
||||
owner=user,
|
||||
is_archived=is_archived,
|
||||
unread=unread,
|
||||
@@ -179,6 +199,24 @@ class BookmarkFactoryMixin:
|
||||
asset.save()
|
||||
return asset
|
||||
|
||||
def setup_asset_file(self, asset: BookmarkAsset, file_content: str = "test"):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
if asset.gzip:
|
||||
with gzip.open(filepath, "wb") as f:
|
||||
f.write(file_content.encode())
|
||||
else:
|
||||
with open(filepath, "w") as f:
|
||||
f.write(file_content)
|
||||
|
||||
def read_asset_file(self, asset: BookmarkAsset):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
with open(filepath, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def has_asset_file(self, asset: BookmarkAsset):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
return os.path.exists(filepath)
|
||||
|
||||
def setup_tag(self, user: User = None, name: str = ""):
|
||||
if user is None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -287,6 +325,12 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin):
|
||||
|
||||
|
||||
class LinkdingApiTestCase(APITestCase):
|
||||
def authenticate(self):
|
||||
self.api_token = Token.objects.get_or_create(
|
||||
user=self.get_or_create_test_user()
|
||||
)[0]
|
||||
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
|
||||
|
||||
def get(self, url, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
@@ -320,6 +364,7 @@ class BookmarkHtmlTag:
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
add_date: str = "",
|
||||
last_modified: str = "",
|
||||
tags: str = "",
|
||||
to_read: bool = False,
|
||||
private: bool = True,
|
||||
@@ -328,6 +373,7 @@ class BookmarkHtmlTag:
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.add_date = add_date
|
||||
self.last_modified = last_modified
|
||||
self.tags = tags
|
||||
self.to_read = to_read
|
||||
self.private = private
|
||||
@@ -339,6 +385,7 @@ class ImportTestMixin:
|
||||
<DT>
|
||||
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
||||
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
||||
{f'LAST_MODIFIED="{tag.last_modified}"' if tag.last_modified else ''}
|
||||
{f'TAGS="{tag.tags}"' if tag.tags else ''}
|
||||
TOREAD="{1 if tag.to_read else 0}"
|
||||
PRIVATE="{1 if tag.private else 0}">
|
||||
|
||||
238
bookmarks/tests/test_assets_service.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import datetime
|
||||
import gzip
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import BookmarkAsset
|
||||
from bookmarks.services import assets
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.setup_temp_assets_dir()
|
||||
self.get_or_create_test_user()
|
||||
|
||||
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
|
||||
self.mock_singlefile_create_snapshot_patcher = mock.patch(
|
||||
"bookmarks.services.singlefile.create_snapshot",
|
||||
)
|
||||
self.mock_singlefile_create_snapshot = (
|
||||
self.mock_singlefile_create_snapshot_patcher.start()
|
||||
)
|
||||
self.mock_singlefile_create_snapshot.side_effect = lambda url, filepath: (
|
||||
open(filepath, "w").write(self.html_content)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.mock_singlefile_create_snapshot_patcher.stop()
|
||||
|
||||
def get_saved_snapshot_file(self):
|
||||
# look up first file in the asset folder
|
||||
files = os.listdir(self.assets_dir)
|
||||
if files:
|
||||
return files[0]
|
||||
|
||||
def test_create_snapshot_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
|
||||
self.assertIsNotNone(asset)
|
||||
self.assertEqual(asset.bookmark, bookmark)
|
||||
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
|
||||
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
|
||||
self.assertIn("HTML snapshot from", asset.display_name)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
|
||||
|
||||
# asset is not saved to the database
|
||||
self.assertIsNone(asset.id)
|
||||
|
||||
def test_create_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
asset.date_created = timezone.datetime(
|
||||
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
|
||||
assets.create_snapshot(asset)
|
||||
|
||||
expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp"
|
||||
expected_temp_filepath = os.path.join(self.assets_dir, expected_temp_filename)
|
||||
expected_filename = "snapshot_2023-08-11_214511_https___example.com.html.gz"
|
||||
expected_filepath = os.path.join(self.assets_dir, expected_filename)
|
||||
|
||||
# should call singlefile.create_snapshot with the correct arguments
|
||||
self.mock_singlefile_create_snapshot.assert_called_once_with(
|
||||
"https://example.com",
|
||||
expected_temp_filepath,
|
||||
)
|
||||
|
||||
# should create gzip file in asset folder
|
||||
self.assertTrue(os.path.exists(expected_filepath))
|
||||
|
||||
# gzip file should contain the correct content
|
||||
with gzip.open(expected_filepath, "rb") as gz_file:
|
||||
self.assertEqual(gz_file.read().decode(), self.html_content)
|
||||
|
||||
# should remove temporary file
|
||||
self.assertFalse(os.path.exists(expected_temp_filepath))
|
||||
|
||||
# should update asset status and file
|
||||
asset.refresh_from_db()
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||
self.assertEqual(asset.file, expected_filename)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
def test_create_snapshot_failure(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
|
||||
self.mock_singlefile_create_snapshot.side_effect = Exception
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
assets.create_snapshot(asset)
|
||||
|
||||
asset.refresh_from_db()
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
|
||||
|
||||
def test_create_snapshot_truncates_asset_file_name(self):
|
||||
# Create a bookmark with a very long URL
|
||||
long_url = "http://" + "a" * 300 + ".com"
|
||||
bookmark = self.setup_bookmark(url=long_url)
|
||||
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
assets.create_snapshot(asset)
|
||||
|
||||
saved_file = self.get_saved_snapshot_file()
|
||||
|
||||
self.assertEqual(192, len(saved_file))
|
||||
self.assertTrue(saved_file.startswith("snapshot_"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
||||
|
||||
def test_upload_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
|
||||
# should create gzip file in asset folder
|
||||
saved_file_name = self.get_saved_snapshot_file()
|
||||
self.assertIsNotNone(saved_file_name)
|
||||
|
||||
# verify file name
|
||||
self.assertTrue(saved_file_name.startswith("snapshot_"))
|
||||
self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz"))
|
||||
|
||||
# gzip file should contain the correct content
|
||||
with gzip.open(os.path.join(self.assets_dir, saved_file_name), "rb") as gz_file:
|
||||
self.assertEqual(gz_file.read().decode(), self.html_content)
|
||||
|
||||
# should create asset
|
||||
self.assertIsNotNone(asset.id)
|
||||
self.assertEqual(asset.bookmark, bookmark)
|
||||
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
|
||||
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
|
||||
self.assertIn("HTML snapshot from", asset.display_name)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||
self.assertEqual(asset.file, saved_file_name)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
def test_upload_snapshot_failure(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
# make gzip.open raise an exception
|
||||
with mock.patch("gzip.open") as mock_gzip_open:
|
||||
mock_gzip_open.side_effect = Exception
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
assets.upload_snapshot(bookmark, b"invalid content")
|
||||
|
||||
# asset is not saved to the database
|
||||
self.assertIsNone(BookmarkAsset.objects.first())
|
||||
|
||||
def test_upload_snapshot_truncates_asset_file_name(self):
|
||||
# Create a bookmark with a very long URL
|
||||
long_url = "http://" + "a" * 300 + ".com"
|
||||
bookmark = self.setup_bookmark(url=long_url)
|
||||
|
||||
assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
|
||||
saved_file = self.get_saved_snapshot_file()
|
||||
|
||||
self.assertEqual(192, len(saved_file))
|
||||
self.assertTrue(saved_file.startswith("snapshot_"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
file_content = b"test content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
|
||||
# should create file in asset folder
|
||||
saved_file_name = self.get_saved_snapshot_file()
|
||||
self.assertIsNotNone(upload_file)
|
||||
|
||||
# verify file name
|
||||
self.assertTrue(saved_file_name.startswith("upload_"))
|
||||
self.assertTrue(saved_file_name.endswith("_test_file.txt"))
|
||||
|
||||
# file should contain the correct content
|
||||
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
|
||||
self.assertEqual(file.read(), file_content)
|
||||
|
||||
# should create asset
|
||||
self.assertIsNotNone(asset.id)
|
||||
self.assertEqual(asset.bookmark, bookmark)
|
||||
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
||||
self.assertEqual(asset.content_type, upload_file.content_type)
|
||||
self.assertEqual(asset.display_name, upload_file.name)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||
self.assertEqual(asset.file, saved_file_name)
|
||||
self.assertEqual(asset.file_size, len(file_content))
|
||||
self.assertFalse(asset.gzip)
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset_truncates_asset_file_name(self):
|
||||
# Create a bookmark with a very long URL
|
||||
long_file_name = "a" * 300 + ".txt"
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
file_content = b"test content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
long_file_name, file_content, content_type="text/plain"
|
||||
)
|
||||
|
||||
assets.upload_asset(bookmark, upload_file)
|
||||
|
||||
saved_file = self.get_saved_snapshot_file()
|
||||
|
||||
self.assertEqual(192, len(saved_file))
|
||||
self.assertTrue(saved_file.startswith("upload_"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.txt"))
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset_failure(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
upload_file = SimpleUploadedFile("test_file.txt", b"test content")
|
||||
|
||||
# make open raise an exception
|
||||
with mock.patch("builtins.open") as mock_open:
|
||||
mock_open.side_effect = Exception
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
assets.upload_asset(bookmark, upload_file)
|
||||
|
||||
# asset is not saved to the database
|
||||
self.assertIsNone(BookmarkAsset.objects.first())
|
||||
32
bookmarks/tests/test_auth_api.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||
|
||||
|
||||
class AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
def authenticate(self, keyword):
|
||||
self.api_token = Token.objects.get_or_create(
|
||||
user=self.get_or_create_test_user()
|
||||
)[0]
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"{keyword} {self.api_token.key}")
|
||||
|
||||
def test_auth_with_token_keyword(self):
|
||||
self.authenticate("Token")
|
||||
|
||||
url = reverse("bookmarks:user-profile")
|
||||
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_auth_with_bearer_keyword(self):
|
||||
self.authenticate("Bearer")
|
||||
|
||||
url = reverse("bookmarks:user-profile")
|
||||
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_auth_with_unknown_keyword(self):
|
||||
self.authenticate("Key")
|
||||
|
||||
url = reverse("bookmarks:user-profile")
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -14,6 +14,20 @@ class AutoTaggingTestCase(TestCase):
|
||||
|
||||
self.assertEqual(tags, {"example"})
|
||||
|
||||
def test_auto_tag_by_domain_handles_invalid_urls(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.com test
|
||||
"""
|
||||
|
||||
url = "https://"
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
url = "example.com"
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_works_with_port(self):
|
||||
script = """
|
||||
example.com example
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset
|
||||
from bookmarks.services import tasks, bookmarks
|
||||
from bookmarks.services import assets, tasks
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
BookmarkListTestMixin,
|
||||
@@ -200,7 +200,7 @@ class BookmarkActionViewTestCase(
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile("test.txt", file_content)
|
||||
|
||||
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
|
||||
with patch.object(assets, "upload_asset") as mock_upload_asset:
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||
@@ -221,7 +221,7 @@ class BookmarkActionViewTestCase(
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile("test.txt", file_content)
|
||||
|
||||
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
|
||||
with patch.object(assets, "upload_asset") as mock_upload_asset:
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||
@@ -230,6 +230,27 @@ class BookmarkActionViewTestCase(
|
||||
|
||||
mock_upload_asset.assert_not_called()
|
||||
|
||||
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
|
||||
def test_upload_asset_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile("test.txt", file_content)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_upload_asset_without_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{"upload_asset": bookmark.id},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_remove_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark)
|
||||
|
||||
@@ -503,3 +503,10 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||
|
||||
def test_does_not_include_rss_feed(self):
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkArchivedViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin
|
||||
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response,
|
||||
"<li ld-bookmark-item>",
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
||||
@@ -11,19 +11,11 @@ from bookmarks.tests.helpers import (
|
||||
|
||||
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.setup_temp_assets_dir()
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def tearDown(self):
|
||||
temp_files = [
|
||||
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
|
||||
]
|
||||
for temp_file in temp_files:
|
||||
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
|
||||
|
||||
def setup_asset_file(self, filename):
|
||||
if not os.path.exists(settings.LD_ASSET_FOLDER):
|
||||
os.makedirs(settings.LD_ASSET_FOLDER)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "w") as f:
|
||||
f.write("test")
|
||||
|
||||
@@ -3,23 +3,15 @@ import os
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
)
|
||||
from bookmarks.services import bookmarks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def tearDown(self):
|
||||
temp_files = [
|
||||
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
|
||||
]
|
||||
for temp_file in temp_files:
|
||||
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
|
||||
def setUp(self):
|
||||
self.setup_temp_assets_dir()
|
||||
|
||||
def setup_asset_file(self, filename):
|
||||
if not os.path.exists(settings.LD_ASSET_FOLDER):
|
||||
os.makedirs(settings.LD_ASSET_FOLDER)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "w") as f:
|
||||
f.write("test")
|
||||
|
||||
340
bookmarks/tests/test_bookmark_assets_api.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import io
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from bookmarks.models import BookmarkAsset
|
||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.setup_temp_assets_dir()
|
||||
|
||||
def assertAsset(self, asset: BookmarkAsset, data: dict):
|
||||
self.assertEqual(asset.id, data["id"])
|
||||
self.assertEqual(asset.bookmark.id, data["bookmark"])
|
||||
self.assertEqual(
|
||||
asset.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
|
||||
)
|
||||
self.assertEqual(asset.file_size, data["file_size"])
|
||||
self.assertEqual(asset.asset_type, data["asset_type"])
|
||||
self.assertEqual(asset.content_type, data["content_type"])
|
||||
self.assertEqual(asset.display_name, data["display_name"])
|
||||
self.assertEqual(asset.status, data["status"])
|
||||
|
||||
def test_asset_list(self):
|
||||
self.authenticate()
|
||||
|
||||
bookmark1 = self.setup_bookmark(url="https://example1.com")
|
||||
bookmark1_assets = [
|
||||
self.setup_asset(bookmark=bookmark1),
|
||||
self.setup_asset(bookmark=bookmark1),
|
||||
self.setup_asset(bookmark=bookmark1),
|
||||
]
|
||||
|
||||
bookmark2 = self.setup_bookmark(url="https://example2.com")
|
||||
bookmark2_assets = [
|
||||
self.setup_asset(bookmark=bookmark2),
|
||||
self.setup_asset(bookmark=bookmark2),
|
||||
self.setup_asset(bookmark=bookmark2),
|
||||
]
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark1.id}
|
||||
)
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["results"]), 3)
|
||||
self.assertAsset(bookmark1_assets[0], response.data["results"][0])
|
||||
self.assertAsset(bookmark1_assets[1], response.data["results"][1])
|
||||
self.assertAsset(bookmark1_assets[2], response.data["results"][2])
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark2.id}
|
||||
)
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["results"]), 3)
|
||||
self.assertAsset(bookmark2_assets[0], response.data["results"][0])
|
||||
self.assertAsset(bookmark2_assets[1], response.data["results"][1])
|
||||
self.assertAsset(bookmark2_assets[2], response.data["results"][2])
|
||||
|
||||
def test_asset_list_only_returns_assets_for_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
self.setup_asset(bookmark=bookmark)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_asset_list_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_asset_detail(self):
|
||||
self.authenticate()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
file="cats.png",
|
||||
file_size=1234,
|
||||
content_type="image/png",
|
||||
display_name="cats.png",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
gzip=False,
|
||||
)
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertAsset(asset, response.data)
|
||||
|
||||
def test_asset_detail_only_returns_asset_for_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_asset_detail_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_asset_download_with_snapshot_asset(self):
|
||||
self.authenticate()
|
||||
|
||||
file_content = """
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test</h1>
|
||||
</body>
|
||||
"""
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
display_name="Snapshot from today",
|
||||
content_type="text/html",
|
||||
gzip=True,
|
||||
)
|
||||
self.setup_asset_file(asset=asset, file_content=file_content)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-download",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response["Content-Type"], "text/html")
|
||||
self.assertEqual(
|
||||
response["Content-Disposition"],
|
||||
'attachment; filename="Snapshot from today.html"',
|
||||
)
|
||||
content = b"".join(response.streaming_content).decode("utf-8")
|
||||
self.assertEqual(content, file_content)
|
||||
|
||||
def test_asset_download_with_uploaded_asset(self):
|
||||
self.authenticate()
|
||||
|
||||
file_content = "some file content"
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
display_name="cats.png",
|
||||
content_type="image/png",
|
||||
gzip=False,
|
||||
)
|
||||
self.setup_asset_file(asset=asset, file_content=file_content)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-download",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response["Content-Type"], "image/png")
|
||||
self.assertEqual(
|
||||
response["Content-Disposition"],
|
||||
'attachment; filename="cats.png"',
|
||||
)
|
||||
content = b"".join(response.streaming_content).decode("utf-8")
|
||||
self.assertEqual(content, file_content)
|
||||
|
||||
def test_asset_download_with_missing_file(self):
|
||||
self.authenticate()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
display_name="cats.png",
|
||||
content_type="image/png",
|
||||
gzip=False,
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-download",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_asset_download_only_returns_asset_for_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-download",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_asset_download_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-download",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def create_upload_body(self):
|
||||
url = "https://example.com"
|
||||
file_content = b"dummy content"
|
||||
file = io.BytesIO(file_content)
|
||||
file.name = "snapshot.html"
|
||||
|
||||
return {"url": url, "file": file}
|
||||
|
||||
def test_upload_asset(self):
|
||||
self.authenticate()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
file_content = b"test file content"
|
||||
file_name = "test.txt"
|
||||
file = SimpleUploadedFile(file_name, file_content, content_type="text/plain")
|
||||
|
||||
response = self.client.post(url, {"file": file}, format="multipart")
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
asset = BookmarkAsset.objects.get(id=response.data["id"])
|
||||
self.assertEqual(asset.bookmark, bookmark)
|
||||
self.assertEqual(asset.display_name, file_name)
|
||||
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
||||
self.assertEqual(asset.content_type, "text/plain")
|
||||
self.assertEqual(asset.file_size, len(file_content))
|
||||
self.assertFalse(asset.gzip)
|
||||
|
||||
content = self.read_asset_file(asset)
|
||||
self.assertEqual(content, file_content)
|
||||
|
||||
def test_upload_asset_with_missing_file(self):
|
||||
self.authenticate()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
|
||||
response = self.client.post(url, {}, format="multipart")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_upload_asset_only_works_for_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
|
||||
response = self.client.post(url, {}, format="multipart")
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_upload_asset_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
|
||||
response = self.client.post(url, {}, format="multipart")
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
|
||||
def test_upload_asset_disabled(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
|
||||
)
|
||||
response = self.client.post(url, {}, format="multipart")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_delete_asset(self):
|
||||
self.authenticate()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
self.setup_asset_file(asset=asset)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
self.assertFalse(self.has_asset_file(asset))
|
||||
|
||||
def test_delete_asset_only_works_for_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_delete_asset_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark=bookmark)
|
||||
url = reverse(
|
||||
"bookmarks:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||
return modal
|
||||
|
||||
def find_section(self, soup, section_name):
|
||||
dt = soup.find("dt", string=section_name)
|
||||
dd = dt.find_next_sibling("dd") if dt else None
|
||||
return dd
|
||||
def find_section_content(self, soup, section_name):
|
||||
h3 = soup.find("h3", string=section_name)
|
||||
content = h3.find_next_sibling("div") if h3 else None
|
||||
return content
|
||||
|
||||
def get_section(self, soup, section_name):
|
||||
dd = self.find_section(soup, section_name)
|
||||
self.assertIsNotNone(dd)
|
||||
return dd
|
||||
def get_section_content(self, soup, section_name):
|
||||
content = self.find_section_content(soup, section_name)
|
||||
self.assertIsNotNone(content)
|
||||
return content
|
||||
|
||||
def find_weblink(self, soup, url):
|
||||
return soup.find("a", {"class": "weblink", "href": url})
|
||||
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# sharing disabled
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertIsNotNone(archived)
|
||||
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertIsNotNone(archived)
|
||||
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# unchecked
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertFalse(archived.has_attr("checked"))
|
||||
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# checked
|
||||
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Status")
|
||||
section = self.get_section_content(soup, "Status")
|
||||
|
||||
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||
self.assertTrue(archived.has_attr("checked"))
|
||||
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
section = self.find_section_content(soup, "Status")
|
||||
self.assertIsNotNone(section)
|
||||
|
||||
# other user's bookmark
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
section = self.find_section_content(soup, "Status")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# guest user
|
||||
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
other_user.profile.save()
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Status")
|
||||
section = self.find_section_content(soup, "Status")
|
||||
self.assertIsNone(section)
|
||||
|
||||
def test_date_added(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Date added")
|
||||
section = self.get_section_content(soup, "Date added")
|
||||
|
||||
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||
date = section.find("span", string=expected_date)
|
||||
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Tags")
|
||||
section = self.find_section_content(soup, "Tags")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with tags
|
||||
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Tags")
|
||||
section = self.get_section_content(soup, "Tags")
|
||||
|
||||
for tag in bookmark.tags.all():
|
||||
tag_link = section.find("a", string=f"#{tag.name}")
|
||||
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark(description="")
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Description")
|
||||
section = self.find_section_content(soup, "Description")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with description
|
||||
bookmark = self.setup_bookmark(description="Test description")
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.get_section(soup, "Description")
|
||||
section = self.get_section_content(soup, "Description")
|
||||
self.assertEqual(section.text.strip(), bookmark.description)
|
||||
|
||||
def test_notes(self):
|
||||
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.find_section(soup, "Notes")
|
||||
section = self.find_section_content(soup, "Notes")
|
||||
self.assertIsNone(section)
|
||||
|
||||
# with notes
|
||||
bookmark = self.setup_bookmark(notes="Test notes")
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
|
||||
section = self.get_section(soup, "Notes")
|
||||
section = self.get_section_content(soup, "Notes")
|
||||
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||
|
||||
def test_edit_link(self):
|
||||
@@ -564,28 +564,12 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.assertIsNone(edit_link)
|
||||
self.assertIsNone(delete_button)
|
||||
|
||||
def test_assets_visibility_no_snapshot_support(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Files")
|
||||
self.assertIsNone(section)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_assets_visibility_with_snapshot_support(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.find_section(soup, "Files")
|
||||
self.assertIsNotNone(section)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_asset_list_visibility(self):
|
||||
# no assets
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
section = self.get_section_content(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
self.assertIsNone(asset_list)
|
||||
|
||||
@@ -594,11 +578,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.setup_asset(bookmark)
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
section = self.get_section_content(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
self.assertIsNotNone(asset_list)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_asset_list(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
assets = [
|
||||
@@ -608,7 +591,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
section = self.get_section(soup, "Files")
|
||||
section = self.get_section_content(soup, "Files")
|
||||
asset_list = section.find("div", {"class": "assets"})
|
||||
|
||||
for asset in assets:
|
||||
@@ -627,6 +610,76 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.assertIsNotNone(view_link)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_asset_list_actions_visibility(self):
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
create_snapshot = soup.find(
|
||||
"button", {"type": "submit", "name": "create_html_snapshot"}
|
||||
)
|
||||
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
|
||||
self.assertIsNotNone(create_snapshot)
|
||||
self.assertIsNotNone(upload_asset)
|
||||
|
||||
# with sharing
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
create_snapshot = soup.find(
|
||||
"button", {"type": "submit", "name": "create_html_snapshot"}
|
||||
)
|
||||
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
|
||||
self.assertIsNone(create_snapshot)
|
||||
self.assertIsNone(upload_asset)
|
||||
|
||||
# with public sharing
|
||||
profile = other_user.profile
|
||||
profile.enable_public_sharing = True
|
||||
profile.save()
|
||||
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
create_snapshot = soup.find(
|
||||
"button", {"type": "submit", "name": "create_html_snapshot"}
|
||||
)
|
||||
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
|
||||
self.assertIsNone(create_snapshot)
|
||||
self.assertIsNone(upload_asset)
|
||||
|
||||
# guest user
|
||||
self.client.logout()
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
soup = self.get_shared_details_modal(bookmark)
|
||||
edit_link = soup.find("a", string="Edit")
|
||||
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||
self.assertIsNone(edit_link)
|
||||
self.assertIsNone(delete_button)
|
||||
|
||||
def test_asset_list_actions_visibility_without_snapshots_enabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
create_snapshot = soup.find(
|
||||
"button", {"type": "submit", "name": "create_html_snapshot"}
|
||||
)
|
||||
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
|
||||
self.assertIsNone(create_snapshot)
|
||||
self.assertIsNotNone(upload_asset)
|
||||
|
||||
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
|
||||
def test_asset_list_actions_visibility_with_uploads_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
create_snapshot = soup.find(
|
||||
"button", {"type": "submit", "name": "create_html_snapshot"}
|
||||
)
|
||||
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
|
||||
self.assertIsNone(create_snapshot)
|
||||
self.assertIsNone(upload_asset)
|
||||
|
||||
def test_asset_without_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark)
|
||||
@@ -639,7 +692,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
view_link = asset_item.find("a", {"href": view_url})
|
||||
self.assertIsNone(view_link)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_asset_status(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
|
||||
@@ -655,7 +707,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
asset_text = asset_item.select_one(".asset-text span")
|
||||
self.assertIn("(failed)", asset_text.text)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_asset_file_size(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset1 = self.setup_asset(bookmark, file_size=None)
|
||||
@@ -676,7 +727,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
asset_text = asset_item.select_one(".asset-text")
|
||||
self.assertIn("11.0\xa0MB", asset_text.text)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_asset_actions_visibility(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -738,7 +788,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
# no pending asset
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
files_section = self.find_section_content(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
@@ -749,7 +799,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
asset.save()
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
files_section = self.find_section_content(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
|
||||
@@ -26,6 +26,11 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
}
|
||||
return {**form_data, **overrides}
|
||||
|
||||
def test_should_render_successfully(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_should_edit_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data({"id": bookmark.id})
|
||||
@@ -46,6 +51,14 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(tags[0].name, "editedtag1")
|
||||
self.assertEqual(tags[1].name, "editedtag2")
|
||||
|
||||
def test_should_return_422_with_invalid_form(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data({"id": bookmark.id, "url": ""})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_should_edit_unread_state(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -128,6 +141,40 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_prevent_duplicate_urls(self):
|
||||
edited_bookmark = self.setup_bookmark(url="http://example.com/edited")
|
||||
existing_bookmark = self.setup_bookmark(url="http://example.com/existing")
|
||||
other_user_bookmark = self.setup_bookmark(
|
||||
url="http://example.com/other-user", user=User.objects.create_user("other")
|
||||
)
|
||||
|
||||
# if the URL isn't modified it's not a duplicate
|
||||
form_data = self.create_form_data({"url": edited_bookmark.url})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
# if the URL is already bookmarked by another user, it's not a duplicate
|
||||
form_data = self.create_form_data({"url": other_user_bookmark.url})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
# if the URL is already bookmarked by the same user, it's a duplicate
|
||||
form_data = self.create_form_data({"url": existing_bookmark.url})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertInHTML(
|
||||
"<li>A bookmark with this URL already exists.</li>",
|
||||
response.content.decode(),
|
||||
)
|
||||
edited_bookmark.refresh_from_db()
|
||||
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
|
||||
|
||||
def test_should_redirect_to_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||
|
||||
def test_does_not_include_rss_feed(self):
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
|
||||
class BookmarkIndexViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertContains(
|
||||
response,
|
||||
"<li ld-bookmark-item>",
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
||||
@@ -46,6 +46,11 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(tags[0].name, "tag1")
|
||||
self.assertEqual(tags[1].name, "tag2")
|
||||
|
||||
def test_should_return_422_with_invalid_form(self):
|
||||
form_data = self.create_form_data({"url": ""})
|
||||
response = self.client.post(reverse("bookmarks:new"), form_data)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_should_create_new_unread_bookmark(self):
|
||||
form_data = self.create_form_data({"unread": True})
|
||||
|
||||
|
||||
70
bookmarks/tests/test_bookmark_previews.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from bookmarks.services import bookmarks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)
|
||||
self.override.enable()
|
||||
|
||||
def tearDown(self):
|
||||
self.override.disable()
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def setup_preview_file(self, filename):
|
||||
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)
|
||||
with open(filepath, "w") as f:
|
||||
f.write("test")
|
||||
|
||||
def setup_bookmark_with_preview(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.preview_image_file = f"preview_{bookmark.id}.jpg"
|
||||
bookmark.save()
|
||||
self.setup_preview_file(bookmark.preview_image_file)
|
||||
return bookmark
|
||||
|
||||
def assertPreviewImageExists(self, bookmark):
|
||||
self.assertTrue(
|
||||
os.path.exists(
|
||||
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
|
||||
)
|
||||
)
|
||||
|
||||
def assertPreviewImageDoesNotExist(self, bookmark):
|
||||
self.assertFalse(
|
||||
os.path.exists(
|
||||
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
|
||||
)
|
||||
)
|
||||
|
||||
def test_delete_bookmark_deletes_preview_image(self):
|
||||
bookmark = self.setup_bookmark_with_preview()
|
||||
self.assertPreviewImageExists(bookmark)
|
||||
|
||||
bookmark.delete()
|
||||
self.assertPreviewImageDoesNotExist(bookmark)
|
||||
|
||||
def test_bulk_delete_bookmarks_deletes_preview_images(self):
|
||||
bookmark1 = self.setup_bookmark_with_preview()
|
||||
bookmark2 = self.setup_bookmark_with_preview()
|
||||
bookmark3 = self.setup_bookmark_with_preview()
|
||||
|
||||
self.assertPreviewImageExists(bookmark1)
|
||||
self.assertPreviewImageExists(bookmark2)
|
||||
self.assertPreviewImageExists(bookmark3)
|
||||
|
||||
bookmarks.delete_bookmarks(
|
||||
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
|
||||
)
|
||||
|
||||
self.assertPreviewImageDoesNotExist(bookmark1)
|
||||
self.assertPreviewImageDoesNotExist(bookmark2)
|
||||
self.assertPreviewImageDoesNotExist(bookmark3)
|
||||
@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
radios = form.select(f'input[name="{name}"][type="radio"]')
|
||||
self.assertTrue(len(radios) == 0)
|
||||
|
||||
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
|
||||
id_attr = f'for="{id}"' if id else ""
|
||||
tag = "label" if id else "div"
|
||||
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
|
||||
def assertUnmodifiedLabel(self, html: str, text: str):
|
||||
soup = self.make_soup(html)
|
||||
label = soup.find("label", string=lambda s: s and s.strip() == text)
|
||||
self.assertEqual(label["class"], ["form-label"])
|
||||
|
||||
self.assertInHTML(needle, html)
|
||||
|
||||
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
|
||||
id_attr = f'for="{id}"' if id else ""
|
||||
tag = "label" if id else "div"
|
||||
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
|
||||
|
||||
self.assertInHTML(needle, html)
|
||||
def assertModifiedLabel(self, html: str, text: str):
|
||||
soup = self.make_soup(html)
|
||||
label = soup.find("label", string=lambda s: s and s.strip() == text)
|
||||
self.assertEqual(label["class"], ["form-label", "text-bold"])
|
||||
|
||||
def test_search_form_inputs(self):
|
||||
# Without params
|
||||
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
# Without modifications
|
||||
url = "/test"
|
||||
rendered_template = self.render_template(url)
|
||||
soup = self.make_soup(rendered_template)
|
||||
button = soup.select_one("button[aria-label='Search preferences']")
|
||||
|
||||
self.assertIn(
|
||||
'<button type="button" class="btn dropdown-toggle">', rendered_template
|
||||
)
|
||||
self.assertNotIn("badge", button["class"])
|
||||
|
||||
# With modifications
|
||||
url = "/test?sort=title_asc"
|
||||
rendered_template = self.render_template(url)
|
||||
soup = self.make_soup(rendered_template)
|
||||
button = soup.select_one("button[aria-label='Search preferences']")
|
||||
|
||||
self.assertIn(
|
||||
'<button type="button" class="btn dropdown-toggle badge">',
|
||||
rendered_template,
|
||||
)
|
||||
self.assertIn("badge", button["class"])
|
||||
|
||||
# Ignores non-preferences modifications
|
||||
url = "/test?q=foo&user=john"
|
||||
rendered_template = self.render_template(url)
|
||||
soup = self.make_soup(rendered_template)
|
||||
button = soup.select_one("button[aria-label='Search preferences']")
|
||||
|
||||
self.assertIn(
|
||||
'<button type="button" class="btn dropdown-toggle">', rendered_template
|
||||
)
|
||||
self.assertNotIn("badge", button["class"])
|
||||
|
||||
def test_modified_labels(self):
|
||||
# Without modifications
|
||||
url = "/test"
|
||||
rendered_template = self.render_template(url)
|
||||
|
||||
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Sort by")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||
|
||||
# Modified sort
|
||||
url = "/test?sort=title_asc"
|
||||
rendered_template = self.render_template(url)
|
||||
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort")
|
||||
self.assertModifiedLabel(rendered_template, "Sort by")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||
|
||||
# Modified shared
|
||||
url = "/test?shared=yes"
|
||||
rendered_template = self.render_template(url)
|
||||
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Sort by")
|
||||
self.assertModifiedLabel(rendered_template, "Shared filter")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||
|
||||
# Modified unread
|
||||
url = "/test?unread=yes"
|
||||
rendered_template = self.render_template(url)
|
||||
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Sort by")
|
||||
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
|
||||
self.assertModifiedLabel(rendered_template, "Unread filter")
|
||||
|
||||
@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
|
||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||
|
||||
def test_includes_public_shared_rss_feed(self):
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNotNone(feed)
|
||||
self.assertEqual(feed.attrs["href"], reverse("bookmarks:feeds.public_shared"))
|
||||
|
||||
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
|
||||
class BookmarkSharedViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
self.assertContains(
|
||||
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
self.assertContains(
|
||||
response,
|
||||
'<li ld-bookmark-item class="shared">',
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import datetime
|
||||
import io
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, ANY
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.response import Response
|
||||
|
||||
import bookmarks.services.bookmarks
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||
from bookmarks.utils import app_version
|
||||
|
||||
|
||||
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self):
|
||||
self.mock_assets_upload_snapshot_patcher = patch(
|
||||
"bookmarks.services.assets.upload_snapshot",
|
||||
)
|
||||
self.mock_assets_upload_snapshot = (
|
||||
self.mock_assets_upload_snapshot_patcher.start()
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.mock_assets_upload_snapshot_patcher.stop()
|
||||
|
||||
def authenticate(self):
|
||||
self.api_token = Token.objects.get_or_create(
|
||||
user=self.get_or_create_test_user()
|
||||
@@ -33,7 +51,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
expectation["title"] = bookmark.title
|
||||
expectation["description"] = bookmark.description
|
||||
expectation["notes"] = bookmark.notes
|
||||
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
|
||||
expectation["web_archive_snapshot_url"] = (
|
||||
bookmark.web_archive_snapshot_url
|
||||
or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)
|
||||
)
|
||||
expectation["favicon_url"] = (
|
||||
f"http://testserver/static/{bookmark.favicon_file}"
|
||||
if bookmark.favicon_file
|
||||
@@ -433,6 +454,40 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.title, "")
|
||||
self.assertEqual(bookmark.description, "")
|
||||
|
||||
def test_create_bookmark_creates_html_snapshot_by_default(self):
|
||||
self.authenticate()
|
||||
|
||||
with patch.object(
|
||||
bookmarks.services.bookmarks,
|
||||
"create_bookmark",
|
||||
wraps=bookmarks.services.bookmarks.create_bookmark,
|
||||
) as mock_create_bookmark:
|
||||
data = {"url": "https://example.com/"}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
|
||||
mock_create_bookmark.assert_called_with(
|
||||
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=False
|
||||
)
|
||||
|
||||
def test_create_bookmark_does_not_create_html_snapshot_if_disabled(self):
|
||||
self.authenticate()
|
||||
|
||||
with patch.object(
|
||||
bookmarks.services.bookmarks,
|
||||
"create_bookmark",
|
||||
wraps=bookmarks.services.bookmarks.create_bookmark,
|
||||
) as mock_create_bookmark:
|
||||
data = {"url": "https://example.com/"}
|
||||
self.post(
|
||||
reverse("bookmarks:bookmark-list") + "?disable_html_snapshot",
|
||||
data,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
mock_create_bookmark.assert_called_with(
|
||||
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
|
||||
)
|
||||
|
||||
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
|
||||
self.authenticate()
|
||||
|
||||
@@ -480,7 +535,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
|
||||
data = {"url": "https://example.com/"}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(
|
||||
reverse("bookmarks:bookmark-list") + "?disable_scraping",
|
||||
data,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertEqual(data["url"], bookmark.url)
|
||||
self.assertEqual("", bookmark.title)
|
||||
self.assertEqual("", bookmark.description)
|
||||
self.assertEqual("", bookmark.notes)
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
self.assertFalse(bookmark.unread)
|
||||
self.assertFalse(bookmark.shared)
|
||||
self.assertBookmarkListEqual([], bookmark.tag_names)
|
||||
|
||||
def test_create_archived_bookmark(self):
|
||||
self.authenticate()
|
||||
@@ -576,6 +645,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertBookmarkListEqual([response.data], [bookmark])
|
||||
|
||||
def test_get_bookmark_returns_fallback_webarchive_url(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark(
|
||||
web_archive_snapshot_url="",
|
||||
url="https://example.com/",
|
||||
added=timezone.datetime(
|
||||
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["web_archive_snapshot_url"],
|
||||
"https://web.archive.org/web/20230811214511/https://example.com/",
|
||||
)
|
||||
|
||||
def test_update_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -586,6 +672,28 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertEqual(updated_bookmark.url, data["url"])
|
||||
|
||||
def test_update_bookmark_ignores_readonly_fields(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
data = {
|
||||
"url": "https://example.com/updated",
|
||||
"web_archive_snapshot_url": "test",
|
||||
"website_title": "test",
|
||||
"website_description": "test",
|
||||
}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertEqual(data["url"], updated_bookmark.url)
|
||||
self.assertNotEqual(
|
||||
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
|
||||
)
|
||||
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
|
||||
self.assertNotEqual(
|
||||
data["website_description"], updated_bookmark.website_description
|
||||
)
|
||||
|
||||
def test_update_bookmark_fails_without_required_fields(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -594,19 +702,24 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
|
||||
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark = self.setup_bookmark(
|
||||
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
|
||||
)
|
||||
|
||||
data = {"url": "https://example.com/"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertEqual(updated_bookmark.url, data["url"])
|
||||
self.assertEqual(updated_bookmark.title, "")
|
||||
self.assertEqual(updated_bookmark.description, "")
|
||||
self.assertEqual(updated_bookmark.notes, "")
|
||||
self.assertEqual(updated_bookmark.tag_names, [])
|
||||
self.assertEqual(updated_bookmark.title, bookmark.title)
|
||||
self.assertEqual(updated_bookmark.description, bookmark.description)
|
||||
self.assertEqual(updated_bookmark.notes, bookmark.notes)
|
||||
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
|
||||
self.assertEqual(updated_bookmark.unread, bookmark.unread)
|
||||
self.assertEqual(updated_bookmark.shared, bookmark.shared)
|
||||
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
||||
|
||||
def test_update_bookmark_unread_flag(self):
|
||||
self.authenticate()
|
||||
@@ -644,6 +757,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_update_bookmark_should_prevent_duplicate_urls(self):
|
||||
self.authenticate()
|
||||
edited_bookmark = self.setup_bookmark(url="https://example.com/edited")
|
||||
existing_bookmark = self.setup_bookmark(url="https://example.com/existing")
|
||||
other_user_bookmark = self.setup_bookmark(
|
||||
url="https://example.com/other", user=self.setup_user()
|
||||
)
|
||||
|
||||
# if the URL isn't modified it's not a duplicate
|
||||
data = {"url": edited_bookmark.url}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
# if the URL is already bookmarked by another user, it's not a duplicate
|
||||
data = {"url": other_user_bookmark.url}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
# if the URL is already bookmarked by the same user, it's a duplicate
|
||||
data = {"url": existing_bookmark.url}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_patch_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -703,16 +839,42 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
tag_names = [tag.name for tag in bookmark.tags.all()]
|
||||
self.assertListEqual(tag_names, ["updated-tag-1", "updated-tag-2"])
|
||||
|
||||
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
||||
def test_patch_ignores_readonly_fields(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
data = {
|
||||
"web_archive_snapshot_url": "test",
|
||||
"website_title": "test",
|
||||
"website_description": "test",
|
||||
}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertNotEqual(
|
||||
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
|
||||
)
|
||||
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
|
||||
self.assertNotEqual(
|
||||
data["website_description"], updated_bookmark.website_description
|
||||
)
|
||||
|
||||
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark(
|
||||
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
|
||||
)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertEqual(updated_bookmark.url, bookmark.url)
|
||||
self.assertEqual(updated_bookmark.title, bookmark.title)
|
||||
self.assertEqual(updated_bookmark.description, bookmark.description)
|
||||
self.assertEqual(updated_bookmark.notes, bookmark.notes)
|
||||
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
|
||||
self.assertEqual(updated_bookmark.unread, bookmark.unread)
|
||||
self.assertEqual(updated_bookmark.shared, bookmark.shared)
|
||||
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
||||
|
||||
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
|
||||
@@ -919,6 +1081,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
{url: "https://example.com/"},
|
||||
expected_status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
@@ -928,6 +1091,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
{url: "https://example.com/"},
|
||||
expected_status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -982,6 +1146,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
response.data["search_preferences"], profile.search_preferences
|
||||
)
|
||||
self.assertEqual(response.data["version"], app_version)
|
||||
|
||||
def test_user_profile(self):
|
||||
self.authenticate()
|
||||
@@ -1015,3 +1180,119 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertUserProfile(response, profile)
|
||||
|
||||
def create_singlefile_upload_body(self):
|
||||
url = "https://example.com"
|
||||
file_content = b"dummy content"
|
||||
file = io.BytesIO(file_content)
|
||||
file.name = "snapshot.html"
|
||||
|
||||
return {"url": url, "file": file}
|
||||
|
||||
def test_singlefile_upload(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
self.authenticate()
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
self.create_singlefile_upload_body(),
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data["message"], "Snapshot uploaded successfully.")
|
||||
|
||||
self.mock_assets_upload_snapshot.assert_called_once()
|
||||
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
|
||||
|
||||
def test_singlefile_creates_bookmark_if_not_exists(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_bookmark(url="https://example.com", user=other_user)
|
||||
|
||||
self.authenticate()
|
||||
self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
self.create_singlefile_upload_body(),
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 2)
|
||||
|
||||
bookmark = Bookmark.objects.get(
|
||||
url="https://example.com", owner=self.get_or_create_test_user()
|
||||
)
|
||||
self.mock_assets_upload_snapshot.assert_called_once()
|
||||
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
|
||||
|
||||
def test_singlefile_updates_own_bookmark_if_exists(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
other_user = self.setup_user()
|
||||
self.setup_bookmark(url="https://example.com", user=other_user)
|
||||
|
||||
self.authenticate()
|
||||
self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
self.create_singlefile_upload_body(),
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 2)
|
||||
self.mock_assets_upload_snapshot.assert_called_once()
|
||||
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
|
||||
|
||||
def test_singlefile_creates_bookmark_without_creating_snapshot(self):
|
||||
with patch(
|
||||
"bookmarks.services.bookmarks.create_bookmark"
|
||||
) as mock_create_bookmark:
|
||||
self.authenticate()
|
||||
self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
self.create_singlefile_upload_body(),
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
mock_create_bookmark.assert_called_once()
|
||||
mock_create_bookmark.assert_called_with(
|
||||
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
|
||||
)
|
||||
|
||||
def test_singlefile_upload_missing_parameters(self):
|
||||
self.authenticate()
|
||||
|
||||
# Missing 'url'
|
||||
file_content = b"dummy content"
|
||||
file = io.BytesIO(file_content)
|
||||
file.name = "snapshot.html"
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
{"file": file},
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data["error"], "Both 'url' and 'file' parameters are required."
|
||||
)
|
||||
|
||||
# Missing 'file'
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
{"url": "https://example.com"},
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data["error"], "Both 'url' and 'file' parameters are required."
|
||||
)
|
||||
|
||||
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
|
||||
def test_singlefile_upload_disabled(self):
|
||||
self.authenticate()
|
||||
self.client.post(
|
||||
reverse("bookmarks:bookmark-singlefile"),
|
||||
self.create_singlefile_upload_body(),
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
@@ -87,6 +87,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
self.authenticate()
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_update_bookmark_only_updates_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
data = {"url": "https://example.com/"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
|
||||
self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_patch_bookmark_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
data = {"url": "https://example.com"}
|
||||
@@ -97,6 +107,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
self.authenticate()
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_patch_bookmark_only_updates_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
data = {"url": "https://example.com"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
|
||||
self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_delete_bookmark_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
@@ -142,3 +162,8 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
|
||||
self.authenticate()
|
||||
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_singlefile_upload_requires_authentication(self):
|
||||
url = reverse("bookmarks:bookmark-singlefile")
|
||||
|
||||
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
details_url = base_url + f"?details={bookmark.id}"
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
<a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def test_should_reflect_unread_state_as_css_class(self):
|
||||
self.setup_bookmark(unread=True)
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="unread">', html)
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["unread"], list_item["class"])
|
||||
|
||||
def test_should_reflect_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="shared">', html)
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["shared"], list_item["class"])
|
||||
|
||||
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.setup_bookmark(unread=True, shared=True)
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["unread", "shared"], list_item["class"])
|
||||
|
||||
def test_show_bookmark_actions_for_owned_bookmarks(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services.bookmarks import (
|
||||
@@ -24,7 +21,6 @@ from bookmarks.services.bookmarks import (
|
||||
mark_bookmarks_as_unread,
|
||||
share_bookmarks,
|
||||
unshare_bookmarks,
|
||||
upload_asset,
|
||||
enhance_with_website_metadata,
|
||||
)
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
@@ -110,6 +106,15 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_create_html_snapshot.assert_called_once_with(bookmark)
|
||||
|
||||
def test_create_should_not_load_html_snapshot_when_disabled(self):
|
||||
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
|
||||
bookmark_data = Bookmark(url="https://example.com")
|
||||
create_bookmark(
|
||||
bookmark_data, "tag1,tag2", self.user, disable_html_snapshot=True
|
||||
)
|
||||
|
||||
mock_create_html_snapshot.assert_not_called()
|
||||
|
||||
def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_automatic_html_snapshots = False
|
||||
@@ -850,53 +855,6 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_upload_asset_should_save_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
with tempfile.TemporaryDirectory() as temp_assets:
|
||||
with override_settings(LD_ASSET_FOLDER=temp_assets):
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
upload_asset(bookmark, upload_file)
|
||||
|
||||
assets = bookmark.bookmarkasset_set.all()
|
||||
self.assertEqual(1, len(assets))
|
||||
|
||||
asset = assets[0]
|
||||
self.assertEqual("test_file.txt", asset.display_name)
|
||||
self.assertEqual("text/plain", asset.content_type)
|
||||
self.assertEqual(upload_file.size, asset.file_size)
|
||||
self.assertEqual(BookmarkAsset.STATUS_COMPLETE, asset.status)
|
||||
self.assertTrue(asset.file.startswith("upload_"))
|
||||
self.assertTrue(asset.file.endswith(upload_file.name))
|
||||
|
||||
# check file exists
|
||||
filepath = os.path.join(temp_assets, asset.file)
|
||||
self.assertTrue(os.path.exists(filepath))
|
||||
with open(filepath, "rb") as f:
|
||||
self.assertEqual(file_content, f.read())
|
||||
|
||||
def test_upload_asset_should_be_failed_if_saving_file_fails(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
# Use an invalid path to force an error
|
||||
with override_settings(LD_ASSET_FOLDER="/non/existing/folder"):
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
upload_asset(bookmark, upload_file)
|
||||
|
||||
assets = bookmark.bookmarkasset_set.all()
|
||||
self.assertEqual(1, len(assets))
|
||||
|
||||
asset = assets[0]
|
||||
self.assertEqual("test_file.txt", asset.display_name)
|
||||
self.assertEqual("text/plain", asset.content_type)
|
||||
self.assertIsNone(asset.file_size)
|
||||
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
|
||||
self.assertEqual("", asset.file)
|
||||
|
||||
def test_enhance_with_website_metadata(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
with patch.object(
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import os.path
|
||||
from unittest import mock
|
||||
|
||||
import waybackpy
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
from bookmarks.models import BookmarkAsset, UserProfile
|
||||
from bookmarks.services import tasks, singlefile
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
@@ -46,11 +44,11 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.mock_load_favicon = self.mock_load_favicon_patcher.start()
|
||||
self.mock_load_favicon.return_value = "https_example_com.png"
|
||||
|
||||
self.mock_singlefile_create_snapshot_patcher = mock.patch(
|
||||
"bookmarks.services.singlefile.create_snapshot",
|
||||
self.mock_assets_create_snapshot_patcher = mock.patch(
|
||||
"bookmarks.services.assets.create_snapshot",
|
||||
)
|
||||
self.mock_singlefile_create_snapshot = (
|
||||
self.mock_singlefile_create_snapshot_patcher.start()
|
||||
self.mock_assets_create_snapshot = (
|
||||
self.mock_assets_create_snapshot_patcher.start()
|
||||
)
|
||||
|
||||
self.mock_load_preview_image_patcher = mock.patch(
|
||||
@@ -70,7 +68,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def tearDown(self):
|
||||
self.mock_save_api_patcher.stop()
|
||||
self.mock_load_favicon_patcher.stop()
|
||||
self.mock_singlefile_create_snapshot_patcher.stop()
|
||||
self.mock_assets_create_snapshot_patcher.stop()
|
||||
self.mock_load_preview_image_patcher.stop()
|
||||
huey.storage.flush_results()
|
||||
huey.immediate = False
|
||||
@@ -488,72 +486,31 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertIn("HTML snapshot", asset.display_name)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
|
||||
|
||||
self.mock_assets_create_snapshot.assert_not_called()
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_should_update_file_info(self):
|
||||
def test_schedule_html_snapshots_should_create_snapshots(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
with mock.patch(
|
||||
"bookmarks.services.tasks._generate_snapshot_filename"
|
||||
) as mock_generate:
|
||||
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
|
||||
mock_generate.return_value = expected_filename
|
||||
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
|
||||
# Run periodic task to process the snapshot
|
||||
tasks._schedule_html_snapshots_task()
|
||||
|
||||
self.mock_singlefile_create_snapshot.assert_called_once_with(
|
||||
"https://example.com",
|
||||
os.path.join(
|
||||
settings.LD_ASSET_FOLDER,
|
||||
expected_filename,
|
||||
),
|
||||
)
|
||||
|
||||
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||
self.assertEqual(asset.file, expected_filename)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_truncate_filename(self):
|
||||
# Create a bookmark with a very long URL
|
||||
long_url = "http://" + "a" * 300 + ".com"
|
||||
bookmark = self.setup_bookmark(url=long_url)
|
||||
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
|
||||
# Run periodic task to process the snapshot
|
||||
tasks._schedule_html_snapshots_task()
|
||||
|
||||
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
self.assertEqual(len(asset.file), 192)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_should_handle_error(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
self.mock_singlefile_create_snapshot.side_effect = singlefile.SingleFileError(
|
||||
"Error"
|
||||
)
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
# Run periodic task to process the snapshot
|
||||
assets = BookmarkAsset.objects.filter(bookmark=bookmark)
|
||||
|
||||
tasks._schedule_html_snapshots_task()
|
||||
|
||||
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
|
||||
self.assertEqual(asset.file, "")
|
||||
self.assertFalse(asset.gzip)
|
||||
# should call create_snapshot for each pending asset
|
||||
self.assertEqual(self.mock_assets_create_snapshot.call_count, 3)
|
||||
|
||||
for asset in assets:
|
||||
self.mock_assets_create_snapshot.assert_any_call(asset)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_should_handle_missing_bookmark(self):
|
||||
def test_create_html_snapshot_should_handle_missing_asset(self):
|
||||
tasks._create_html_snapshot_task(123)
|
||||
|
||||
self.mock_singlefile_create_snapshot.assert_not_called()
|
||||
self.mock_assets_create_snapshot.assert_not_called()
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=False)
|
||||
def test_create_html_snapshot_should_not_create_asset_when_single_file_is_disabled(
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.client.force_login(self.get_or_create_test_user())
|
||||
|
||||
def test_does_not_render_custom_style_tag_by_default(self):
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertNotContains(response, "<style>")
|
||||
|
||||
def test_renders_custom_style_tag_if_user_has_custom_css(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.custom_css = "body { background-color: red; }"
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertContains(response, "<style>body { background-color: red; }</style>")
|
||||
28
bookmarks/tests/test_custom_css_view.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def test_with_empty_css(self):
|
||||
response = self.client.get(reverse("bookmarks:custom_css"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "text/css")
|
||||
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
|
||||
self.assertEqual(response.content.decode(), "")
|
||||
|
||||
def test_with_custom_css(self):
|
||||
css = "body { background-color: red; }"
|
||||
self.user.profile.custom_css = css
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:custom_css"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "text/css")
|
||||
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
|
||||
self.assertEqual(response.content.decode(), css)
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.services import exporter
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
@@ -7,20 +8,19 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_export_bookmarks(self):
|
||||
added = timezone.now()
|
||||
timestamp = int(added.timestamp())
|
||||
|
||||
bookmarks = [
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/1",
|
||||
title="Title 1",
|
||||
added=added,
|
||||
added=datetime.fromtimestamp(1, timezone.utc),
|
||||
modified=datetime.fromtimestamp(11, timezone.utc),
|
||||
description="Example description",
|
||||
),
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/2",
|
||||
title="Title 2",
|
||||
added=added,
|
||||
added=datetime.fromtimestamp(2, timezone.utc),
|
||||
modified=datetime.fromtimestamp(22, timezone.utc),
|
||||
tags=[
|
||||
self.setup_tag(name="tag1"),
|
||||
self.setup_tag(name="tag2"),
|
||||
@@ -28,15 +28,24 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
],
|
||||
),
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/3", title="Title 3", added=added, unread=True
|
||||
url="https://example.com/3",
|
||||
title="Title 3",
|
||||
added=datetime.fromtimestamp(3, timezone.utc),
|
||||
modified=datetime.fromtimestamp(33, timezone.utc),
|
||||
unread=True,
|
||||
),
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/4", title="Title 4", added=added, shared=True
|
||||
url="https://example.com/4",
|
||||
title="Title 4",
|
||||
added=datetime.fromtimestamp(4, timezone.utc),
|
||||
modified=datetime.fromtimestamp(44, timezone.utc),
|
||||
shared=True,
|
||||
),
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/5",
|
||||
title="Title 5",
|
||||
added=added,
|
||||
added=datetime.fromtimestamp(5, timezone.utc),
|
||||
modified=datetime.fromtimestamp(55, timezone.utc),
|
||||
shared=True,
|
||||
description="Example description",
|
||||
notes="Example notes",
|
||||
@@ -44,20 +53,23 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/6",
|
||||
title="Title 6",
|
||||
added=added,
|
||||
added=datetime.fromtimestamp(6, timezone.utc),
|
||||
modified=datetime.fromtimestamp(66, timezone.utc),
|
||||
shared=True,
|
||||
notes="Example notes",
|
||||
),
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/7",
|
||||
title="Title 7",
|
||||
added=added,
|
||||
added=datetime.fromtimestamp(7, timezone.utc),
|
||||
modified=datetime.fromtimestamp(77, timezone.utc),
|
||||
is_archived=True,
|
||||
),
|
||||
self.setup_bookmark(
|
||||
url="https://example.com/8",
|
||||
title="Title 8",
|
||||
added=added,
|
||||
added=datetime.fromtimestamp(8, timezone.utc),
|
||||
modified=datetime.fromtimestamp(88, timezone.utc),
|
||||
tags=[self.setup_tag(name="tag4"), self.setup_tag(name="tag5")],
|
||||
is_archived=True,
|
||||
),
|
||||
@@ -65,17 +77,17 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = exporter.export_netscape_html(bookmarks)
|
||||
|
||||
lines = [
|
||||
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
|
||||
'<DT><A HREF="https://example.com/1" ADD_DATE="1" LAST_MODIFIED="11" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
|
||||
"<DD>Example description",
|
||||
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
|
||||
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
|
||||
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
|
||||
f'<DT><A HREF="https://example.com/5" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
|
||||
'<DT><A HREF="https://example.com/2" ADD_DATE="2" LAST_MODIFIED="22" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
|
||||
'<DT><A HREF="https://example.com/3" ADD_DATE="3" LAST_MODIFIED="33" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
|
||||
'<DT><A HREF="https://example.com/4" ADD_DATE="4" LAST_MODIFIED="44" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
|
||||
'<DT><A HREF="https://example.com/5" ADD_DATE="5" LAST_MODIFIED="55" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
|
||||
"<DD>Example description[linkding-notes]Example notes[/linkding-notes]",
|
||||
f'<DT><A HREF="https://example.com/6" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
|
||||
'<DT><A HREF="https://example.com/6" ADD_DATE="6" LAST_MODIFIED="66" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
|
||||
"<DD>[linkding-notes]Example notes[/linkding-notes]",
|
||||
f'<DT><A HREF="https://example.com/7" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
|
||||
f'<DT><A HREF="https://example.com/8" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
|
||||
'<DT><A HREF="https://example.com/7" ADD_DATE="7" LAST_MODIFIED="77" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
|
||||
'<DT><A HREF="https://example.com/8" ADD_DATE="8" LAST_MODIFIED="88" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
|
||||
]
|
||||
self.assertIn("\n\r".join(lines), html)
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
self.assertEqual(bookmark.title, html_tag.title)
|
||||
self.assertEqual(bookmark.description, html_tag.description)
|
||||
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
|
||||
self.assertEqual(
|
||||
bookmark.date_modified, parse_timestamp(html_tag.last_modified)
|
||||
)
|
||||
self.assertEqual(bookmark.unread, html_tag.to_read)
|
||||
self.assertEqual(bookmark.shared, not html_tag.private)
|
||||
|
||||
@@ -45,6 +48,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
add_date="1",
|
||||
last_modified="11",
|
||||
tags="example-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -52,6 +56,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Foo title",
|
||||
description="",
|
||||
add_date="2",
|
||||
last_modified="22",
|
||||
tags="",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -59,6 +64,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Bar title",
|
||||
description="Bar description",
|
||||
add_date="3",
|
||||
last_modified="33",
|
||||
tags="bar-tag, other-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -66,6 +72,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Baz title",
|
||||
description="Baz description",
|
||||
add_date="4",
|
||||
last_modified="44",
|
||||
to_read=True,
|
||||
),
|
||||
]
|
||||
@@ -90,6 +97,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
add_date="1",
|
||||
last_modified="11",
|
||||
tags="example-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -97,6 +105,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Foo title",
|
||||
description="",
|
||||
add_date="2",
|
||||
last_modified="22",
|
||||
tags="",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -104,20 +113,23 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Bar title",
|
||||
description="Bar description",
|
||||
add_date="3",
|
||||
last_modified="33",
|
||||
tags="bar-tag, other-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
href="https://example.com/unread",
|
||||
title="Unread title",
|
||||
description="Unread description",
|
||||
add_date="3",
|
||||
add_date="4",
|
||||
last_modified="44",
|
||||
to_read=True,
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
href="https://example.com/private",
|
||||
title="Private title",
|
||||
description="Private description",
|
||||
add_date="4",
|
||||
add_date="5",
|
||||
last_modified="55",
|
||||
private=True,
|
||||
),
|
||||
]
|
||||
@@ -136,6 +148,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Updated Example title",
|
||||
description="Updated Example description",
|
||||
add_date="111",
|
||||
last_modified="1111",
|
||||
tags="updated-example-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -143,6 +156,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Updated Foo title",
|
||||
description="Updated Foo description",
|
||||
add_date="222",
|
||||
last_modified="2222",
|
||||
tags="new-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -150,6 +164,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Updated Bar title",
|
||||
description="Updated Bar description",
|
||||
add_date="333",
|
||||
last_modified="3333",
|
||||
tags="updated-bar-tag, updated-other-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -157,6 +172,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Unread title",
|
||||
description="Unread description",
|
||||
add_date="3",
|
||||
last_modified="3",
|
||||
to_read=False,
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -164,9 +180,15 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
title="Private title",
|
||||
description="Private description",
|
||||
add_date="4",
|
||||
last_modified="4",
|
||||
private=False,
|
||||
),
|
||||
BookmarkHtmlTag(href="https://baz.com", add_date="444", tags="baz-tag"),
|
||||
BookmarkHtmlTag(
|
||||
href="https://baz.com",
|
||||
add_date="444",
|
||||
last_modified="4444",
|
||||
tags="baz-tag",
|
||||
),
|
||||
]
|
||||
|
||||
# Import updated data
|
||||
@@ -291,6 +313,19 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1)
|
||||
)
|
||||
|
||||
def test_use_add_date_when_no_last_modified(self):
|
||||
test_html = self.render_html(
|
||||
tags_html=f"""
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1">Example.com</A>
|
||||
<DD>Example.com
|
||||
"""
|
||||
)
|
||||
|
||||
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(Bookmark.objects.all()[0].date_modified, parse_timestamp("1"))
|
||||
|
||||
def test_keep_title_if_imported_bookmark_has_empty_title(self):
|
||||
test_html = self.render_html(
|
||||
tags=[BookmarkHtmlTag(href="https://example.com", title="Example.com")]
|
||||
|
||||
@@ -2,10 +2,10 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import GlobalSettings
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class LayoutTestCase(TestCase, BookmarkFactoryMixin):
|
||||
class LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -63,3 +63,38 @@ class LayoutTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html,
|
||||
count=0,
|
||||
)
|
||||
|
||||
def test_does_not_link_custom_css_when_empty(self):
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
|
||||
self.assertIsNone(link)
|
||||
|
||||
def test_does_link_custom_css_when_not_empty(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.custom_css = "body { background-color: red; }"
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
|
||||
self.assertIsNotNone(link)
|
||||
|
||||
def test_custom_css_link_href(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.custom_css = "body { background-color: red; }"
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
|
||||
expected_url = (
|
||||
reverse("bookmarks:custom_css") + f"?hash={profile.custom_css_hash}"
|
||||
)
|
||||
self.assertEqual(link["href"], expected_url)
|
||||
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import URLResolver
|
||||
|
||||
from bookmarks import utils
|
||||
|
||||
|
||||
class OidcSupportTest(TestCase):
|
||||
def test_should_not_add_oidc_urls_by_default(self):
|
||||
@@ -55,9 +57,83 @@ class OidcSupportTest(TestCase):
|
||||
base_settings = importlib.import_module("siteroot.settings.base")
|
||||
importlib.reload(base_settings)
|
||||
|
||||
self.assertEqual(
|
||||
True,
|
||||
base_settings.OIDC_VERIFY_SSL,
|
||||
)
|
||||
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
|
||||
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
|
||||
self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM)
|
||||
|
||||
del os.environ["LD_ENABLE_OIDC"]
|
||||
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email")
|
||||
def test_username_should_use_email_by_default(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "test preferred username",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["email"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||
def test_username_should_use_custom_claim(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "test preferred username",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["preferred_username"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim")
|
||||
def test_username_should_fallback_to_email_for_non_existing_claim(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "test preferred username",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["email"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||
def test_username_should_fallback_to_email_for_empty_claim(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual(claims["email"], username)
|
||||
|
||||
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||
def test_username_should_be_normalized(self):
|
||||
claims = {
|
||||
"email": "test@example.com",
|
||||
"name": "test name",
|
||||
"given_name": "test given name",
|
||||
"preferred_username": "NormalizedUser",
|
||||
"nickname": "test nickname",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
username = utils.generate_username(claims["email"], claims)
|
||||
|
||||
self.assertEqual("NormalizedUser", username)
|
||||
|
||||
@@ -18,6 +18,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
self.assertEqual(bookmark.href, html_tag.href)
|
||||
self.assertEqual(bookmark.title, html_tag.title)
|
||||
self.assertEqual(bookmark.date_added, html_tag.add_date)
|
||||
self.assertEqual(bookmark.date_modified, html_tag.last_modified)
|
||||
self.assertEqual(bookmark.description, html_tag.description)
|
||||
self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))
|
||||
self.assertEqual(bookmark.to_read, html_tag.to_read)
|
||||
@@ -30,6 +31,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
add_date="1",
|
||||
last_modified="11",
|
||||
tags="example-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -37,6 +39,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
title="Foo title",
|
||||
description="",
|
||||
add_date="2",
|
||||
last_modified="22",
|
||||
tags="",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
@@ -44,13 +47,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
title="Bar title",
|
||||
description="Bar description",
|
||||
add_date="3",
|
||||
last_modified="33",
|
||||
tags="bar-tag, other-tag",
|
||||
),
|
||||
BookmarkHtmlTag(
|
||||
href="https://example.com/baz",
|
||||
title="Baz title",
|
||||
description="Baz description",
|
||||
add_date="3",
|
||||
add_date="4",
|
||||
to_read=True,
|
||||
),
|
||||
]
|
||||
@@ -72,9 +76,17 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
add_date="1",
|
||||
last_modified="1",
|
||||
tags="example-tag",
|
||||
),
|
||||
BookmarkHtmlTag(href="", title="", description="", add_date="", tags=""),
|
||||
BookmarkHtmlTag(
|
||||
href="",
|
||||
title="",
|
||||
description="",
|
||||
add_date="",
|
||||
last_modified="",
|
||||
tags="",
|
||||
),
|
||||
]
|
||||
html = self.render_html(html_tags)
|
||||
bookmarks = parse(html)
|
||||
|
||||
@@ -43,6 +43,7 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
response = self.client.post(reverse("change_password"), form_data)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIn("old_password", response.context_data["form"].errors)
|
||||
|
||||
def test_should_return_error_for_mismatching_new_password(self):
|
||||
@@ -54,4 +55,5 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
response = self.client.post(reverse("change_password"), form_data)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIn("new_password2", response.context_data["form"].errors)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import random
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
@@ -22,6 +23,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
if not overrides:
|
||||
overrides = {}
|
||||
form_data = {
|
||||
"update_profile": "",
|
||||
"theme": UserProfile.THEME_AUTO,
|
||||
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
||||
@@ -45,6 +47,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"auto_tagging_rules": "",
|
||||
"items_per_page": "30",
|
||||
"sticky_pagination": False,
|
||||
"collapse_side_panel": False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -115,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"auto_tagging_rules": "example.com tag",
|
||||
"items_per_page": "10",
|
||||
"sticky_pagination": True,
|
||||
"collapse_side_panel": True,
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:settings.update"), form_data, follow=True
|
||||
@@ -192,9 +196,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
||||
)
|
||||
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
def test_update_profile_with_invalid_form_returns_422(self):
|
||||
form_data = self.create_profile_form_data({"items_per_page": "-1"})
|
||||
response = self.client.post(reverse("bookmarks:settings.update"), form_data)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
||||
form_data = {
|
||||
"theme": UserProfile.THEME_DARK,
|
||||
@@ -210,6 +223,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
|
||||
self.assertSuccessMessage(html, "Profile updated", count=0)
|
||||
|
||||
def test_update_profile_updates_custom_css_hash(self):
|
||||
form_data = self.create_profile_form_data(
|
||||
{
|
||||
"custom_css": "body { background-color: #000; }",
|
||||
}
|
||||
)
|
||||
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
|
||||
self.user.profile.refresh_from_db()
|
||||
|
||||
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
|
||||
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
|
||||
|
||||
form_data["custom_css"] = "body { background-color: #fff; }"
|
||||
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
|
||||
self.user.profile.refresh_from_db()
|
||||
|
||||
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
|
||||
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
|
||||
|
||||
form_data["custom_css"] = ""
|
||||
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
|
||||
self.user.profile.refresh_from_db()
|
||||
|
||||
self.assertEqual("", self.user.profile.custom_css_hash)
|
||||
|
||||
def test_enable_favicons_should_schedule_icon_update(self):
|
||||
with patch.object(
|
||||
tasks, "schedule_bookmarks_without_favicons"
|
||||
@@ -217,7 +255,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# Enabling favicons schedules update
|
||||
form_data = self.create_profile_form_data(
|
||||
{
|
||||
"update_profile": "",
|
||||
"enable_favicons": True,
|
||||
}
|
||||
)
|
||||
@@ -331,7 +368,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# Enabling favicons schedules update
|
||||
form_data = self.create_profile_form_data(
|
||||
{
|
||||
"update_profile": "",
|
||||
"enable_preview_images": True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import gzip
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
@@ -9,34 +9,15 @@ from bookmarks.services import singlefile
|
||||
|
||||
|
||||
class SingleFileServiceTestCase(TestCase):
|
||||
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
|
||||
html_filepath = "temp.html.gz"
|
||||
temp_html_filepath = "temp.html.gz.tmp"
|
||||
def setUp(self):
|
||||
self.temp_html_filepath = None
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.html_filepath):
|
||||
os.remove(self.html_filepath)
|
||||
if os.path.exists(self.temp_html_filepath):
|
||||
if self.temp_html_filepath and os.path.exists(self.temp_html_filepath):
|
||||
os.remove(self.temp_html_filepath)
|
||||
|
||||
def create_test_file(self, *args, **kwargs):
|
||||
with open(self.temp_html_filepath, "w") as file:
|
||||
file.write(self.html_content)
|
||||
|
||||
def test_create_snapshot(self):
|
||||
mock_process = mock.Mock()
|
||||
mock_process.wait.return_value = 0
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen", return_value=mock_process):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
|
||||
self.assertTrue(os.path.exists(self.html_filepath))
|
||||
self.assertFalse(os.path.exists(self.temp_html_filepath))
|
||||
|
||||
with gzip.open(self.html_filepath, "rt") as file:
|
||||
content = file.read()
|
||||
self.assertEqual(content, self.html_content)
|
||||
self.temp_html_filepath = tempfile.mkstemp(suffix=".tmp")[1]
|
||||
|
||||
def test_create_snapshot_failure(self):
|
||||
# subprocess fails - which it probably doesn't as single-file doesn't return exit codes
|
||||
@@ -44,12 +25,12 @@ class SingleFileServiceTestCase(TestCase):
|
||||
mock_popen.side_effect = subprocess.CalledProcessError(1, "command")
|
||||
|
||||
with self.assertRaises(singlefile.SingleFileError):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
singlefile.create_snapshot("http://example.com", "nonexistentfile.tmp")
|
||||
|
||||
# so also check that it raises error if output file isn't created
|
||||
with mock.patch("subprocess.Popen"):
|
||||
with self.assertRaises(singlefile.SingleFileError):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
singlefile.create_snapshot("http://example.com", "nonexistentfile.tmp")
|
||||
|
||||
def test_create_snapshot_empty_options(self):
|
||||
mock_process = mock.Mock()
|
||||
@@ -57,16 +38,16 @@ class SingleFileServiceTestCase(TestCase):
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen") as mock_popen:
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
|
||||
|
||||
expected_args = [
|
||||
"single-file",
|
||||
'--browser-arg="--headless=new"',
|
||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||
'--browser-arg="--no-sandbox"',
|
||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
||||
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||
"http://example.com",
|
||||
self.html_filepath + ".tmp",
|
||||
self.temp_html_filepath,
|
||||
]
|
||||
mock_popen.assert_called_with(expected_args, start_new_session=True)
|
||||
|
||||
@@ -79,21 +60,21 @@ class SingleFileServiceTestCase(TestCase):
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen") as mock_popen:
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
|
||||
|
||||
expected_args = [
|
||||
"single-file",
|
||||
'--browser-arg="--headless=new"',
|
||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||
'--browser-arg="--no-sandbox"',
|
||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
||||
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||
"--some-option",
|
||||
"some value",
|
||||
"--another-option",
|
||||
"another value",
|
||||
"--third-option=third value",
|
||||
"http://example.com",
|
||||
self.html_filepath + ".tmp",
|
||||
self.temp_html_filepath,
|
||||
]
|
||||
mock_popen.assert_called_with(expected_args, start_new_session=True)
|
||||
|
||||
@@ -103,7 +84,7 @@ class SingleFileServiceTestCase(TestCase):
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen", return_value=mock_process):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
|
||||
|
||||
mock_process.wait.assert_called_with(timeout=120)
|
||||
|
||||
@@ -114,6 +95,6 @@ class SingleFileServiceTestCase(TestCase):
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen", return_value=mock_process):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
|
||||
|
||||
mock_process.wait.assert_called_with(timeout=180)
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.urls import path, include
|
||||
from django.urls import re_path
|
||||
|
||||
from bookmarks import views
|
||||
from bookmarks.api.routes import router
|
||||
from bookmarks.api import routes as api_routes
|
||||
from bookmarks.feeds import (
|
||||
AllBookmarksFeed,
|
||||
UnreadBookmarksFeed,
|
||||
@@ -55,7 +55,14 @@ urlpatterns = [
|
||||
# Toasts
|
||||
path("toasts/acknowledge", views.toasts.acknowledge, name="toasts.acknowledge"),
|
||||
# API
|
||||
path("api/", include(router.urls), name="api"),
|
||||
path("api/", include(api_routes.default_router.urls)),
|
||||
path("api/bookmarks/", include(api_routes.bookmark_router.urls)),
|
||||
path(
|
||||
"api/bookmarks/<int:bookmark_id>/assets/",
|
||||
include(api_routes.bookmark_asset_router.urls),
|
||||
),
|
||||
path("api/tags/", include(api_routes.tag_router.urls)),
|
||||
path("api/user/", include(api_routes.user_router.urls)),
|
||||
# Feeds
|
||||
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
|
||||
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
|
||||
@@ -65,4 +72,6 @@ urlpatterns = [
|
||||
path("health", views.health, name="health"),
|
||||
# Manifest
|
||||
path("manifest.json", views.manifest, name="manifest"),
|
||||
# Custom CSS
|
||||
path("custom_css", views.custom_css, name="custom_css"),
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.template.defaultfilters import pluralize
|
||||
from django.utils import timezone, formats
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
|
||||
def generate_username(email):
|
||||
def generate_username(email, claims):
|
||||
# taken from mozilla-django-oidc docs :)
|
||||
|
||||
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||
# it and slice at 150 characters.
|
||||
return unicodedata.normalize("NFKC", email)[:150]
|
||||
if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:
|
||||
username = claims[settings.OIDC_USERNAME_CLAIM]
|
||||
else:
|
||||
username = email
|
||||
return unicodedata.normalize("NFKC", username)[:150]
|
||||
|
||||
@@ -4,4 +4,5 @@ from .settings import *
|
||||
from .toasts import *
|
||||
from .health import health
|
||||
from .manifest import manifest
|
||||
from .custom_css import custom_css
|
||||
from .root import root
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import QuerySet
|
||||
from django.http import (
|
||||
@@ -19,7 +20,7 @@ from bookmarks.models import (
|
||||
BookmarkSearch,
|
||||
build_tag_string,
|
||||
)
|
||||
from bookmarks.services import bookmarks as bookmark_actions, tasks
|
||||
from bookmarks.services import assets as asset_actions, tasks
|
||||
from bookmarks.services.bookmarks import (
|
||||
create_bookmark,
|
||||
update_bookmark,
|
||||
@@ -104,6 +105,7 @@ def shared(request):
|
||||
"tag_cloud": tag_cloud,
|
||||
"details": bookmark_details,
|
||||
"users": users,
|
||||
"rss_feed_url": reverse("bookmarks:feeds.public_shared"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -150,7 +152,6 @@ def convert_tag_string(tag_string: str):
|
||||
|
||||
@login_required
|
||||
def new(request):
|
||||
status = 200
|
||||
initial_url = request.GET.get("url")
|
||||
initial_title = request.GET.get("title")
|
||||
initial_description = request.GET.get("description")
|
||||
@@ -169,8 +170,6 @@ def new(request):
|
||||
return HttpResponseRedirect(reverse("bookmarks:close"))
|
||||
else:
|
||||
return HttpResponseRedirect(reverse("bookmarks:index"))
|
||||
else:
|
||||
status = 422
|
||||
else:
|
||||
form = BookmarkForm()
|
||||
if initial_url:
|
||||
@@ -186,6 +185,7 @@ def new(request):
|
||||
if initial_mark_unread:
|
||||
form.initial["unread"] = "true"
|
||||
|
||||
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||
context = {
|
||||
"form": form,
|
||||
"auto_close": initial_auto_close,
|
||||
@@ -216,9 +216,10 @@ def edit(request, bookmark_id: int):
|
||||
|
||||
form.initial["tag_string"] = build_tag_string(bookmark.tag_names, " ")
|
||||
|
||||
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||
context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url}
|
||||
|
||||
return render(request, "bookmarks/edit.html", context)
|
||||
return render(request, "bookmarks/edit.html", context, status=status)
|
||||
|
||||
|
||||
def remove(request, bookmark_id: int):
|
||||
@@ -278,6 +279,9 @@ def create_html_snapshot(request, bookmark_id: int):
|
||||
|
||||
|
||||
def upload_asset(request, bookmark_id: int):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return HttpResponseForbidden("Asset upload is disabled")
|
||||
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
@@ -285,9 +289,9 @@ def upload_asset(request, bookmark_id: int):
|
||||
|
||||
file = request.FILES.get("upload_asset_file")
|
||||
if not file:
|
||||
raise ValueError("No file uploaded")
|
||||
return HttpResponseBadRequest("No file provided")
|
||||
|
||||
bookmark_actions.upload_asset(bookmark, file)
|
||||
asset_actions.upload_asset(bookmark, file)
|
||||
|
||||
|
||||
def remove_asset(request, asset_id: int):
|
||||
@@ -315,7 +319,10 @@ def update_state(request, bookmark_id: int):
|
||||
def index_action(request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query = queries.query_bookmarks(request.user, request.user_profile, search)
|
||||
handle_action(request, query)
|
||||
|
||||
response = handle_action(request, query)
|
||||
if response:
|
||||
return response
|
||||
|
||||
if turbo.accept(request):
|
||||
return partials.active_bookmark_update(request)
|
||||
@@ -327,7 +334,10 @@ def index_action(request):
|
||||
def archived_action(request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
|
||||
handle_action(request, query)
|
||||
|
||||
response = handle_action(request, query)
|
||||
if response:
|
||||
return response
|
||||
|
||||
if turbo.accept(request):
|
||||
return partials.archived_bookmark_update(request)
|
||||
@@ -340,7 +350,9 @@ def shared_action(request):
|
||||
if "bulk_execute" in request.POST:
|
||||
return HttpResponseBadRequest("View does not support bulk actions")
|
||||
|
||||
handle_action(request)
|
||||
response = handle_action(request)
|
||||
if response:
|
||||
return response
|
||||
|
||||
if turbo.accept(request):
|
||||
return partials.shared_bookmark_update(request)
|
||||
@@ -351,25 +363,25 @@ def shared_action(request):
|
||||
def handle_action(request, query: QuerySet[Bookmark] = None):
|
||||
# Single bookmark actions
|
||||
if "archive" in request.POST:
|
||||
archive(request, request.POST["archive"])
|
||||
return archive(request, request.POST["archive"])
|
||||
if "unarchive" in request.POST:
|
||||
unarchive(request, request.POST["unarchive"])
|
||||
return unarchive(request, request.POST["unarchive"])
|
||||
if "remove" in request.POST:
|
||||
remove(request, request.POST["remove"])
|
||||
return remove(request, request.POST["remove"])
|
||||
if "mark_as_read" in request.POST:
|
||||
mark_as_read(request, request.POST["mark_as_read"])
|
||||
return mark_as_read(request, request.POST["mark_as_read"])
|
||||
if "unshare" in request.POST:
|
||||
unshare(request, request.POST["unshare"])
|
||||
return unshare(request, request.POST["unshare"])
|
||||
if "create_html_snapshot" in request.POST:
|
||||
create_html_snapshot(request, request.POST["create_html_snapshot"])
|
||||
return create_html_snapshot(request, request.POST["create_html_snapshot"])
|
||||
if "upload_asset" in request.POST:
|
||||
upload_asset(request, request.POST["upload_asset"])
|
||||
return upload_asset(request, request.POST["upload_asset"])
|
||||
if "remove_asset" in request.POST:
|
||||
remove_asset(request, request.POST["remove_asset"])
|
||||
return remove_asset(request, request.POST["remove_asset"])
|
||||
|
||||
# State updates
|
||||
if "update_state" in request.POST:
|
||||
update_state(request, request.POST["update_state"])
|
||||
return update_state(request, request.POST["update_state"])
|
||||
|
||||
# Bulk actions
|
||||
if "bulk_execute" in request.POST:
|
||||
@@ -387,25 +399,25 @@ def handle_action(request, query: QuerySet[Bookmark] = None):
|
||||
bookmark_ids = request.POST.getlist("bookmark_id")
|
||||
|
||||
if "bulk_archive" == bulk_action:
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
return archive_bookmarks(bookmark_ids, request.user)
|
||||
if "bulk_unarchive" == bulk_action:
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
return unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if "bulk_delete" == bulk_action:
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
return delete_bookmarks(bookmark_ids, request.user)
|
||||
if "bulk_tag" == bulk_action:
|
||||
tag_string = convert_tag_string(request.POST["bulk_tag_string"])
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
return tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if "bulk_untag" == bulk_action:
|
||||
tag_string = convert_tag_string(request.POST["bulk_tag_string"])
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
return untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if "bulk_read" == bulk_action:
|
||||
mark_bookmarks_as_read(bookmark_ids, request.user)
|
||||
return mark_bookmarks_as_read(bookmark_ids, request.user)
|
||||
if "bulk_unread" == bulk_action:
|
||||
mark_bookmarks_as_unread(bookmark_ids, request.user)
|
||||
return mark_bookmarks_as_unread(bookmark_ids, request.user)
|
||||
if "bulk_share" == bulk_action:
|
||||
share_bookmarks(bookmark_ids, request.user)
|
||||
return share_bookmarks(bookmark_ids, request.user)
|
||||
if "bulk_unshare" == bulk_action:
|
||||
unshare_bookmarks(bookmark_ids, request.user)
|
||||
return unshare_bookmarks(bookmark_ids, request.user)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -208,6 +208,7 @@ class BookmarkListContext:
|
||||
self.show_favicons = user_profile.enable_favicons
|
||||
self.show_preview_images = user_profile.enable_preview_images
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||
|
||||
@staticmethod
|
||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
||||
@@ -358,7 +359,6 @@ class BookmarkAssetItem:
|
||||
self.id = asset.id
|
||||
self.display_name = asset.display_name
|
||||
self.asset_type = asset.asset_type
|
||||
self.content_type = asset.content_type
|
||||
self.file = asset.file
|
||||
self.file_size = asset.file_size
|
||||
self.status = asset.status
|
||||
@@ -398,8 +398,8 @@ class BookmarkDetailsContext:
|
||||
self.sharing_enabled = user_profile.enable_sharing
|
||||
self.preview_image_enabled = user_profile.enable_preview_images
|
||||
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
|
||||
# For now hide files section if snapshots are not supported
|
||||
self.show_files = settings.LD_ENABLE_SNAPSHOTS
|
||||
self.snapshots_enabled = settings.LD_ENABLE_SNAPSHOTS
|
||||
self.uploads_enabled = not settings.LD_DISABLE_ASSET_UPLOAD
|
||||
|
||||
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
|
||||
if not self.web_archive_snapshot_url:
|
||||
|
||||
10
bookmarks/views/custom_css.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.http import HttpResponse
|
||||
|
||||
custom_css_cache_max_age = 2592000 # 30 days
|
||||
|
||||
|
||||
def custom_css(request):
|
||||
css = request.user_profile.custom_css
|
||||
response = HttpResponse(css, content_type="text/css")
|
||||
response["Cache-Control"] = f"public, max-age={custom_css_cache_max_age}"
|
||||
return response
|
||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12.6-alpine3.20 AS python-base
|
||||
FROM python:3.12.9-alpine3.21 AS build-deps
|
||||
# Add required packages
|
||||
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
||||
# libpq-dev: build Postgres client from source
|
||||
@@ -18,24 +18,8 @@ FROM python:3.12.6-alpine3.20 AS python-base
|
||||
# libffi-dev openssl-dev rust cargo: build Python cryptography from source
|
||||
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
|
||||
FROM python-base AS python-build
|
||||
# install build dependencies
|
||||
# install python dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -U pip && pip install -r requirements.txt
|
||||
# copy files needed for Django build
|
||||
COPY . .
|
||||
COPY --from=node-build /etc/linkding .
|
||||
# remove style sources
|
||||
RUN rm -rf bookmarks/styles
|
||||
# run Django part of the build
|
||||
RUN mkdir data && \
|
||||
python manage.py collectstatic
|
||||
|
||||
|
||||
FROM python-base AS prod-deps
|
||||
COPY requirements.txt ./requirements.txt
|
||||
# Need to build psycopg2 from source for ARM platforms
|
||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
@@ -44,7 +28,7 @@ RUN mkdir /opt/venv && \
|
||||
/opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
|
||||
FROM python-base AS compile-icu
|
||||
FROM build-deps AS compile-icu
|
||||
# Defines SQLite version
|
||||
# Since this is only needed for downloading the header files this probably
|
||||
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
||||
@@ -65,7 +49,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.12.6-alpine3.20 AS linkding
|
||||
FROM python:3.12.9-alpine3.21 AS linkding
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||
@@ -74,19 +58,25 @@ RUN set -x ; \
|
||||
addgroup -g 82 -S www-data ; \
|
||||
adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
COPY --from=prod-deps /opt/venv /opt/venv
|
||||
# copy output from build stage
|
||||
COPY --from=python-build /etc/linkding/static static/
|
||||
# copy python dependencies
|
||||
COPY --from=build-deps /opt/venv /opt/venv
|
||||
# copy output from node build
|
||||
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
|
||||
# copy compiled icu extension
|
||||
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||
# copy application code
|
||||
COPY . .
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
# Generate static files, remove source styles that are not needed
|
||||
RUN mkdir data && \
|
||||
python manage.py collectstatic
|
||||
|
||||
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
|
||||
ENV UWSGI_MAX_FD=4096
|
||||
# Expose uwsgi server at port 9090
|
||||
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 . && \
|
||||
chmod +x ./bootstrap.sh
|
||||
@@ -100,18 +90,20 @@ CMD ["./bootstrap.sh"]
|
||||
FROM node:18-alpine AS ublock-build
|
||||
WORKDIR /etc/linkding
|
||||
# Install necessary tools
|
||||
RUN apk add --no-cache curl jq unzip
|
||||
# Fetch the latest release tag
|
||||
# Download the library
|
||||
# Unzip the library
|
||||
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
|
||||
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
|
||||
unzip uBlock0.zip
|
||||
# Patch assets.json to enable easylist-cookies by default
|
||||
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
||||
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
|
||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
||||
# Download and unzip the latest uBlock Origin Lite release
|
||||
# Patch manifest to enable annoyances by default
|
||||
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
|
||||
RUN apk add --no-cache curl jq unzip && \
|
||||
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
|
||||
echo "Downloading $DOWNLOAD_URL" && \
|
||||
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
|
||||
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
|
||||
rm uBOLite.zip && \
|
||||
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
|
||||
uBOLite.chromium.mv3/manifest.json > temp.json && \
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json && \
|
||||
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||
|
||||
|
||||
FROM linkding AS linkding-plus
|
||||
@@ -119,9 +111,11 @@ FROM linkding AS linkding-plus
|
||||
RUN apk update && apk add nodejs npm chromium
|
||||
# install single-file from fork for now, which contains several hotfixes
|
||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||
# copy uBlock0
|
||||
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
|
||||
# create chromium profile folder for user running background tasks
|
||||
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
|
||||
# copy uBlock
|
||||
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
|
||||
# create chromium profile folder for user running background tasks and set permissions
|
||||
RUN mkdir -p chromium-profile && \
|
||||
chown -R www-data:www-data chromium-profile && \
|
||||
chown -R www-data:www-data uBOLite.chromium.mv3
|
||||
# enable snapshot support
|
||||
ENV LD_ENABLE_SNAPSHOTS=True
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12.6-slim-bookworm AS python-base
|
||||
FROM python:3.12.9-slim-bookworm AS build-deps
|
||||
# Add required packages
|
||||
# build-essential pkg-config: build Python packages from source
|
||||
# libpq-dev: build Postgres client from source
|
||||
@@ -20,24 +20,8 @@ RUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev li
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
|
||||
FROM python-base AS python-build
|
||||
# install build dependencies
|
||||
# install python dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -U pip && pip install -r requirements.txt
|
||||
# copy files needed for Django build
|
||||
COPY . .
|
||||
COPY --from=node-build /etc/linkding .
|
||||
# remove style sources
|
||||
RUN rm -rf bookmarks/styles
|
||||
# run Django part of the build
|
||||
RUN mkdir data && \
|
||||
python manage.py collectstatic
|
||||
|
||||
|
||||
FROM python-base AS prod-deps
|
||||
COPY requirements.txt ./requirements.txt
|
||||
# Need to build psycopg2 from source for ARM platforms
|
||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
@@ -46,7 +30,7 @@ RUN mkdir /opt/venv && \
|
||||
/opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
|
||||
FROM python-base AS compile-icu
|
||||
FROM build-deps AS compile-icu
|
||||
# Defines SQLite version
|
||||
# Since this is only needed for downloading the header files this probably
|
||||
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
||||
@@ -67,27 +51,33 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.12.6-slim-bookworm as linkding
|
||||
FROM python:3.12.9-slim-bookworm AS linkding
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
COPY --from=prod-deps /opt/venv /opt/venv
|
||||
# copy output from build stage
|
||||
COPY --from=python-build /etc/linkding/static static/
|
||||
# copy python dependencies
|
||||
COPY --from=build-deps /opt/venv /opt/venv
|
||||
# copy output from node build
|
||||
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
|
||||
# copy compiled icu extension
|
||||
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||
# copy application code
|
||||
COPY . .
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
# Generate static files
|
||||
RUN mkdir data && \
|
||||
python manage.py collectstatic
|
||||
|
||||
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
|
||||
ENV UWSGI_MAX_FD=4096
|
||||
# Expose uwsgi server at port 9090
|
||||
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"]
|
||||
RUN chmod g+w . && \
|
||||
chmod +x ./bootstrap.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
@@ -98,18 +88,20 @@ CMD ["./bootstrap.sh"]
|
||||
FROM node:18-alpine AS ublock-build
|
||||
WORKDIR /etc/linkding
|
||||
# Install necessary tools
|
||||
RUN apk add --no-cache curl jq unzip
|
||||
# Fetch the latest release tag
|
||||
# Download the library
|
||||
# Unzip the library
|
||||
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
|
||||
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
|
||||
unzip uBlock0.zip
|
||||
# Patch assets.json to enable easylist-cookies by default
|
||||
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
||||
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
|
||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
||||
# Download and unzip the latest uBlock Origin Lite release
|
||||
# Patch manifest to enable annoyances by default
|
||||
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
|
||||
RUN apk add --no-cache curl jq unzip && \
|
||||
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
|
||||
echo "Downloading $DOWNLOAD_URL" && \
|
||||
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
|
||||
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
|
||||
rm uBOLite.zip && \
|
||||
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
|
||||
uBOLite.chromium.mv3/manifest.json > temp.json && \
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json && \
|
||||
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||
|
||||
|
||||
FROM linkding AS linkding-plus
|
||||
@@ -123,9 +115,11 @@ RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
|
||||
apt-get update && apt-get install -y nodejs
|
||||
# install single-file from fork for now, which contains several hotfixes
|
||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||
# create chromium profile folder for user running background tasks
|
||||
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
|
||||
# copy uBlock0
|
||||
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
|
||||
# copy uBlock
|
||||
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
|
||||
# create chromium profile folder for user running background tasks and set permissions
|
||||
RUN mkdir -p chromium-profile && \
|
||||
chown -R www-data:www-data chromium-profile && \
|
||||
chown -R www-data:www-data uBOLite.chromium.mv3
|
||||
# enable snapshot support
|
||||
ENV LD_ENABLE_SNAPSHOTS=True
|
||||
|
||||
@@ -27,10 +27,11 @@ export default defineConfig({
|
||||
label: 'Guides',
|
||||
items: [
|
||||
{ label: 'Backups', slug: 'backups' },
|
||||
{ label: 'Admin', slug: 'admin' },
|
||||
{ label: 'Archiving', slug: 'archiving' },
|
||||
{ label: 'Keyboard Shortcuts', slug: 'shortcuts' },
|
||||
{ label: 'How To', slug: 'how-to' },
|
||||
{ label: 'Troubleshooting', slug: 'troubleshooting' },
|
||||
{ label: 'Admin', slug: 'admin' },
|
||||
{ label: 'REST API', slug: 'api' },
|
||||
],
|
||||
},
|
||||
@@ -45,6 +46,9 @@ export default defineConfig({
|
||||
customCss: [
|
||||
'./src/styles/custom.css',
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/sissbruecker/linkding/edit/master/docs/',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
1199
docs/package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.27.1",
|
||||
"astro": "^4.15.8",
|
||||
"astro": "^4.16.18",
|
||||
"sharp": "^0.32.5",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
BIN
docs/public/donations/2024-10-04-django.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/public/donations/2024-10-04-internet-archive.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/public/donations/2024-10-04-noyb.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/public/donations/2024-10-04-singlefile.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
@@ -9,9 +9,35 @@ description: "Acknowledgements and thanks to contributors and sponsors"
|
||||
|
||||
See the table below for a list of donations.
|
||||
|
||||
| Source | Description | Amount | Donated to |
|
||||
|---------------------------------------|---------------------------------------------|---------|------------------------------------------------------------------|
|
||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/2023-10-11-internet-archive.png) |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Description</th>
|
||||
<th>Amount</th>
|
||||
<th>Donated to</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
|
||||
<td>Linkding hosting June 2022 - September 2023</td>
|
||||
<td>$163.50</td>
|
||||
<td><a href="/donations/2023-10-11-internet-archive.png">Internet Archive</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
|
||||
<td>Linkding hosting October 2023 - September 2024</td>
|
||||
<td>$287.04</td>
|
||||
<td>
|
||||
<a href="/donations/2024-10-04-django.png">Django</a><br>
|
||||
<a href="/donations/2024-10-04-singlefile.png">SingleFile</a><br>
|
||||
<a href="/donations/2024-10-04-internet-archive.png">Internet Archive</a><br>
|
||||
<a href="/donations/2024-10-04-noyb.png">NOYB</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## JetBrains
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ The application provides a REST API that can be used by 3rd party applications t
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests against the API must be authorized using an authorization token. The application automatically generates an API token for each user, which can be accessed through the *Settings* page.
|
||||
All requests against the API must be authorized using an authorization token. The application automatically generates an
|
||||
API token for each user, which can be accessed through the *Settings* page.
|
||||
|
||||
The token needs to be passed as `Authorization` header in the HTTP request:
|
||||
|
||||
@@ -91,9 +92,11 @@ Retrieves a single bookmark by ID.
|
||||
GET /api/bookmarks/check/?url=https%3A%2F%2Fexample.com
|
||||
```
|
||||
|
||||
Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the response holds the bookmark data, otherwise it is `null`.
|
||||
Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the
|
||||
response holds the bookmark data, otherwise it is `null`.
|
||||
|
||||
Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property contains the tag names that would be automatically added when creating a bookmark for that URL.
|
||||
Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property
|
||||
contains the tag names that would be automatically added when creating a bookmark for that URL.
|
||||
|
||||
Example response:
|
||||
|
||||
@@ -127,11 +130,13 @@ POST /api/bookmarks/
|
||||
Creates a new bookmark. Tags are simply assigned using their names. Including
|
||||
`is_archived: true` saves a bookmark directly to the archive.
|
||||
|
||||
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request. If you have an application where you want to keep using scraped metadata, but also allow users to leave the title or description empty, you should:
|
||||
If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If
|
||||
you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint
|
||||
to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in
|
||||
the future to return an error instead.
|
||||
|
||||
- Fetch the scraped title and description using the `/check` endpoint.
|
||||
- Prefill the title and description fields in your app with the fetched values and allow users to clear those values.
|
||||
- Add the `disable_scraping` query parameter to prevent the API from adding them back again.
|
||||
If the title and description are not provided or empty, the application automatically tries to scrape them from the
|
||||
bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
|
||||
|
||||
Example payload:
|
||||
|
||||
@@ -155,36 +160,17 @@ Example payload:
|
||||
|
||||
```
|
||||
PUT /api/bookmarks/<id>/
|
||||
```
|
||||
|
||||
Updates a bookmark.
|
||||
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
|
||||
Tags are simply assigned using their names.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"title": "Example title",
|
||||
"description": "Example description",
|
||||
"tag_names": [
|
||||
"tag1",
|
||||
"tag2"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Patch**
|
||||
|
||||
```
|
||||
PATCH /api/bookmarks/<id>/
|
||||
```
|
||||
|
||||
Updates a bookmark partially.
|
||||
Allows to modify individual fields of a bookmark.
|
||||
Updates a bookmark.
|
||||
When using `POST`, at least all required fields must be provided (currently only `url`).
|
||||
When using `PATCH`, only the fields that should be updated need to be provided.
|
||||
Regardless which method is used, any field that is not provided is not modified.
|
||||
Tags are simply assigned using their names.
|
||||
|
||||
If the provided URL is already bookmarked this returns an error.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
@@ -223,6 +209,96 @@ DELETE /api/bookmarks/<id>/
|
||||
|
||||
Deletes a bookmark by ID.
|
||||
|
||||
### Bookmark Assets
|
||||
|
||||
**List**
|
||||
|
||||
```
|
||||
GET /api/bookmarks/<bookmark_id>/assets/
|
||||
```
|
||||
|
||||
List assets for a specific bookmark.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"bookmark": 1,
|
||||
"asset_type": "snapshot",
|
||||
"date_created": "2023-10-01T12:00:00Z",
|
||||
"content_type": "text/html",
|
||||
"display_name": "HTML snapshot from 10/01/2023",
|
||||
"status": "complete",
|
||||
"gzip": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"bookmark": 1,
|
||||
"asset_type": "upload",
|
||||
"date_created": "2023-10-01T12:05:00Z",
|
||||
"content_type": "image/png",
|
||||
"display_name": "example.png",
|
||||
"status": "complete",
|
||||
"gzip": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Retrieve**
|
||||
|
||||
```
|
||||
GET /api/bookmarks/<bookmark_id>/assets/<id>/
|
||||
```
|
||||
|
||||
Retrieves a single asset by ID for a specific bookmark.
|
||||
|
||||
**Download**
|
||||
|
||||
```
|
||||
GET /api/bookmarks/<bookmark_id>/assets/<id>/download/
|
||||
```
|
||||
|
||||
Downloads the asset file.
|
||||
|
||||
**Upload**
|
||||
|
||||
```
|
||||
POST /api/bookmarks/<bookmark_id>/assets/upload/
|
||||
```
|
||||
|
||||
Uploads a new asset for a specific bookmark. The request must be a `multipart/form-data` request with a single part
|
||||
named `file` containing the file to upload.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"bookmark": 1,
|
||||
"asset_type": "upload",
|
||||
"date_created": "2023-10-01T12:10:00Z",
|
||||
"content_type": "application/pdf",
|
||||
"display_name": "example.pdf",
|
||||
"status": "complete",
|
||||
"gzip": false
|
||||
}
|
||||
```
|
||||
|
||||
**Delete**
|
||||
|
||||
```
|
||||
DELETE /api/bookmarks/<bookmark_id>/assets/<id>/
|
||||
```
|
||||
|
||||
Deletes an asset by ID for a specific bookmark.
|
||||
|
||||
### Tags
|
||||
|
||||
**List**
|
||||
|
||||