mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 23:13:12 +08:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e487cf726a | ||
|
|
f2800efc1a | ||
|
|
9a00ae4b93 | ||
|
|
da9371e33c | ||
|
|
5b3f2f6563 | ||
|
|
04065f8079 | ||
|
|
d986ff0900 | ||
|
|
51a85bbaf1 | ||
|
|
39b911880d | ||
|
|
9db3fa1248 | ||
|
|
77689366a0 | ||
|
|
f2e6014ca4 | ||
|
|
da98929f07 | ||
|
|
1b0684bd6c | ||
|
|
8928c78530 | ||
|
|
61108234b4 | ||
|
|
7b098d4549 | ||
|
|
648e67bd91 | ||
|
|
6bba4f35c8 | ||
|
|
6d9d0e19f1 | ||
|
|
a23c357f2f | ||
|
|
f1acb4f7c9 | ||
|
|
48fc499aed | ||
|
|
2a55800e18 | ||
|
|
e45dffb9cb | ||
|
|
226eb69f8b | ||
|
|
b9bee24047 | ||
|
|
9dfc9b03b4 | ||
|
|
6ab6a031c7 | ||
|
|
1a1092d03a | ||
|
|
4260dfce79 | ||
|
|
2d3bd13a12 | ||
|
|
b037de14c9 | ||
|
|
bbf173c135 | ||
|
|
002fec37d0 | ||
|
|
996e2b6e19 | ||
|
|
6838e45e99 | ||
|
|
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 |
@@ -3,7 +3,6 @@
|
||||
|
||||
# Include files required for build or at runtime
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
@@ -19,4 +18,4 @@
|
||||
!/version.txt
|
||||
|
||||
# Remove dev settings
|
||||
/siteroot/settings/dev.py
|
||||
/bookmarks/settings/dev.py
|
||||
|
||||
89
.github/workflows/build.yaml
vendored
Normal file
89
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
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: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.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 }}
|
||||
ghcr.io/sissbruecker/linkding:latest
|
||||
ghcr.io/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
|
||||
ghcr.io/sissbruecker/linkding:latest-alpine
|
||||
ghcr.io/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
|
||||
ghcr.io/sissbruecker/linkding:latest-plus
|
||||
ghcr.io/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
|
||||
ghcr.io/sissbruecker/linkding:latest-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
target: linkding-plus
|
||||
push: true
|
||||
2
.github/workflows/main.yaml
vendored
2
.github/workflows/main.yaml
vendored
@@ -55,4 +55,4 @@ jobs:
|
||||
npm run build
|
||||
python manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,6 +1,95 @@
|
||||
# Changelog
|
||||
|
||||
## (23/09/2024)
|
||||
## 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
|
||||
|
||||
3
Makefile
3
Makefile
@@ -4,13 +4,12 @@ serve:
|
||||
python manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py process_tasks
|
||||
python manage.py run_huey
|
||||
|
||||
test:
|
||||
pytest -n auto
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
black siteroot
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
||||
|
||||
@@ -58,7 +58,7 @@ Small improvements, bugfixes and documentation improvements are always welcome.
|
||||
|
||||
## Development
|
||||
|
||||
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.12
|
||||
|
||||
34
bookmarks/api/auth.py
Normal file
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,26 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
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
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +33,7 @@ class BookmarkViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
@@ -46,67 +48,63 @@ class BookmarkViewSet(
|
||||
return super().get_permissions()
|
||||
|
||||
def get_queryset(self):
|
||||
# Provide filtered queryset for list actions
|
||||
user = self.request.user
|
||||
# For list action, use query set that applies search and tag projections
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
if self.action == "list":
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
elif self.action == "archived":
|
||||
return queries.query_archived_bookmarks(user, user.profile, search)
|
||||
elif self.action == "shared":
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmarks(
|
||||
user, self.request.user_profile, search, public_only
|
||||
)
|
||||
|
||||
# For single entity actions use default query set without projections
|
||||
# For single entity actions return user owned bookmarks
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
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)
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
def archived(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def shared(self, request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not request.user.is_authenticated
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
def shared(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def archive(self, request, pk):
|
||||
def archive(self, request: HttpRequest, 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):
|
||||
def unarchive(self, request: HttpRequest, 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)
|
||||
def check(self, request):
|
||||
def check(self, request: HttpRequest):
|
||||
url = request.GET.get("url")
|
||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
@@ -129,6 +127,119 @@ class BookmarkViewSet(
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def singlefile(self, request: HttpRequest):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
url = request.POST.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,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkAssetSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# limit access to assets to the owner of the bookmark for now
|
||||
bookmark = access.bookmark_write(self.request, self.kwargs["bookmark_id"])
|
||||
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: HttpRequest, 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 = StreamingHttpResponse(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: HttpRequest, bookmark_id):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
bookmark = access.bookmark_write(request, bookmark_id)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
assets.remove_asset(instance)
|
||||
|
||||
|
||||
class TagViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
@@ -136,6 +247,7 @@ class TagViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = TagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -148,11 +260,23 @@ class TagViewSet(
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
@action(methods=["get"], detail=False)
|
||||
def profile(self, request):
|
||||
def profile(self, request: HttpRequest):
|
||||
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):
|
||||
@@ -24,6 +22,11 @@ class BookmarkListSerializer(ListSerializer):
|
||||
return super().to_representation(data)
|
||||
|
||||
|
||||
class EmtpyField(serializers.ReadOnlyField):
|
||||
def to_representation(self, value):
|
||||
return None
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
@@ -59,12 +62,13 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Custom tag_names field to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False)
|
||||
# Custom fields to return URLs for favicon and preview image
|
||||
# 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()
|
||||
website_title = EmtpyField()
|
||||
website_description = EmtpyField()
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
@@ -82,23 +86,31 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def get_website_title(self, obj: Bookmark):
|
||||
return None
|
||||
def get_web_archive_snapshot_url(self, obj: Bookmark):
|
||||
if obj.web_archive_snapshot_url:
|
||||
return obj.web_archive_snapshot_url
|
||||
|
||||
def get_website_description(self, obj: Bookmark):
|
||||
return None
|
||||
return generate_fallback_webarchive_url(obj.url, obj.date_added)
|
||||
|
||||
def create(self, validated_data):
|
||||
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):
|
||||
@@ -109,7 +121,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
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
|
||||
@@ -130,6 +142,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
class Meta:
|
||||
model = Tag
|
||||
@@ -155,4 +182,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"search_preferences",
|
||||
"version",
|
||||
]
|
||||
|
||||
version = serializers.ReadOnlyField(default=app_version)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import BookmarkSearch, Toast
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import Toast
|
||||
|
||||
|
||||
def toasts(request):
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
mock_website_metadata = website_loader.WebsiteMetadata(
|
||||
url="https://example.com",
|
||||
title="Example Domain",
|
||||
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
|
||||
preview_image=None,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.website_loader_patch = patch.object(
|
||||
website_loader, "load_website_metadata", return_value=mock_website_metadata
|
||||
)
|
||||
self.website_loader_patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.website_loader_patch.stop()
|
||||
|
||||
def test_enter_url_prefills_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
url.fill("https://example.com")
|
||||
expect(title).to_have_value("Example Domain")
|
||||
expect(description).to_have_value(
|
||||
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
|
||||
)
|
||||
|
||||
def test_enter_url_does_not_overwrite_modified_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
title.fill("Modified title")
|
||||
description.fill("Modified description")
|
||||
url.fill("https://example.com")
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(title).to_have_value("Modified title")
|
||||
expect(description).to_have_value("Modified description")
|
||||
|
||||
def test_with_initial_url_prefills_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page_url = reverse("bookmarks:new") + f"?url={quote('https://example.com')}"
|
||||
page = self.open(page_url, p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(url).to_have_value("https://example.com")
|
||||
expect(title).to_have_value("Example Domain")
|
||||
expect(description).to_have_value(
|
||||
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
|
||||
)
|
||||
|
||||
def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
|
||||
self,
|
||||
):
|
||||
with sync_playwright() as p:
|
||||
page_url = (
|
||||
reverse("bookmarks:new")
|
||||
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
|
||||
)
|
||||
page = self.open(page_url, p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(url).to_have_value("https://example.com")
|
||||
expect(title).to_have_value("Initial title")
|
||||
expect(description).to_have_value("Initial description")
|
||||
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(
|
||||
title="Existing title",
|
||||
description="Existing description",
|
||||
notes="Existing notes",
|
||||
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
|
||||
unread=True,
|
||||
)
|
||||
tag_names = " ".join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label("URL").fill(existing_bookmark.url)
|
||||
# Already bookmarked hint should be visible
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(
|
||||
existing_bookmark.title, page.get_by_label("Title").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.description,
|
||||
page.get_by_label("Description").input_value(),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.notes, page.get_by_label("Notes").input_value()
|
||||
)
|
||||
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
|
||||
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
|
||||
|
||||
# Enter non-bookmarked URL
|
||||
page.get_by_label("URL").fill("https://example.com/unknown")
|
||||
# Already bookmarked hint should be hidden
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||
state="hidden", timeout=2000
|
||||
)
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
notes="Existing notes", description="Existing description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
|
||||
details = page.locator("details.notes")
|
||||
expect(details).not_to_have_attribute("open", value="")
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
url = (
|
||||
reverse("bookmarks:new")
|
||||
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
)
|
||||
page = self.open(url, p)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
||||
@@ -74,7 +74,7 @@ class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.all", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -87,7 +87,7 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
).filter(unread=True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.unread", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -100,7 +100,7 @@ class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -114,4 +114,4 @@ class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.public_shared")
|
||||
return reverse("linkding:feeds.public_shared")
|
||||
|
||||
95
bookmarks/forms.py
Normal file
95
bookmarks/forms.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django import forms
|
||||
|
||||
from bookmarks.models import Bookmark, build_tag_string
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
def __init__(self, request: HttpRequest, instance: Bookmark = None):
|
||||
self.request = request
|
||||
|
||||
initial = None
|
||||
if instance is None and request.method == "GET":
|
||||
initial = {
|
||||
"url": request.GET.get("url"),
|
||||
"title": request.GET.get("title"),
|
||||
"description": request.GET.get("description"),
|
||||
"notes": request.GET.get("notes"),
|
||||
"tag_string": request.GET.get("tags"),
|
||||
"auto_close": "auto_close" in request.GET,
|
||||
"unread": request.user_profile.default_mark_unread,
|
||||
}
|
||||
if instance is not None and request.method == "GET":
|
||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||
data = request.POST if request.method == "POST" else None
|
||||
super().__init__(data, instance=instance, initial=initial)
|
||||
|
||||
@property
|
||||
def is_auto_close(self):
|
||||
return self.data.get("auto_close", False) == "True" or self.initial.get(
|
||||
"auto_close", False
|
||||
)
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
|
||||
def save(self, commit=False):
|
||||
tag_string = convert_tag_string(self.data["tag_string"])
|
||||
bookmark = super().save(commit=False)
|
||||
if self.instance.pk:
|
||||
return update_bookmark(bookmark, tag_string, self.request.user)
|
||||
else:
|
||||
return create_bookmark(bookmark, tag_string, self.request.user)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
return tag_string.replace(" ", ",")
|
||||
@@ -1,60 +1,22 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive, setAfterPageLoadFocusTarget } 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)");
|
||||
|
||||
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 },
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.element.dataset.bookmarkId;
|
||||
setAfterPageLoadFocusTarget(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
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">
|
||||
<div class="content"></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(".side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector(".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);
|
||||
124
bookmarks/frontend/behaviors/focus-utils.js
Normal file
124
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let afterPageLoadFocusTarget = [];
|
||||
let firstPageLoad = true;
|
||||
|
||||
export function setAfterPageLoadFocusTarget(...targets) {
|
||||
afterPageLoadFocusTarget = targets;
|
||||
}
|
||||
|
||||
function programmaticFocus(element) {
|
||||
// Ensure element is focusable
|
||||
// Hide focus outline if element is not focusable by default - might
|
||||
// reconsider this later
|
||||
const isFocusable = element.tabIndex >= 0;
|
||||
if (!isFocusable) {
|
||||
// Apparently the default tabIndex is -1, even though an element is still
|
||||
// not focusable with that. Setting an explicit -1 also sets the attribute
|
||||
// and the element becomes focusable.
|
||||
element.tabIndex = -1;
|
||||
// `focusVisible` is not supported in all browsers, so hide the outline manually
|
||||
element.style["outline"] = "none";
|
||||
}
|
||||
element.focus({
|
||||
focusVisible: isKeyboardActive() && isFocusable,
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Register global listener for navigation and try to focus an element that
|
||||
// results in a meaningful announcement.
|
||||
document.addEventListener("turbo:load", () => {
|
||||
// Ignore initial page load to let the browser handle announcements
|
||||
if (firstPageLoad) {
|
||||
firstPageLoad = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is an explicit focus target for the next page load
|
||||
for (const target of afterPageLoadFocusTarget) {
|
||||
const element = document.querySelector(target);
|
||||
if (element) {
|
||||
programmaticFocus(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
afterPageLoadFocusTarget = [];
|
||||
|
||||
// If there is some autofocus element, let the browser handle it
|
||||
const autofocus = document.querySelector("[autofocus]");
|
||||
if (autofocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is a toast as a result of some action, focus it
|
||||
const toast = document.querySelector(".toast");
|
||||
if (toast) {
|
||||
programmaticFocus(toast);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise go with main
|
||||
const main = document.querySelector("main");
|
||||
if (main) {
|
||||
programmaticFocus(main);
|
||||
}
|
||||
});
|
||||
91
bookmarks/frontend/behaviors/modal.js
Normal file
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/0043_userprofile_collapse_side_panel.py
Normal file
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),
|
||||
),
|
||||
]
|
||||
46
bookmarks/migrations/0044_bookmark_latest_snapshot.py
Normal file
46
bookmarks/migrations/0044_bookmark_latest_snapshot.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 12:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
# Update the latest snapshot for each bookmark
|
||||
Bookmark = apps.get_model("bookmarks", "bookmark")
|
||||
BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset")
|
||||
|
||||
latest_snapshots = (
|
||||
BookmarkAsset.objects.filter(
|
||||
bookmark=OuterRef("pk"), asset_type="snapshot", status="complete"
|
||||
)
|
||||
.order_by("-date_created")
|
||||
.values("id")[:1]
|
||||
)
|
||||
Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0043_userprofile_collapse_side_panel"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="latest_snapshot",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="latest_snapshot",
|
||||
to="bookmarks.bookmarkasset",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
||||
@@ -6,7 +6,6 @@ from typing import List
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
@@ -23,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
date_added = models.DateTimeField()
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -70,8 +69,15 @@ class Bookmark(models.Model):
|
||||
date_added = models.DateTimeField()
|
||||
date_modified = models.DateTimeField()
|
||||
date_accessed = models.DateTimeField(blank=True, null=True)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
tags = models.ManyToManyField(Tag)
|
||||
latest_snapshot = models.ForeignKey(
|
||||
"BookmarkAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="latest_snapshot",
|
||||
)
|
||||
|
||||
@property
|
||||
def resolved_title(self):
|
||||
@@ -93,6 +99,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"
|
||||
@@ -138,56 +157,6 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.initial.get("notes", None) or (
|
||||
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"
|
||||
SORT_ADDED_DESC = "added_desc"
|
||||
@@ -374,9 +343,7 @@ class UserProfile(models.Model):
|
||||
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||
]
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
|
||||
theme = models.CharField(
|
||||
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
|
||||
)
|
||||
@@ -440,6 +407,7 @@ 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:
|
||||
@@ -479,16 +447,17 @@ class UserProfileForm(forms.ModelForm):
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
]
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
||||
|
||||
@@ -497,7 +466,7 @@ class Toast(models.Model):
|
||||
key = models.CharField(max_length=50)
|
||||
message = models.TextField()
|
||||
acknowledged = models.BooleanField(default=False)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class FeedToken(models.Model):
|
||||
@@ -507,7 +476,7 @@ class FeedToken(models.Model):
|
||||
|
||||
key = models.CharField(max_length=40, primary_key=True)
|
||||
user = models.OneToOneField(
|
||||
get_user_model(),
|
||||
User,
|
||||
related_name="feed_token",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
@@ -541,7 +510,7 @@ class GlobalSettings(models.Model):
|
||||
default=LANDING_PAGE_LOGIN,
|
||||
)
|
||||
guest_profile_user = models.ForeignKey(
|
||||
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
enable_link_prefetch = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
155
bookmarks/services/assets.py
Normal file
155
bookmarks/services/assets.py
Normal file
@@ -0,0 +1,155 @@
|
||||
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()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.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()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.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 remove_asset(asset: BookmarkAsset):
|
||||
# If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot
|
||||
bookmark = asset.bookmark
|
||||
if bookmark and bookmark.latest_snapshot == asset:
|
||||
latest = (
|
||||
BookmarkAsset.objects.filter(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
.exclude(pk=asset.pk)
|
||||
.order_by("-date_created")
|
||||
.first()
|
||||
)
|
||||
|
||||
bookmark.latest_snapshot = latest
|
||||
bookmark.save()
|
||||
|
||||
asset.delete()
|
||||
|
||||
|
||||
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}"
|
||||
@@ -11,10 +11,18 @@ def get_tags(script: str, url: str):
|
||||
return result
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
if "#" in line:
|
||||
i = line.index("#")
|
||||
line = line[:i]
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines or lines that start with a comment
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Remove trailing comment - only if # is preceded by whitespace
|
||||
comment_match = re.search(r"\s+#", line)
|
||||
if comment_match:
|
||||
line = line[: comment_match.start()]
|
||||
|
||||
# Ignore lines that don't contain a URL and a tag
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
@@ -36,6 +44,11 @@ def get_tags(script: str, url: str):
|
||||
):
|
||||
continue
|
||||
|
||||
if parsed_pattern.fragment and not _fragment_matches(
|
||||
parsed_pattern.fragment, parsed_url.fragment
|
||||
):
|
||||
continue
|
||||
|
||||
for tag in parts[1:]:
|
||||
result.add(tag)
|
||||
|
||||
@@ -65,3 +78,7 @@ def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:
|
||||
return actual_fragment.startswith(expected_fragment)
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
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, User, 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 +43,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
|
||||
@@ -193,44 +197,15 @@ 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,
|
||||
def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
owned_bookmarks = Bookmark.objects.filter(
|
||||
owner=current_user, id__in=sanitized_bookmark_ids
|
||||
)
|
||||
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
|
||||
for bookmark in owned_bookmarks:
|
||||
tasks.refresh_metadata(bookmark)
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
|
||||
@@ -35,7 +35,7 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]"
|
||||
tag_names = bookmark.tag_names
|
||||
if bookmark.is_archived:
|
||||
tag_names.append("linkding:archived")
|
||||
tag_names.append("linkding:bookmarks.archived")
|
||||
tags = ",".join(tag_names)
|
||||
toread = "1" if bookmark.unread else "0"
|
||||
private = "0" if bookmark.shared else "1"
|
||||
|
||||
@@ -62,9 +62,9 @@ class BookmarkParser(HTMLParser):
|
||||
def handle_start_a(self, attrs: Dict[str, str]):
|
||||
vars(self).update(attrs)
|
||||
tag_names = parse_tag_string(self.tags)
|
||||
archived = "linkding:archived" in self.tags
|
||||
archived = "linkding:bookmarks.archived" in self.tags
|
||||
try:
|
||||
tag_names.remove("linkding:archived")
|
||||
tag_names.remove("linkding:bookmarks.archived")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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,22 +1,20 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import waybackpy
|
||||
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 django.utils import timezone
|
||||
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.website_loader import DEFAULT_USER_AGENT
|
||||
from bookmarks.services import assets, favicon_loader, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT, load_website_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,7 +157,7 @@ def schedule_bookmarks_without_favicons(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
@@ -175,7 +173,7 @@ def schedule_refresh_favicons(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_refresh_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(owner=user)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
@@ -214,7 +212,7 @@ def schedule_bookmarks_without_previews(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(
|
||||
Q(preview_image_file__exact=""),
|
||||
owner=user,
|
||||
@@ -228,6 +226,31 @@ def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
logging.exception(exc)
|
||||
|
||||
|
||||
def refresh_metadata(bookmark: Bookmark):
|
||||
if not settings.LD_DISABLE_BACKGROUND_TASKS:
|
||||
_refresh_metadata_task(bookmark.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _refresh_metadata_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f"Refresh metadata for bookmark. url={bookmark.url}")
|
||||
|
||||
metadata = load_website_metadata(bookmark.url)
|
||||
if metadata.title:
|
||||
bookmark.title = metadata.title
|
||||
if metadata.description:
|
||||
bookmark.description = metadata.description
|
||||
bookmark.date_modified = timezone.now()
|
||||
|
||||
bookmark.save()
|
||||
logger.info(f"Successfully refreshed metadata for bookmark. url={bookmark.url}")
|
||||
|
||||
|
||||
def is_html_snapshot_feature_active() -> bool:
|
||||
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
@@ -236,7 +259,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 +269,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 +301,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 +311,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:
|
||||
|
||||
@@ -27,10 +27,20 @@ class WebsiteMetadata:
|
||||
}
|
||||
|
||||
|
||||
def load_website_metadata(url: str, ignore_cache: bool = False):
|
||||
if ignore_cache:
|
||||
return _load_website_metadata(url)
|
||||
return _load_website_metadata_cached(url)
|
||||
|
||||
|
||||
# Caching metadata avoids scraping again when saving bookmarks, in case the
|
||||
# metadata was already scraped to show preview values in the bookmark form
|
||||
@lru_cache(maxsize=10)
|
||||
def load_website_metadata(url: str):
|
||||
def _load_website_metadata_cached(url: str):
|
||||
return _load_website_metadata(url)
|
||||
|
||||
|
||||
def _load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
|
||||
@@ -58,7 +58,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "siteroot.urls"
|
||||
ROOT_URLCONF = "bookmarks.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
@@ -80,7 +80,7 @@ TEMPLATES = [
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
WSGI_APPLICATION = "siteroot.wsgi.application"
|
||||
WSGI_APPLICATION = "bookmarks.wsgi.application"
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
@@ -131,7 +131,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
# REST framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
"bookmarks.api.auth.LinkdingTokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||
@@ -146,6 +146,7 @@ ALLOW_REGISTRATION = False
|
||||
LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -153,6 +154,7 @@ LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
|
||||
LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -179,7 +181,7 @@ HUEY = {
|
||||
|
||||
|
||||
# Enable OICD support if configured
|
||||
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1")
|
||||
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "true", "1")
|
||||
|
||||
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
|
||||
|
||||
@@ -194,11 +196,18 @@ if LD_ENABLE_OIDC:
|
||||
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
||||
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
|
||||
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "true", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "true", "1")
|
||||
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
|
||||
|
||||
# Enable authentication proxy support if configured
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
|
||||
"LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER"
|
||||
)
|
||||
@@ -265,6 +274,7 @@ LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")
|
||||
LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -286,6 +296,13 @@ LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
|
||||
LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
|
||||
2
bookmarks/static/robots.txt
Normal file
2
bookmarks/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.bookmarks-form-page {
|
||||
section {
|
||||
main {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -15,14 +15,23 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& .form-group .clear-button {
|
||||
display: none;
|
||||
& .form-group .suffix-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
height: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .form-group .clear-button,
|
||||
& .form-group #refresh-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .form-group input.modified,
|
||||
& .form-group textarea.modified {
|
||||
background: var(--primary-color-shade);
|
||||
}
|
||||
|
||||
& .form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: var(--warning-color);
|
||||
|
||||
@@ -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 {
|
||||
main {
|
||||
grid-column: span var(--grid-columns);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
@@ -429,7 +459,7 @@ ul.bookmark-list {
|
||||
|
||||
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
|
||||
|
||||
&.active section:first-of-type .content-area-header {
|
||||
&.active .main .section-header {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
/* Shared components */
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
/* Section header component */
|
||||
.section-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
section.content-area .content-area-header {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,20 @@ body {
|
||||
header {
|
||||
margin-bottom: var(--unit-9);
|
||||
|
||||
.logo {
|
||||
a.app-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 var(--unit-3);
|
||||
.app-name {
|
||||
margin-left: var(--unit-3);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--unit-10);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
--font-size-xl: 1rem;
|
||||
--line-height: 1rem;
|
||||
|
||||
/* Sizes */
|
||||
|
||||
@@ -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">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
<div class="section-header mb-0">
|
||||
<h1 id="main-heading">Archived bookmarks</h1>
|
||||
<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>
|
||||
|
||||
@@ -28,23 +28,27 @@
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% 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 class="side-panel col-1">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,157 +5,162 @@
|
||||
{% if bookmark_list.is_empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
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 %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
<section aria-label="Bookmark list">
|
||||
<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 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">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.snapshot_url %}
|
||||
<a href="{{ bookmark_item.snapshot_url }}"
|
||||
title="{{ bookmark_item.snapshot_title }}"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<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 #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images %}
|
||||
{% if bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% else %}
|
||||
<div class="preview-image placeholder">
|
||||
<div class="img"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images %}
|
||||
{% if bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% else %}
|
||||
<div class="preview-image placeholder">
|
||||
<div class="img"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
{% if asset.file %}
|
||||
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a>
|
||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||
{% endif %}
|
||||
{% if details.is_editable %}
|
||||
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
|
||||
@@ -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 %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span>{{ details.bookmark.url }}</span>
|
||||
</a>
|
||||
{% if details.latest_snapshot %}
|
||||
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}"
|
||||
<a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -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>
|
||||
<a href="{% url 'linkding: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">
|
||||
@@ -28,7 +24,7 @@
|
||||
<div class="actions">
|
||||
<div class="left-actions">
|
||||
<a class="btn btn-wide"
|
||||
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit bookmark - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,12 @@
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-between align-baseline">
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="btn btn-link clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
<div class="flex">
|
||||
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
|
||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
{{ form.title.errors }}
|
||||
@@ -43,7 +46,8 @@
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-between align-baseline">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
|
||||
<button ld-clear-button data-for="{{ form.description.id_for_label }}" class="btn btn-link clear-button"
|
||||
<button ld-clear-button data-for="{{ form.description.id_for_label }}"
|
||||
class="btn btn-link suffix-button clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,12 +95,12 @@
|
||||
{% endif %}
|
||||
<div class="divider"></div>
|
||||
<div class="form-group d-flex justify-between">
|
||||
{% if auto_close %}
|
||||
{% if form.is_auto_close %}
|
||||
<input type="submit" value="Save and close" class="btn btn-primary btn-wide">
|
||||
{% else %}
|
||||
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||
<a href="{{ return_url }}" class="btn">Nevermind</a>
|
||||
</div>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
@@ -111,8 +115,9 @@
|
||||
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
|
||||
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
const editedBookmarkId = {{ bookmark_id }};
|
||||
const editedBookmarkId = {{ form.instance.id|default:0 }};
|
||||
let isTitleModified = !!titleInput.value;
|
||||
let isDescriptionModified = !!descriptionInput.value;
|
||||
|
||||
@@ -144,7 +149,7 @@
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@@ -154,6 +159,7 @@
|
||||
// Display hint if URL is already bookmarked
|
||||
const existingBookmark = data.bookmark;
|
||||
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
|
||||
refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';
|
||||
|
||||
// Prefill form with existing bookmark data
|
||||
if (existingBookmark) {
|
||||
@@ -193,6 +199,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
function refreshMetadata() {
|
||||
if (!urlInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;
|
||||
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata;
|
||||
const existingBookmark = data.bookmark;
|
||||
toggleLoadingIcon(urlInput, false);
|
||||
|
||||
if (metadata.title && metadata.title !== existingBookmark?.title) {
|
||||
titleInput.value = metadata.title;
|
||||
titleInput.classList.add("modified");
|
||||
}
|
||||
|
||||
if (metadata.description && metadata.description !== existingBookmark?.description) {
|
||||
descriptionInput.value = metadata.description;
|
||||
descriptionInput.classList.add("modified");
|
||||
}
|
||||
});
|
||||
}
|
||||
refreshButton.addEventListener('click', refreshMetadata);
|
||||
|
||||
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
||||
if (!editedBookmarkId) {
|
||||
checkUrl();
|
||||
@@ -203,6 +239,8 @@
|
||||
descriptionInput.addEventListener('input', () => {
|
||||
isDescriptionModified = true;
|
||||
});
|
||||
} else {
|
||||
refreshButton.style['display'] = 'inline-block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
|
||||
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
|
||||
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||
<link rel="manifest" href="{% url 'linkding:manifest' %}">
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="Linkding" href="{% url 'linkding:opensearch' %}"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
<meta name="description" content="Self-hosted bookmark service">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="author" content="Sascha Ißbrücker">
|
||||
<title>linkding</title>
|
||||
<title>{{ page_title|default:'Linkding' }}</title>
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
@@ -30,11 +31,14 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% endif %}
|
||||
{% if request.user_profile.custom_css %}
|
||||
<link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% url 'linkding: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>
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
{% load shared %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block title %}Bookmarks - Linkding{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
<div class="section-header mb-0">
|
||||
<h1 id="main-heading">Bookmarks</h1>
|
||||
<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>
|
||||
|
||||
@@ -27,23 +30,27 @@
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% 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 class="side-panel col-1">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
{# Use data attributes as storage for access in static scripts #}
|
||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||
{% include 'bookmarks/head.html' %}
|
||||
<html lang="en" data-api-base-url="{% url 'linkding:api-root' %}">
|
||||
{% block head %}{% include 'bookmarks/head.html' %}{% endblock %}
|
||||
<body ld-global-shortcuts>
|
||||
|
||||
<div class="d-none">
|
||||
@@ -68,7 +68,7 @@
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts">
|
||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
<div class="toast d-flex">
|
||||
@@ -80,22 +80,28 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-between">
|
||||
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>LINKDING</h1>
|
||||
<a href="{% url 'linkding:root' %}" class="app-link d-flex align-center">
|
||||
<img class="app-logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<span class="app-name">LINKDING</span>
|
||||
</a>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% else %}
|
||||
{# Otherwise show login link #}
|
||||
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||
{% endif %}
|
||||
<nav>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% else %}
|
||||
{# Otherwise show login link #}
|
||||
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="modals">
|
||||
{% block overlays %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,77 +2,103 @@
|
||||
{% htmlmin %}
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div class="dropdown">
|
||||
<a href="{% url 'linkding:bookmarks.new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<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>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Active</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
<div ld-dropdown class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Settings
|
||||
</button>
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:settings.general' %}" class="menu-link">General</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<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 'linkding: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>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Bookmarks</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived bookmarks</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared bookmarks</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
<div class="divider"></div>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
||||
<a href="{% url 'linkding:settings.general' %}" class="menu-link">Settings</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<div class="divider"></div>
|
||||
<li class="menu-item">
|
||||
<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>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="New bookmark - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">
|
||||
<div class="content-area-header">
|
||||
<h2>Shared bookmarks</h2>
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Shared bookmarks</h1>
|
||||
<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>
|
||||
|
||||
@@ -25,30 +25,36 @@
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# Filters #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>User</h2>
|
||||
</div>
|
||||
<div>
|
||||
{% user_select bookmark_list.search users %}
|
||||
<br>
|
||||
</div>
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% 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 class="side-panel col-1">
|
||||
<section aria-labelledby="user-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="user-heading">User</h2>
|
||||
</div>
|
||||
<div>
|
||||
{% user_select bookmark_list.search users %}
|
||||
<br>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Registration complete{% endblock %}
|
||||
{% block head %}
|
||||
{% with page_title="Registration complete - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Registration complete. You can now use the application.</p>
|
||||
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Registration complete</h1>
|
||||
</div>
|
||||
<p class="text-success">
|
||||
You can now use the application.
|
||||
</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Registration{% endblock %}
|
||||
{% block head %}
|
||||
{% with page_title="Registration - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Register</h2>
|
||||
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Register</h1>
|
||||
</div>
|
||||
<form method="post" action="{% url 'django_registration_register' %}" novalidate>
|
||||
{% csrf_token %}
|
||||
@@ -34,5 +38,5 @@
|
||||
<input type="submit" value="Register" class="btn btn-primary btn-wide">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
7
bookmarks/templates/opensearch.xml
Normal file
7
bookmarks/templates/opensearch.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
|
||||
<ShortName>Linkding</ShortName>
|
||||
<Description>Linkding</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Image width="16" height="16" type="image/x-icon">{{base_url}}static/favicon.ico</Image>
|
||||
<Url type="text/html" template="{{ bookmarks_url }}?client=opensearch&q={searchTerms}"/>
|
||||
</OpenSearchDescription>
|
||||
@@ -1,12 +1,16 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block head %}
|
||||
{% with page_title="Login - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Login</h2>
|
||||
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Login</h1>
|
||||
</div>
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
@@ -29,12 +33,12 @@
|
||||
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
|
||||
<input type="hidden" name="next" value="{{ next }}"/>
|
||||
{% if enable_oidc %}
|
||||
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
|
||||
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}" data-turbo="false">Login with OIDC</a>
|
||||
{% endif %}
|
||||
{% if allow_registration %}
|
||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Password changed{% endblock %}
|
||||
{% block head %}
|
||||
{% with page_title="Password changed - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Password Changed</h2>
|
||||
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Password Changed</h1>
|
||||
</div>
|
||||
<p class="text-success">
|
||||
Your password was changed successfully.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
{% block head %}
|
||||
{% with page_title="Change password - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Change Password</h2>
|
||||
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Change Password</h1>
|
||||
</div>
|
||||
<form method="post" action="{% url 'change_password' %}">
|
||||
{% csrf_token %}
|
||||
@@ -41,5 +45,5 @@
|
||||
<br/>
|
||||
<input type="submit" value="Change Password" class="btn btn-primary btn-wide">
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
{% block head %}
|
||||
{% with page_title="Settings - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
{% block content %}
|
||||
<main class="settings-page" aria-labelledby="main-heading">
|
||||
<h1 id="main-heading">Settings</h1>
|
||||
|
||||
{# Profile section #}
|
||||
{% if success_message %}
|
||||
@@ -14,12 +19,12 @@
|
||||
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="content-area">
|
||||
<h2>Profile</h2>
|
||||
<section aria-labelledby="profile-heading">
|
||||
<h2 id="profile-heading">Profile</h2>
|
||||
<p>
|
||||
<a href="{% url 'change_password' %}">Change password</a>
|
||||
</p>
|
||||
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||
<form action="{% url 'linkding:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||
@@ -124,6 +129,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" }}
|
||||
@@ -219,7 +234,7 @@ reddit.com/r/Music music reddit</pre>
|
||||
<div class="form-input-hint">
|
||||
Makes shared bookmarks publicly accessible, without requiring a login.
|
||||
That means that anyone with a link to this instance can view shared bookmarks via the <a
|
||||
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
|
||||
href="{% url 'linkding:bookmarks.shared' %}">shared bookmarks page</a>.
|
||||
</div>
|
||||
</div>
|
||||
{% if has_snapshot_support %}
|
||||
@@ -268,9 +283,9 @@ reddit.com/r/Music music reddit</pre>
|
||||
|
||||
{# Global settings section #}
|
||||
{% if global_settings_form %}
|
||||
<section class="content-area">
|
||||
<h2>Global settings</h2>
|
||||
<form action="{% url 'bookmarks:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||
<section aria-labelledby="global-settings-heading">
|
||||
<h2 id="global-settings-heading">Global settings</h2>
|
||||
<form action="{% url 'linkding:settings.update' %}" method="post" novalidate data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
|
||||
@@ -308,11 +323,11 @@ reddit.com/r/Music music reddit</pre>
|
||||
{% endif %}
|
||||
|
||||
{# Import section #}
|
||||
<section class="content-area">
|
||||
<h2>Import</h2>
|
||||
<section aria-labelledby="import-heading">
|
||||
<h2 id="import-heading">Import</h2>
|
||||
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
|
||||
added and existing ones are updated.</p>
|
||||
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
|
||||
<form method="post" enctype="multipart/form-data" action="{% url 'linkding:settings.import' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="import_map_private_flag" class="form-checkbox">
|
||||
@@ -336,10 +351,10 @@ reddit.com/r/Music music reddit</pre>
|
||||
</section>
|
||||
|
||||
{# Export section #}
|
||||
<section class="content-area">
|
||||
<h2>Export</h2>
|
||||
<section aria-labelledby="export-heading">
|
||||
<h2 id="export-heading">Export</h2>
|
||||
<p>Export all bookmarks in Netscape HTML format.</p>
|
||||
<a class="btn btn-primary" target="_blank" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||
<a class="btn btn-primary" target="_blank" href="{% url 'linkding:settings.export' %}">Download (.html)</a>
|
||||
{% if export_error %}
|
||||
<div class="has-error">
|
||||
<p class="form-input-hint">
|
||||
@@ -350,8 +365,8 @@ reddit.com/r/Music music reddit</pre>
|
||||
</section>
|
||||
|
||||
{# About section #}
|
||||
<section class="content-area about">
|
||||
<h2>About</h2>
|
||||
<section class="about" aria-labelledby="about-heading">
|
||||
<h2 id="about-heading">About</h2>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -374,7 +389,7 @@ reddit.com/r/Music music reddit</pre>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Integrations - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
<main class="settings-page" aria-labelledby="main-heading">
|
||||
<h1 id="main-heading">Integrations</h1>
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
<section class="content-area">
|
||||
<h2>Browser Extension</h2>
|
||||
<section aria-labelledby="browser-extension-heading">
|
||||
<h2 id="browser-extension-heading">Browser Extension</h2>
|
||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
|
||||
extension is available in the official extension stores for:</p>
|
||||
<ul>
|
||||
@@ -31,8 +36,8 @@
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
|
||||
<section class="content-area">
|
||||
<h2>REST API</h2>
|
||||
<section aria-labelledby="rest-api-heading">
|
||||
<h2 id="rest-api-heading">REST API</h2>
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="width-50 width-md-100">
|
||||
@@ -48,8 +53,8 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="content-area">
|
||||
<h2>RSS Feeds</h2>
|
||||
<section aria-labelledby="rss-feeds-heading">
|
||||
<h2 id="rss-feeds-heading">RSS Feeds</h2>
|
||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||
<ul style="list-style-position: outside;">
|
||||
<li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||
@@ -84,5 +89,5 @@
|
||||
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{% url 'bookmarks:settings.index' as index_url %}
|
||||
{% url 'bookmarks:settings.general' as general_url %}
|
||||
{% url 'bookmarks:settings.integrations' as integrations_url %}
|
||||
|
||||
<ul class="tab tab-block">
|
||||
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == general_url%}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.general' %}">General</a>
|
||||
</li>
|
||||
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="tab-item">
|
||||
<a href="{% url 'admin:index' %}" target="_blank">
|
||||
<span>Admin</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<br>
|
||||
@@ -3,7 +3,6 @@ from typing import List
|
||||
from django import template
|
||||
|
||||
from bookmarks.models import (
|
||||
BookmarkForm,
|
||||
BookmarkSearch,
|
||||
BookmarkSearchForm,
|
||||
User,
|
||||
@@ -12,23 +11,6 @@ from bookmarks.models import (
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag("bookmarks/form.html", name="bookmark_form", takes_context=True)
|
||||
def bookmark_form(
|
||||
context,
|
||||
form: BookmarkForm,
|
||||
cancel_url: str,
|
||||
bookmark_id: int = 0,
|
||||
auto_close: bool = False,
|
||||
):
|
||||
return {
|
||||
"request": context["request"],
|
||||
"form": form,
|
||||
"auto_close": auto_close,
|
||||
"bookmark_id": bookmark_id,
|
||||
"cancel_url": cancel_url,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag(
|
||||
"bookmarks/search.html", name="bookmark_search", takes_context=True
|
||||
)
|
||||
|
||||
@@ -142,5 +142,6 @@ def render_markdown(context, markdown_text):
|
||||
|
||||
as_html = renderer.convert(markdown_text)
|
||||
sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)
|
||||
linkified_html = bleach.linkify(sanitized_html)
|
||||
|
||||
return mark_safe(sanitized_html)
|
||||
return mark_safe(linkified_html)
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
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.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
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
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
|
||||
|
||||
|
||||
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(
|
||||
@@ -182,6 +198,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()
|
||||
@@ -290,6 +324,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)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.test import TestCase
|
||||
|
||||
class AppOptionsTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.settings_module = importlib.import_module("siteroot.settings.base")
|
||||
self.settings_module = importlib.import_module("bookmarks.settings.base")
|
||||
|
||||
def test_empty_csrf_trusted_origins(self):
|
||||
module = importlib.reload(self.settings_module)
|
||||
|
||||
411
bookmarks/tests/test_assets_service.py
Normal file
411
bookmarks/tests/test_assets_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import datetime
|
||||
import gzip
|
||||
import os
|
||||
from datetime import timedelta
|
||||
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())
|
||||
|
||||
def test_create_snapshot_updates_bookmark_latest_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
first_asset = assets.create_snapshot_asset(bookmark)
|
||||
first_asset.save()
|
||||
|
||||
assets.create_snapshot(first_asset)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.latest_snapshot, first_asset)
|
||||
|
||||
second_asset = assets.create_snapshot_asset(bookmark)
|
||||
second_asset.save()
|
||||
|
||||
assets.create_snapshot(second_asset)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.latest_snapshot, second_asset)
|
||||
|
||||
def test_upload_snapshot_updates_bookmark_latest_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
first_asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.latest_snapshot, first_asset)
|
||||
|
||||
second_asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.latest_snapshot, second_asset)
|
||||
self.assertNotEqual(bookmark.latest_snapshot, first_asset)
|
||||
|
||||
def test_create_snapshot_failure_does_not_update_latest_snapshot(self):
|
||||
# Create a bookmark with an existing latest_snapshot
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
|
||||
|
||||
# Create a new snapshot asset that will fail
|
||||
failing_asset = assets.create_snapshot_asset(bookmark)
|
||||
failing_asset.save()
|
||||
|
||||
# Make the snapshot creation fail
|
||||
self.mock_singlefile_create_snapshot.side_effect = Exception(
|
||||
"Snapshot creation failed"
|
||||
)
|
||||
|
||||
# Attempt to create a snapshot (which will fail)
|
||||
with self.assertRaises(Exception):
|
||||
assets.create_snapshot(failing_asset)
|
||||
|
||||
# Verify that the bookmark's latest_snapshot is still the initial snapshot
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
|
||||
|
||||
def test_upload_snapshot_failure_does_not_update_latest_snapshot(self):
|
||||
# Create a bookmark with an existing latest_snapshot
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
initial_snapshot = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
|
||||
|
||||
# Make the gzip.open function fail
|
||||
with mock.patch("gzip.open") as mock_gzip_open:
|
||||
mock_gzip_open.side_effect = Exception("Upload failed")
|
||||
|
||||
# Attempt to upload a snapshot (which will fail)
|
||||
with self.assertRaises(Exception):
|
||||
assets.upload_snapshot(bookmark, b"New content")
|
||||
|
||||
# Verify that the bookmark's latest_snapshot is still the initial snapshot
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.latest_snapshot, initial_snapshot)
|
||||
|
||||
def test_remove_latest_snapshot_updates_bookmark(self):
|
||||
# Create a bookmark with multiple snapshots
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# Create base time (1 hour ago)
|
||||
base_time = timezone.now() - timedelta(hours=1)
|
||||
|
||||
# Create three snapshots with explicitly different dates
|
||||
old_asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
file="old_snapshot.html.gz",
|
||||
date_created=base_time,
|
||||
)
|
||||
self.setup_asset_file(old_asset)
|
||||
|
||||
middle_asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
file="middle_snapshot.html.gz",
|
||||
date_created=base_time + timedelta(minutes=30),
|
||||
)
|
||||
self.setup_asset_file(middle_asset)
|
||||
|
||||
latest_asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
file="latest_snapshot.html.gz",
|
||||
date_created=base_time + timedelta(minutes=60),
|
||||
)
|
||||
self.setup_asset_file(latest_asset)
|
||||
|
||||
# Set the latest asset as the bookmark's latest_snapshot
|
||||
bookmark.latest_snapshot = latest_asset
|
||||
bookmark.save()
|
||||
|
||||
# Delete the latest snapshot
|
||||
assets.remove_asset(latest_asset)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
# Verify that middle_asset is now the latest_snapshot
|
||||
self.assertEqual(bookmark.latest_snapshot, middle_asset)
|
||||
|
||||
# Delete the middle snapshot
|
||||
assets.remove_asset(middle_asset)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
# Verify that old_asset is now the latest_snapshot
|
||||
self.assertEqual(bookmark.latest_snapshot, old_asset)
|
||||
|
||||
# Delete the last snapshot
|
||||
assets.remove_asset(old_asset)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
# Verify that latest_snapshot is now None
|
||||
self.assertIsNone(bookmark.latest_snapshot)
|
||||
|
||||
def test_remove_non_latest_snapshot_does_not_affect_bookmark(self):
|
||||
# Create a bookmark with multiple snapshots
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# Create base time (1 hour ago)
|
||||
base_time = timezone.now() - timedelta(hours=1)
|
||||
|
||||
# Create two snapshots with explicitly different dates
|
||||
old_asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
file="old_snapshot.html.gz",
|
||||
date_created=base_time,
|
||||
)
|
||||
self.setup_asset_file(old_asset)
|
||||
|
||||
latest_asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
file="latest_snapshot.html.gz",
|
||||
date_created=base_time + timedelta(minutes=30),
|
||||
)
|
||||
self.setup_asset_file(latest_asset)
|
||||
|
||||
# Set the latest asset as the bookmark's latest_snapshot
|
||||
bookmark.latest_snapshot = latest_asset
|
||||
bookmark.save()
|
||||
|
||||
# Delete the old snapshot (not the latest)
|
||||
assets.remove_asset(old_asset)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
# Verify that latest_snapshot hasn't changed
|
||||
self.assertEqual(bookmark.latest_snapshot, latest_asset)
|
||||
32
bookmarks/tests/test_auth_api.py
Normal file
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("linkding:user-profile")
|
||||
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_auth_with_bearer_keyword(self):
|
||||
self.authenticate("Bearer")
|
||||
|
||||
url = reverse("linkding:user-profile")
|
||||
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_auth_with_unknown_keyword(self):
|
||||
self.authenticate("Key")
|
||||
|
||||
url = reverse("linkding:user-profile")
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -21,7 +21,7 @@ class AuthProxySupportTest(TestCase):
|
||||
)
|
||||
|
||||
headers = {"REMOTE_USER": user.username}
|
||||
response = self.client.get(reverse("bookmarks:index"), **headers)
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"), **headers)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -43,7 +43,7 @@ class AuthProxySupportTest(TestCase):
|
||||
)
|
||||
|
||||
headers = {"Custom-User": user.username}
|
||||
response = self.client.get(reverse("bookmarks:index"), **headers)
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"), **headers)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -53,6 +53,8 @@ class AuthProxySupportTest(TestCase):
|
||||
)
|
||||
|
||||
headers = {"REMOTE_USER": user.username}
|
||||
response = self.client.get(reverse("bookmarks:index"), **headers, follow=True)
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.index"), **headers, follow=True
|
||||
)
|
||||
|
||||
self.assertRedirects(response, "/login/?next=%2Fbookmarks")
|
||||
|
||||
@@ -202,3 +202,44 @@ class AutoTaggingTestCase(TestCase):
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"tag1", "tag2"})
|
||||
|
||||
def test_auto_tag_with_url_fragment(self):
|
||||
script = """
|
||||
example.com/#/section/1 section1
|
||||
example.com/#/section/2 section2
|
||||
"""
|
||||
url = "https://example.com/#/section/1"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"section1"})
|
||||
|
||||
def test_auto_tag_with_url_fragment_partial_match(self):
|
||||
script = """
|
||||
example.com/#/section section
|
||||
"""
|
||||
url = "https://example.com/#/section/1"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"section"})
|
||||
|
||||
def test_auto_tag_with_url_fragment_ignores_case(self):
|
||||
script = """
|
||||
example.com/#SECTION section
|
||||
"""
|
||||
url = "https://example.com/#section"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"section"})
|
||||
|
||||
def test_auto_tag_with_url_fragment_and_comment(self):
|
||||
script = """
|
||||
example.com/#section1 section1 #This is a comment
|
||||
"""
|
||||
url = "https://example.com/#section1"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, {"section1"})
|
||||
|
||||
@@ -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,
|
||||
@@ -37,7 +37,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"archive": [bookmark.id],
|
||||
},
|
||||
@@ -54,7 +54,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"archive": [bookmark.id],
|
||||
},
|
||||
@@ -69,7 +69,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"unarchive": [bookmark.id],
|
||||
},
|
||||
@@ -85,7 +85,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"unarchive": [bookmark.id],
|
||||
},
|
||||
@@ -99,7 +99,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"remove": [bookmark.id],
|
||||
},
|
||||
@@ -114,7 +114,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"remove": [bookmark.id],
|
||||
},
|
||||
@@ -126,7 +126,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"mark_as_read": [bookmark.id],
|
||||
},
|
||||
@@ -139,7 +139,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"unshare": [bookmark.id],
|
||||
},
|
||||
@@ -156,7 +156,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"unshare": [bookmark.id],
|
||||
},
|
||||
@@ -172,7 +172,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark()
|
||||
with patch.object(tasks, "_create_html_snapshot_task"):
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"create_html_snapshot": [bookmark.id],
|
||||
},
|
||||
@@ -187,7 +187,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
with patch.object(tasks, "_create_html_snapshot_task"):
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"create_html_snapshot": [bookmark.id],
|
||||
},
|
||||
@@ -200,9 +200,9 @@ 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"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -221,21 +221,42 @@ 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"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
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("linkding: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("linkding: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)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
|
||||
reverse("linkding:bookmarks.index.action"), {"remove_asset": asset.id}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
@@ -246,7 +267,7 @@ class BookmarkActionViewTestCase(
|
||||
asset = self.setup_asset(bookmark)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"), {"remove_asset": asset.id}
|
||||
reverse("linkding:bookmarks.index.action"), {"remove_asset": asset.id}
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
@@ -255,7 +276,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"update_state": bookmark.id,
|
||||
"is_archived": "on",
|
||||
@@ -275,7 +296,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"update_state": bookmark.id,
|
||||
"is_archived": "on",
|
||||
@@ -296,7 +317,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -321,7 +342,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -343,7 +364,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
reverse("linkding:bookmarks.archived.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unarchive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -368,7 +389,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
reverse("linkding:bookmarks.archived.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unarchive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -390,7 +411,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -415,7 +436,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -439,7 +460,7 @@ class BookmarkActionViewTestCase(
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_tag"],
|
||||
"bulk_execute": [""],
|
||||
@@ -471,7 +492,7 @@ class BookmarkActionViewTestCase(
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_tag"],
|
||||
"bulk_execute": [""],
|
||||
@@ -500,7 +521,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_untag"],
|
||||
"bulk_execute": [""],
|
||||
@@ -532,7 +553,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_untag"],
|
||||
"bulk_execute": [""],
|
||||
@@ -559,7 +580,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_read"],
|
||||
"bulk_execute": [""],
|
||||
@@ -584,7 +605,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_read"],
|
||||
"bulk_execute": [""],
|
||||
@@ -606,7 +627,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unread"],
|
||||
"bulk_execute": [""],
|
||||
@@ -631,7 +652,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(unread=False, user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unread"],
|
||||
"bulk_execute": [""],
|
||||
@@ -653,7 +674,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_share"],
|
||||
"bulk_execute": [""],
|
||||
@@ -678,7 +699,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(shared=False, user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_share"],
|
||||
"bulk_execute": [""],
|
||||
@@ -700,7 +721,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unshare"],
|
||||
"bulk_execute": [""],
|
||||
@@ -725,7 +746,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unshare"],
|
||||
"bulk_execute": [""],
|
||||
@@ -747,7 +768,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -763,7 +784,7 @@ class BookmarkActionViewTestCase(
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action") + "?page=2",
|
||||
reverse("linkding:bookmarks.index.action") + "?page=2",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -792,7 +813,7 @@ class BookmarkActionViewTestCase(
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title="Bookmark 3").first())
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -812,7 +833,7 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action") + "?q=foo",
|
||||
reverse("linkding:bookmarks.index.action") + "?q=foo",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -837,7 +858,7 @@ class BookmarkActionViewTestCase(
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
reverse("linkding:bookmarks.archived.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -857,7 +878,7 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action") + "?q=foo",
|
||||
reverse("linkding:bookmarks.archived.action") + "?q=foo",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -872,7 +893,7 @@ class BookmarkActionViewTestCase(
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:shared.action"),
|
||||
reverse("linkding:bookmarks.shared.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
@@ -887,7 +908,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -896,7 +917,7 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
@@ -913,7 +934,7 @@ class BookmarkActionViewTestCase(
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
{
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
@@ -926,22 +947,22 @@ class BookmarkActionViewTestCase(
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
def test_index_action_redirects_to_index_with_query_params(self):
|
||||
url = reverse("bookmarks:index.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("bookmarks:index") + "?q=foo&page=2"
|
||||
url = reverse("linkding:bookmarks.index.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("linkding:bookmarks.index") + "?q=foo&page=2"
|
||||
response = self.client.post(url)
|
||||
|
||||
self.assertRedirects(response, redirect_url)
|
||||
|
||||
def test_archived_action_redirects_to_archived_with_query_params(self):
|
||||
url = reverse("bookmarks:archived.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("bookmarks:archived") + "?q=foo&page=2"
|
||||
url = reverse("linkding:bookmarks.archived.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("linkding:bookmarks.archived") + "?q=foo&page=2"
|
||||
response = self.client.post(url)
|
||||
|
||||
self.assertRedirects(response, redirect_url)
|
||||
|
||||
def test_shared_action_redirects_to_shared_with_query_params(self):
|
||||
url = reverse("bookmarks:shared.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("bookmarks:shared") + "?q=foo&page=2"
|
||||
url = reverse("linkding:bookmarks.shared.action") + "?q=foo&page=2"
|
||||
redirect_url = reverse("linkding:bookmarks.shared") + "?q=foo&page=2"
|
||||
response = self.client.post(url)
|
||||
|
||||
self.assertRedirects(response, redirect_url)
|
||||
@@ -991,7 +1012,7 @@ class BookmarkActionViewTestCase(
|
||||
def test_index_action_with_turbo_returns_bookmark_update(self):
|
||||
fixture = self.bookmark_update_fixture()
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
reverse("linkding:bookmarks.index.action"),
|
||||
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||
)
|
||||
|
||||
@@ -1009,7 +1030,7 @@ class BookmarkActionViewTestCase(
|
||||
def test_archived_action_with_turbo_returns_bookmark_update(self):
|
||||
fixture = self.bookmark_update_fixture()
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
reverse("linkding:bookmarks.archived.action"),
|
||||
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||
)
|
||||
|
||||
@@ -1027,7 +1048,7 @@ class BookmarkActionViewTestCase(
|
||||
def test_shared_action_with_turbo_returns_bookmark_update(self):
|
||||
fixture = self.bookmark_update_fixture()
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:shared.action"),
|
||||
reverse("linkding:bookmarks.shared.action"),
|
||||
HTTP_ACCEPT="text/vnd.turbo-stream.html",
|
||||
)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class BookmarkArchivedViewTestCase(
|
||||
self.setup_bookmark(is_archived=True, user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -59,7 +59,7 @@ class BookmarkArchivedViewTestCase(
|
||||
3, prefix="bar", archived=True
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived") + "?q=foo")
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
@@ -84,7 +84,7 @@ class BookmarkArchivedViewTestCase(
|
||||
unarchived_bookmarks + other_user_bookmarks
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -100,7 +100,7 @@ class BookmarkArchivedViewTestCase(
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived") + "?q=foo")
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -132,7 +132,7 @@ class BookmarkArchivedViewTestCase(
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
@@ -149,7 +149,8 @@ class BookmarkArchivedViewTestCase(
|
||||
self.setup_bookmark(is_archived=True, tags=tags)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:archived") + f"?q=%23{tags[0].name}+%23{tags[1].name}"
|
||||
reverse("linkding:bookmarks.archived")
|
||||
+ f"?q=%23{tags[0].name}+%23{tags[1].name}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
@@ -167,7 +168,7 @@ class BookmarkArchivedViewTestCase(
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:archived")
|
||||
reverse("linkding:bookmarks.archived")
|
||||
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
@@ -187,7 +188,7 @@ class BookmarkArchivedViewTestCase(
|
||||
self.setup_bookmark(tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:archived")
|
||||
reverse("linkding:bookmarks.archived")
|
||||
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
@@ -196,7 +197,7 @@ class BookmarkArchivedViewTestCase(
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
|
||||
|
||||
@@ -207,14 +208,14 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
|
||||
|
||||
def test_edit_link_return_url_respects_search_options(self):
|
||||
bookmark = self.setup_bookmark(title="foo", is_archived=True)
|
||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||
base_url = reverse("bookmarks:archived")
|
||||
edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
base_url = reverse("linkding:bookmarks.archived")
|
||||
|
||||
# without query params
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
@@ -240,8 +241,8 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
def test_bulk_edit_respects_search_options(self):
|
||||
action_url = reverse("bookmarks:archived.action")
|
||||
base_url = reverse("bookmarks:archived")
|
||||
action_url = reverse("linkding:bookmarks.archived.action")
|
||||
base_url = reverse("linkding:bookmarks.archived")
|
||||
|
||||
# without params
|
||||
url = f"{action_url}"
|
||||
@@ -264,7 +265,7 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse("bookmarks:archived")
|
||||
url = reverse("linkding:bookmarks.archived")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
@@ -277,6 +278,7 @@ class BookmarkArchivedViewTestCase(
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
@@ -287,7 +289,7 @@ class BookmarkArchivedViewTestCase(
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse("bookmarks:archived")
|
||||
url = reverse("linkding:bookmarks.archived")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
@@ -302,6 +304,7 @@ class BookmarkArchivedViewTestCase(
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
@@ -309,13 +312,13 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
def test_apply_search_preferences(self):
|
||||
# no params
|
||||
response = self.client.post(reverse("bookmarks:archived"))
|
||||
response = self.client.post(reverse("linkding:bookmarks.archived"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("bookmarks:archived"))
|
||||
self.assertEqual(response.url, reverse("linkding:bookmarks.archived"))
|
||||
|
||||
# some params
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"q": "foo",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -323,12 +326,13 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:archived") + "?q=foo&sort=title_asc"
|
||||
response.url,
|
||||
reverse("linkding:bookmarks.archived") + "?q=foo&sort=title_asc",
|
||||
)
|
||||
|
||||
# params with default value are removed
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"q": "foo",
|
||||
"user": "",
|
||||
@@ -339,12 +343,12 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:archived") + "?q=foo&unread=yes"
|
||||
response.url, reverse("linkding:bookmarks.archived") + "?q=foo&unread=yes"
|
||||
)
|
||||
|
||||
# page is removed
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"q": "foo",
|
||||
"page": "2",
|
||||
@@ -353,7 +357,8 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:archived") + "?q=foo&sort=title_asc"
|
||||
response.url,
|
||||
reverse("linkding:bookmarks.archived") + "?q=foo&sort=title_asc",
|
||||
)
|
||||
|
||||
def test_save_search_preferences(self):
|
||||
@@ -361,7 +366,7 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
# no params
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"save": "",
|
||||
},
|
||||
@@ -378,7 +383,7 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
# with param
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -396,7 +401,7 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
# add a param
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -415,7 +420,7 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
# remove a param
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"save": "",
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
@@ -433,7 +438,7 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
# ignores non-preferences
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
reverse("linkding:bookmarks.archived"),
|
||||
{
|
||||
"save": "",
|
||||
"q": "foo",
|
||||
@@ -453,7 +458,7 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
|
||||
def test_url_encode_bookmark_actions_url(self):
|
||||
url = reverse("bookmarks:archived") + "?q=%23foo"
|
||||
url = reverse("linkding:bookmarks.archived") + "?q=%23foo"
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
@@ -467,34 +472,34 @@ class BookmarkArchivedViewTestCase(
|
||||
def test_encode_search_params(self):
|
||||
bookmark = self.setup_bookmark(description="alert('xss')", is_archived=True)
|
||||
|
||||
url = reverse("bookmarks:archived") + "?q=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.archived") + "?q=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse("bookmarks:archived") + "?sort=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.archived") + "?sort=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:archived") + "?unread=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.archived") + "?unread=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:archived") + "?shared=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.archived") + "?shared=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:archived") + "?user=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.archived") + "?user=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.archived") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
def test_turbo_frame_details_modal_renders_details_modal_update(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse("bookmarks:archived") + f"?bookmark_id={bookmark.id}"
|
||||
url = reverse("linkding:bookmarks.archived") + f"?bookmark_id={bookmark.id}"
|
||||
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
@@ -503,3 +508,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("linkding: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:
|
||||
@@ -31,10 +31,11 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
# capture number of queries
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
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
|
||||
|
||||
@@ -45,9 +46,10 @@ 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,
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
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")
|
||||
@@ -125,13 +117,13 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_view_access(self):
|
||||
self.view_access_test("bookmarks:assets.view")
|
||||
self.view_access_test("linkding:assets.view")
|
||||
|
||||
def test_view_access_guest_user(self):
|
||||
self.view_access_guest_user_test("bookmarks:assets.view")
|
||||
self.view_access_guest_user_test("linkding:assets.view")
|
||||
|
||||
def test_reader_view_access(self):
|
||||
self.view_access_test("bookmarks:assets.read")
|
||||
self.view_access_test("linkding:assets.read")
|
||||
|
||||
def test_reader_view_access_guest_user(self):
|
||||
self.view_access_guest_user_test("bookmarks:assets.read")
|
||||
self.view_access_guest_user_test("linkding:assets.read")
|
||||
|
||||
@@ -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
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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding: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(
|
||||
"linkding:bookmark_asset-detail",
|
||||
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
|
||||
)
|
||||
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -15,32 +15,36 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.client.force_login(user)
|
||||
|
||||
def get_details_form(self, soup, bookmark):
|
||||
form_url = reverse("bookmarks:index.action") + f"?details={bookmark.id}"
|
||||
form_url = (
|
||||
reverse("linkding:bookmarks.index.action") + f"?details={bookmark.id}"
|
||||
)
|
||||
return soup.find("form", {"action": form_url, "enctype": "multipart/form-data"})
|
||||
|
||||
def get_index_details_modal(self, bookmark):
|
||||
url = reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
url = reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
|
||||
response = self.client.get(url)
|
||||
soup = self.make_soup(response.content)
|
||||
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||
return modal
|
||||
soup = self.make_soup(response.content.decode())
|
||||
return soup.select_one("div.modal.bookmark-details")
|
||||
|
||||
def get_shared_details_modal(self, bookmark):
|
||||
url = reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
url = reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
response = self.client.get(url)
|
||||
soup = self.make_soup(response.content)
|
||||
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||
return modal
|
||||
soup = self.make_soup(response.content.decode())
|
||||
return soup.select_one("div.modal.bookmark-details")
|
||||
|
||||
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 has_details_modal(self, response):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
return soup.select_one("div.modal.bookmark-details") is not None
|
||||
|
||||
def get_section(self, soup, section_name):
|
||||
dd = self.find_section(soup, section_name)
|
||||
self.assertIsNotNone(dd)
|
||||
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_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})
|
||||
@@ -51,59 +55,38 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
def find_asset(self, soup, asset):
|
||||
return soup.find("div", {"data-asset-id": asset.id})
|
||||
|
||||
def details_route_access_test(self):
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# other user's bookmark
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# non-existent bookmark - just returns without modal in response
|
||||
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# guest user
|
||||
self.client.logout()
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_access(self):
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(self.has_details_modal(response))
|
||||
|
||||
# other user's bookmark
|
||||
other_user = self.setup_user()
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.has_details_modal(response))
|
||||
|
||||
# non-existent bookmark - just returns without modal in response
|
||||
response = self.client.get(reverse("bookmarks:index") + "?details=9999")
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.index") + "?details=9999"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.has_details_modal(response))
|
||||
|
||||
# guest user
|
||||
self.client.logout()
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.has_details_modal(response))
|
||||
|
||||
def test_access_with_sharing(self):
|
||||
# shared bookmark, sharing disabled
|
||||
@@ -111,9 +94,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.has_details_modal(response))
|
||||
|
||||
# shared bookmark, sharing enabled
|
||||
profile = other_user.profile
|
||||
@@ -121,25 +105,28 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(self.has_details_modal(response))
|
||||
|
||||
# shared bookmark, guest user, no public sharing
|
||||
self.client.logout()
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(self.has_details_modal(response))
|
||||
|
||||
# shared bookmark, guest user, public sharing
|
||||
profile.enable_public_sharing = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:shared") + f"?details={bookmark.id}"
|
||||
reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(self.has_details_modal(response))
|
||||
|
||||
def test_displays_title(self):
|
||||
# with title
|
||||
@@ -231,7 +218,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 3)
|
||||
|
||||
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
||||
reader_mode_url = reverse("linkding:assets.read", args=[asset.id])
|
||||
link = self.find_weblink(soup, reader_mode_url)
|
||||
self.assertIsNotNone(link)
|
||||
|
||||
@@ -367,7 +354,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 +370,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 +382,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 +394,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 +407,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 +423,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,19 +440,19 @@ 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}")
|
||||
self.assertIsNotNone(tag_link)
|
||||
expected_url = reverse("bookmarks:index") + f"?q=%23{tag.name}"
|
||||
expected_url = reverse("linkding:bookmarks.index") + f"?q=%23{tag.name}"
|
||||
self.assertEqual(tag_link["href"], expected_url)
|
||||
|
||||
def test_description(self):
|
||||
@@ -473,14 +460,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 +475,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):
|
||||
@@ -519,7 +506,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
form = delete_button.find_parent("form")
|
||||
self.assertIsNotNone(form)
|
||||
expected_url = reverse("bookmarks:index.action")
|
||||
expected_url = reverse("linkding:bookmarks.index.action")
|
||||
self.assertEqual(expected_url, form["action"])
|
||||
|
||||
def test_actions_visibility(self):
|
||||
@@ -564,28 +551,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 +565,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 +578,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:
|
||||
@@ -622,11 +592,81 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
self.assertIsNotNone(asset_text)
|
||||
self.assertIn(asset.display_name, asset_text.text)
|
||||
|
||||
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
||||
view_url = reverse("linkding:assets.view", args=[asset.id])
|
||||
view_link = asset_item.find("a", {"href": view_url})
|
||||
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)
|
||||
@@ -635,11 +675,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
soup = self.get_index_details_modal(bookmark)
|
||||
asset_item = self.find_asset(soup, asset)
|
||||
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
||||
view_url = reverse("linkding:assets.view", args=[asset.id])
|
||||
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 +694,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 +714,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 +775,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 +786,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")
|
||||
)
|
||||
|
||||
@@ -28,14 +28,18 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_should_render_successfully(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding: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})
|
||||
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
@@ -55,7 +59,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
@@ -63,12 +67,16 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
form_data = self.create_form_data({"id": bookmark.id, "unread": True})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.unread)
|
||||
|
||||
form_data = self.create_form_data({"id": bookmark.id, "unread": False})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.unread)
|
||||
|
||||
@@ -76,12 +84,16 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
form_data = self.create_form_data({"id": bookmark.id, "shared": True})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
form_data = self.create_form_data({"id": bookmark.id, "shared": False})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
@@ -95,7 +107,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
notes="edited notes",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -151,21 +165,21 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# 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
|
||||
reverse("linkding: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
|
||||
reverse("linkding: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
|
||||
reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertInHTML(
|
||||
@@ -180,23 +194,23 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
form_data = self.create_form_data()
|
||||
|
||||
url = (
|
||||
reverse("bookmarks:edit", args=[bookmark.id])
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
+ "?return_url="
|
||||
+ reverse("bookmarks:close")
|
||||
+ reverse("linkding:bookmarks.close")
|
||||
)
|
||||
response = self.client.post(url, form_data)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:close"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.close"))
|
||||
|
||||
def test_should_redirect_to_bookmark_index_by_default(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[bookmark.id]), form_data
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.index"))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -204,17 +218,17 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def post_with(return_url, follow=None):
|
||||
form_data = self.create_form_data()
|
||||
url = (
|
||||
reverse("bookmarks:edit", args=[bookmark.id])
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
+ f"?return_url={return_url}"
|
||||
)
|
||||
return self.client.post(url, form_data, follow=follow)
|
||||
|
||||
response = post_with("https://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.index"))
|
||||
response = post_with("//example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.index"))
|
||||
response = post_with("://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.index"))
|
||||
|
||||
response = post_with("/foo//example.com", follow=True)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -227,7 +241,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
form_data = self.create_form_data({"id": bookmark.id})
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[bookmark.id]), form_data
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertNotEqual(bookmark.url, form_data["url"])
|
||||
@@ -238,7 +252,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.user.profile.enable_sharing = False
|
||||
self.user.profile.save()
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -255,7 +271,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.user.profile.enable_sharing = True
|
||||
self.user.profile.save()
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -272,12 +290,16 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
)
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
||||
def test_should_show_notes_if_there_are_notes(self):
|
||||
bookmark = self.setup_bookmark(notes="test notes")
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
)
|
||||
|
||||
self.assertContains(response, '<details class="notes" open>', count=1)
|
||||
|
||||
@@ -44,7 +44,7 @@ class BookmarkIndexViewTestCase(
|
||||
self.setup_bookmark(user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -53,7 +53,7 @@ class BookmarkIndexViewTestCase(
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index") + "?q=foo")
|
||||
response = self.client.get(reverse("linkding:bookmarks.index") + "?q=foo")
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -75,7 +75,7 @@ class BookmarkIndexViewTestCase(
|
||||
archived_bookmarks + other_user_bookmarks
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -91,7 +91,7 @@ class BookmarkIndexViewTestCase(
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index") + "?q=foo")
|
||||
response = self.client.get(reverse("linkding:bookmarks.index") + "?q=foo")
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -113,7 +113,7 @@ class BookmarkIndexViewTestCase(
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
@@ -130,7 +130,7 @@ class BookmarkIndexViewTestCase(
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index")
|
||||
reverse("linkding:bookmarks.index")
|
||||
+ f"?q=%23{tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
@@ -149,7 +149,8 @@ class BookmarkIndexViewTestCase(
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
reverse("linkding:bookmarks.index")
|
||||
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
@@ -168,7 +169,8 @@ class BookmarkIndexViewTestCase(
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
reverse("linkding:bookmarks.index")
|
||||
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
@@ -176,7 +178,7 @@ class BookmarkIndexViewTestCase(
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
|
||||
|
||||
@@ -187,14 +189,14 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
|
||||
|
||||
def test_edit_link_return_url_respects_search_options(self):
|
||||
bookmark = self.setup_bookmark(title="foo")
|
||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||
base_url = reverse("bookmarks:index")
|
||||
edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
base_url = reverse("linkding:bookmarks.index")
|
||||
|
||||
# without query params
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
@@ -220,8 +222,8 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
def test_bulk_edit_respects_search_options(self):
|
||||
action_url = reverse("bookmarks:index.action")
|
||||
base_url = reverse("bookmarks:index")
|
||||
action_url = reverse("linkding:bookmarks.index.action")
|
||||
base_url = reverse("linkding:bookmarks.index")
|
||||
|
||||
# without params
|
||||
url = f"{action_url}"
|
||||
@@ -244,7 +246,7 @@ class BookmarkIndexViewTestCase(
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse("bookmarks:index")
|
||||
url = reverse("linkding:bookmarks.index")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
@@ -257,6 +259,7 @@ class BookmarkIndexViewTestCase(
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
@@ -267,7 +270,7 @@ class BookmarkIndexViewTestCase(
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse("bookmarks:index")
|
||||
url = reverse("linkding:bookmarks.index")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
@@ -282,6 +285,7 @@ class BookmarkIndexViewTestCase(
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
@@ -289,13 +293,13 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
def test_apply_search_preferences(self):
|
||||
# no params
|
||||
response = self.client.post(reverse("bookmarks:index"))
|
||||
response = self.client.post(reverse("linkding:bookmarks.index"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("bookmarks:index"))
|
||||
self.assertEqual(response.url, reverse("linkding:bookmarks.index"))
|
||||
|
||||
# some params
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"q": "foo",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -303,12 +307,12 @@ class BookmarkIndexViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:index") + "?q=foo&sort=title_asc"
|
||||
response.url, reverse("linkding:bookmarks.index") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
# params with default value are removed
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"q": "foo",
|
||||
"user": "",
|
||||
@@ -318,11 +322,13 @@ class BookmarkIndexViewTestCase(
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("bookmarks:index") + "?q=foo&unread=yes")
|
||||
self.assertEqual(
|
||||
response.url, reverse("linkding:bookmarks.index") + "?q=foo&unread=yes"
|
||||
)
|
||||
|
||||
# page is removed
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"q": "foo",
|
||||
"page": "2",
|
||||
@@ -331,7 +337,7 @@ class BookmarkIndexViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:index") + "?q=foo&sort=title_asc"
|
||||
response.url, reverse("linkding:bookmarks.index") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
def test_save_search_preferences(self):
|
||||
@@ -339,7 +345,7 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
# no params
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"save": "",
|
||||
},
|
||||
@@ -356,7 +362,7 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
# with param
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -374,7 +380,7 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
# add a param
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -393,7 +399,7 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
# remove a param
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"save": "",
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
@@ -411,7 +417,7 @@ class BookmarkIndexViewTestCase(
|
||||
|
||||
# ignores non-preferences
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
reverse("linkding:bookmarks.index"),
|
||||
{
|
||||
"save": "",
|
||||
"q": "foo",
|
||||
@@ -431,7 +437,7 @@ class BookmarkIndexViewTestCase(
|
||||
)
|
||||
|
||||
def test_url_encode_bookmark_actions_url(self):
|
||||
url = reverse("bookmarks:index") + "?q=%23foo"
|
||||
url = reverse("linkding:bookmarks.index") + "?q=%23foo"
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
@@ -445,34 +451,34 @@ class BookmarkIndexViewTestCase(
|
||||
def test_encode_search_params(self):
|
||||
bookmark = self.setup_bookmark(description="alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:index") + "?q=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.index") + "?q=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse("bookmarks:index") + "?sort=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.index") + "?sort=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:index") + "?unread=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.index") + "?unread=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:index") + "?shared=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.index") + "?shared=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:index") + "?user=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.index") + "?user=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.index") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
def test_turbo_frame_details_modal_renders_details_modal_update(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse("bookmarks:index") + f"?bookmark_id={bookmark.id}"
|
||||
url = reverse("linkding:bookmarks.index") + f"?bookmark_id={bookmark.id}"
|
||||
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
@@ -481,3 +487,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("linkding: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()
|
||||
@@ -29,10 +31,11 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
||||
# capture number of queries
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
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
|
||||
|
||||
@@ -43,9 +46,10 @@ 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,
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
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
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_should_create_new_bookmark(self):
|
||||
form_data = self.create_form_data()
|
||||
|
||||
self.client.post(reverse("bookmarks:new"), form_data)
|
||||
self.client.post(reverse("linkding:bookmarks.new"), form_data)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
|
||||
@@ -48,13 +48,13 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
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)
|
||||
response = self.client.post(reverse("linkding: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})
|
||||
|
||||
self.client.post(reverse("bookmarks:new"), form_data)
|
||||
self.client.post(reverse("linkding:bookmarks.new"), form_data)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
|
||||
@@ -64,7 +64,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_should_create_new_shared_bookmark(self):
|
||||
form_data = self.create_form_data({"shared": True})
|
||||
|
||||
self.client.post(reverse("bookmarks:new"), form_data)
|
||||
self.client.post(reverse("linkding:bookmarks.new"), form_data)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
|
||||
@@ -72,7 +72,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
def test_should_prefill_url_from_url_parameter(self):
|
||||
response = self.client.get(reverse("bookmarks:new") + "?url=http://example.com")
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.new") + "?url=http://example.com"
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -83,7 +85,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def test_should_prefill_title_from_url_parameter(self):
|
||||
response = self.client.get(reverse("bookmarks:new") + "?title=Example%20Title")
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.new") + "?title=Example%20Title"
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -95,7 +99,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_should_prefill_description_from_url_parameter(self):
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:new") + "?description=Example%20Site%20Description"
|
||||
reverse("linkding:bookmarks.new")
|
||||
+ "?description=Example%20Site%20Description"
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
@@ -105,9 +110,22 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_prefill_tags_from_url_parameter(self):
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.new") + "?tags=tag1%20tag2%20tag3"
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="text" name="tag_string" value="tag1 tag2 tag3" '
|
||||
'class="form-input" autocomplete="off" autocapitalize="off" '
|
||||
'id="id_tag_string">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_prefill_notes_from_url_parameter(self):
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:new")
|
||||
reverse("linkding:bookmarks.new")
|
||||
+ "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29"
|
||||
)
|
||||
html = response.content.decode()
|
||||
@@ -129,50 +147,51 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
|
||||
response = self.client.get(reverse("bookmarks:new") + "?auto_close")
|
||||
response = self.client.get(reverse("linkding:bookmarks.new") + "?auto_close")
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="hidden" name="auto_close" value="true" '
|
||||
'id="id_auto_close">',
|
||||
'<input type="hidden" name="auto_close" value="True" id="id_auto_close">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(self):
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="hidden" name="auto_close" id="id_auto_close">', html
|
||||
'<input type="hidden" name="auto_close" value="False" id="id_auto_close">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_redirect_to_index_view(self):
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(reverse("bookmarks:new"), form_data)
|
||||
response = self.client.post(reverse("linkding:bookmarks.new"), form_data)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.index"))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:new") + "?return_url=https://example.com", form_data
|
||||
reverse("linkding:bookmarks.new") + "?return_url=https://example.com",
|
||||
form_data,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.index"))
|
||||
|
||||
def test_auto_close_should_redirect_to_close_view(self):
|
||||
form_data = self.create_form_data({"auto_close": "true"})
|
||||
form_data = self.create_form_data({"auto_close": "True"})
|
||||
|
||||
response = self.client.post(reverse("bookmarks:new"), form_data)
|
||||
response = self.client.post(reverse("linkding:bookmarks.new"), form_data)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:close"))
|
||||
self.assertRedirects(response, reverse("linkding:bookmarks.close"))
|
||||
|
||||
def test_should_respect_share_profile_setting(self):
|
||||
self.user.profile.enable_sharing = False
|
||||
self.user.profile.save()
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -189,7 +208,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.user.profile.enable_sharing = True
|
||||
self.user.profile.save()
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -208,7 +227,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.enable_sharing = True
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(
|
||||
"""
|
||||
@@ -222,7 +241,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.enable_public_sharing = True
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(
|
||||
"""
|
||||
@@ -235,12 +254,14 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
)
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
||||
def test_should_not_check_unread_by_default(self):
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
@@ -252,11 +273,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.default_mark_unread = True
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="checkbox" name="unread" value="true" '
|
||||
'id="id_unread" checked="">',
|
||||
'<input type="checkbox" name="unread" id="id_unread" checked="">',
|
||||
html,
|
||||
)
|
||||
|
||||
70
bookmarks/tests/test_bookmark_previews.py
Normal file
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")
|
||||
|
||||
@@ -75,7 +75,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True, user=user4),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -94,7 +94,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True, user=user3),
|
||||
]
|
||||
|
||||
url = reverse("bookmarks:shared") + "?user=" + user1.username
|
||||
url = reverse("linkding:bookmarks.shared") + "?user=" + user1.username
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
@@ -109,7 +109,7 @@ class BookmarkSharedViewTestCase(
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared") + "?q=foo")
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared") + "?q=foo")
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -125,7 +125,7 @@ class BookmarkSharedViewTestCase(
|
||||
3, shared=True, user=user2, prefix="user2"
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -159,7 +159,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]])
|
||||
self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]])
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -181,7 +181,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
|
||||
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]])
|
||||
|
||||
url = reverse("bookmarks:shared") + "?user=" + user1.username
|
||||
url = reverse("linkding:bookmarks.shared") + "?user=" + user1.username
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
@@ -217,7 +217,9 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
|
||||
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared") + "?q=searchvalue")
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.shared") + "?q=searchvalue"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -241,7 +243,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
|
||||
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -258,7 +260,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True))
|
||||
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False))
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
self.assertVisibleUserOptions(response, expected_visible_users)
|
||||
|
||||
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
|
||||
@@ -278,7 +280,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
|
||||
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
self.assertVisibleUserOptions(response, expected_visible_users)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
@@ -313,7 +315,7 @@ class BookmarkSharedViewTestCase(
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
@@ -330,7 +332,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
|
||||
|
||||
@@ -347,7 +349,7 @@ class BookmarkSharedViewTestCase(
|
||||
self.setup_bookmark(shared=True),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse("bookmarks:shared"))
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
|
||||
|
||||
@@ -358,8 +360,8 @@ class BookmarkSharedViewTestCase(
|
||||
user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(title="foo", shared=True, user=user)
|
||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||
base_url = reverse("bookmarks:shared")
|
||||
edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
base_url = reverse("linkding:bookmarks.shared")
|
||||
|
||||
# without query params
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
@@ -394,13 +396,13 @@ class BookmarkSharedViewTestCase(
|
||||
|
||||
def test_apply_search_preferences(self):
|
||||
# no params
|
||||
response = self.client.post(reverse("bookmarks:shared"))
|
||||
response = self.client.post(reverse("linkding:bookmarks.shared"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("bookmarks:shared"))
|
||||
self.assertEqual(response.url, reverse("linkding:bookmarks.shared"))
|
||||
|
||||
# some params
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"q": "foo",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -408,12 +410,12 @@ class BookmarkSharedViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:shared") + "?q=foo&sort=title_asc"
|
||||
response.url, reverse("linkding:bookmarks.shared") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
# params with default value are removed
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"q": "foo",
|
||||
"user": "",
|
||||
@@ -424,12 +426,12 @@ class BookmarkSharedViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:shared") + "?q=foo&unread=yes"
|
||||
response.url, reverse("linkding:bookmarks.shared") + "?q=foo&unread=yes"
|
||||
)
|
||||
|
||||
# page is removed
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"q": "foo",
|
||||
"page": "2",
|
||||
@@ -438,7 +440,7 @@ class BookmarkSharedViewTestCase(
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:shared") + "?q=foo&sort=title_asc"
|
||||
response.url, reverse("linkding:bookmarks.shared") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
def test_save_search_preferences(self):
|
||||
@@ -447,7 +449,7 @@ class BookmarkSharedViewTestCase(
|
||||
|
||||
# no params
|
||||
self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"save": "",
|
||||
},
|
||||
@@ -464,7 +466,7 @@ class BookmarkSharedViewTestCase(
|
||||
|
||||
# with param
|
||||
self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -482,7 +484,7 @@ class BookmarkSharedViewTestCase(
|
||||
|
||||
# add a param
|
||||
self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
@@ -501,7 +503,7 @@ class BookmarkSharedViewTestCase(
|
||||
|
||||
# remove a param
|
||||
self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"save": "",
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
@@ -519,7 +521,7 @@ class BookmarkSharedViewTestCase(
|
||||
|
||||
# ignores non-preferences
|
||||
self.client.post(
|
||||
reverse("bookmarks:shared"),
|
||||
reverse("linkding:bookmarks.shared"),
|
||||
{
|
||||
"save": "",
|
||||
"q": "foo",
|
||||
@@ -539,7 +541,7 @@ class BookmarkSharedViewTestCase(
|
||||
)
|
||||
|
||||
def test_url_encode_bookmark_actions_url(self):
|
||||
url = reverse("bookmarks:shared") + "?q=%23foo"
|
||||
url = reverse("linkding:bookmarks.shared") + "?q=%23foo"
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
@@ -557,34 +559,34 @@ class BookmarkSharedViewTestCase(
|
||||
user.profile.save()
|
||||
bookmark = self.setup_bookmark(description="alert('xss')", shared=True)
|
||||
|
||||
url = reverse("bookmarks:shared") + "?q=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.shared") + "?q=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse("bookmarks:shared") + "?sort=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.shared") + "?sort=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:shared") + "?unread=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.shared") + "?unread=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:shared") + "?shared=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.shared") + "?shared=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:shared") + "?user=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.shared") + "?user=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
|
||||
url = reverse("linkding:bookmarks.shared") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
def test_turbo_frame_details_modal_renders_details_modal_update(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse("bookmarks:shared") + f"?bookmark_id={bookmark.id}"
|
||||
url = reverse("linkding:bookmarks.shared") + f"?bookmark_id={bookmark.id}"
|
||||
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
@@ -593,3 +595,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("linkding: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("linkding: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()
|
||||
@@ -30,10 +32,11 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
# capture number of queries
|
||||
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
|
||||
)
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
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
|
||||
|
||||
@@ -45,9 +48,10 @@ 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,
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
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,12 +1,12 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookmarks.models import BookmarkForm, Bookmark
|
||||
|
||||
User = get_user_model()
|
||||
from bookmarks.forms import BookmarkForm
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
ENABLED_URL_VALIDATION_TEST_CASES = [
|
||||
("thisisnotavalidurl", False),
|
||||
@@ -29,12 +29,10 @@ DISABLED_URL_VALIDATION_TEST_CASES = [
|
||||
]
|
||||
|
||||
|
||||
class BookmarkValidationTestCase(TestCase):
|
||||
class BookmarkValidationTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(
|
||||
"testuser", "test@example.com", "password123"
|
||||
)
|
||||
self.get_or_create_test_user()
|
||||
|
||||
def test_bookmark_model_should_not_allow_missing_url(self):
|
||||
bookmark = Bookmark(
|
||||
@@ -66,12 +64,15 @@ class BookmarkValidationTestCase(TestCase):
|
||||
self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
|
||||
|
||||
def test_bookmark_form_should_validate_required_fields(self):
|
||||
form = BookmarkForm(data={"url": ""})
|
||||
rf = RequestFactory()
|
||||
request = rf.post("/", data={"url": ""})
|
||||
form = BookmarkForm(request)
|
||||
|
||||
self.assertEqual(len(form.errors), 1)
|
||||
self.assertIn("required", str(form.errors))
|
||||
|
||||
form = BookmarkForm(data={"url": None})
|
||||
request = rf.post("/", data={})
|
||||
form = BookmarkForm(request)
|
||||
|
||||
self.assertEqual(len(form.errors), 1)
|
||||
self.assertIn("required", str(form.errors))
|
||||
@@ -106,7 +107,9 @@ class BookmarkValidationTestCase(TestCase):
|
||||
def _run_bookmark_form_url_validity_checks(self, cases):
|
||||
for case in cases:
|
||||
url, expectation = case
|
||||
form = BookmarkForm(data={"url": url})
|
||||
rf = RequestFactory()
|
||||
request = rf.post("/", data={"url": url})
|
||||
form = BookmarkForm(request)
|
||||
|
||||
if expectation:
|
||||
self.assertEqual(len(form.errors), 0)
|
||||
|
||||
@@ -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
|
||||
@@ -68,7 +89,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmarks = self.setup_numbered_bookmarks(5)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
@@ -83,7 +104,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
@@ -95,7 +116,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark.save()
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
self.assertIsNone(response.data["results"][0]["website_title"])
|
||||
self.assertIsNone(response.data["results"][0]["website_description"])
|
||||
@@ -106,7 +127,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_numbered_bookmarks(5, archived=True)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
|
||||
@@ -117,7 +138,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list") + "?q=" + search_value,
|
||||
reverse("linkding:bookmark-list") + "?q=" + search_value,
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
@@ -129,7 +150,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
# Filter off
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
self.assertBookmarkListEqual(
|
||||
response.data["results"], unread_bookmarks + read_bookmarks
|
||||
@@ -137,14 +158,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
# Filter shared
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list") + "?unread=yes",
|
||||
reverse("linkding:bookmark-list") + "?unread=yes",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], unread_bookmarks)
|
||||
|
||||
# Filter unshared
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list") + "?unread=no",
|
||||
reverse("linkding:bookmark-list") + "?unread=no",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], read_bookmarks)
|
||||
@@ -156,7 +177,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
# Filter off
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
self.assertBookmarkListEqual(
|
||||
response.data["results"], unshared_bookmarks + shared_bookmarks
|
||||
@@ -164,14 +185,14 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
# Filter shared
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list") + "?shared=yes",
|
||||
reverse("linkding:bookmark-list") + "?shared=yes",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
||||
|
||||
# Filter unshared
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list") + "?shared=no",
|
||||
reverse("linkding:bookmark-list") + "?shared=no",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], unshared_bookmarks)
|
||||
@@ -182,7 +203,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmarks.reverse()
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-list") + "?sort=title_desc",
|
||||
reverse("linkding:bookmark-list") + "?sort=title_desc",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
@@ -193,7 +214,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-archived"),
|
||||
reverse("linkding:bookmark-archived"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
@@ -210,7 +231,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-archived"),
|
||||
reverse("linkding:bookmark-archived"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
@@ -224,7 +245,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_numbered_bookmarks(5, archived=True)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-archived") + "?q=" + search_value,
|
||||
reverse("linkding:bookmark-archived") + "?q=" + search_value,
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||
@@ -235,7 +256,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmarks.reverse()
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-archived") + "?sort=title_desc",
|
||||
reverse("linkding:bookmark-archived") + "?sort=title_desc",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
@@ -259,7 +280,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(shared=True, user=user4)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared"),
|
||||
reverse("linkding:bookmark-shared"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
||||
@@ -279,7 +300,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared"),
|
||||
reverse("linkding:bookmark-shared"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
||||
@@ -296,7 +317,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(shared=True, user=user2)
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared"),
|
||||
reverse("linkding:bookmark-shared"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
||||
@@ -318,7 +339,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(shared=True, user=user3),
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared") + "?q=searchvalue",
|
||||
reverse("linkding:bookmark-shared") + "?q=searchvalue",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], expected_bookmarks)
|
||||
@@ -331,7 +352,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(shared=True, user=user_search_user),
|
||||
]
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared") + "?user=" + user_search_user.username,
|
||||
reverse("linkding:bookmark-shared") + "?user=" + user_search_user.username,
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], expected_bookmarks)
|
||||
@@ -350,7 +371,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
),
|
||||
]
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared")
|
||||
reverse("linkding:bookmark-shared")
|
||||
+ "?q=searchvalue&user="
|
||||
+ combined_search_user.username,
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
@@ -364,7 +385,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmarks.reverse()
|
||||
|
||||
response = self.get(
|
||||
reverse("bookmarks:bookmark-shared") + "?sort=title_desc",
|
||||
reverse("linkding:bookmark-shared") + "?sort=title_desc",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||
@@ -382,7 +403,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
"shared": False,
|
||||
"tag_names": ["tag1", "tag2"],
|
||||
}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertEqual(bookmark.url, data["url"])
|
||||
self.assertEqual(bookmark.title, data["title"])
|
||||
@@ -406,7 +427,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
description="Website description",
|
||||
preview_image=None,
|
||||
)
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertEqual(bookmark.title, "Website title")
|
||||
self.assertEqual(bookmark.description, "Website description")
|
||||
@@ -425,7 +446,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
preview_image=None,
|
||||
)
|
||||
self.post(
|
||||
reverse("bookmarks:bookmark-list") + "?disable_scraping",
|
||||
reverse("linkding:bookmark-list") + "?disable_scraping",
|
||||
data,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
@@ -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("linkding: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("linkding: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()
|
||||
|
||||
@@ -447,7 +502,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
"is_archived": True,
|
||||
"tag_names": ["tag1", "tag2"],
|
||||
}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertEqual(bookmark.id, original_bookmark.id)
|
||||
self.assertEqual(bookmark.url, data["url"])
|
||||
@@ -471,7 +526,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
"description": "Test description",
|
||||
"tag_names": ["tag 1", "tag 2"],
|
||||
}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
tag_names = [tag.name for tag in bookmark.tags.all()]
|
||||
self.assertListEqual(tag_names, ["tag-1", "tag-2"])
|
||||
@@ -481,7 +536,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
data = {"url": "https://example.com/"}
|
||||
self.post(
|
||||
reverse("bookmarks:bookmark-list") + "?disable_scraping",
|
||||
reverse("linkding:bookmark-list") + "?disable_scraping",
|
||||
data,
|
||||
status.HTTP_201_CREATED,
|
||||
)
|
||||
@@ -506,7 +561,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
"is_archived": True,
|
||||
"tag_names": ["tag1", "tag2"],
|
||||
}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertEqual(bookmark.url, data["url"])
|
||||
self.assertEqual(bookmark.title, data["title"])
|
||||
@@ -520,7 +575,7 @@ 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("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
@@ -528,7 +583,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
|
||||
data = {"url": "https://example.com/", "unread": True}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertTrue(bookmark.unread)
|
||||
|
||||
@@ -536,7 +591,7 @@ 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("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertFalse(bookmark.unread)
|
||||
|
||||
@@ -544,7 +599,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
|
||||
data = {"url": "https://example.com/", "shared": True}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
@@ -552,7 +607,7 @@ 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("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
@@ -566,7 +621,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
@@ -574,7 +629,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertBookmarkListEqual([response.data], [bookmark])
|
||||
|
||||
@@ -586,16 +641,33 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
tags=[tag1],
|
||||
)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
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("linkding: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()
|
||||
|
||||
data = {"url": "https://example.com/updated"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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"])
|
||||
@@ -610,7 +682,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
"website_title": "test",
|
||||
"website_description": "test",
|
||||
}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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)
|
||||
@@ -627,7 +699,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
data = {"title": "https://example.com/"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
|
||||
@@ -637,7 +709,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
data = {"url": "https://example.com/"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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"])
|
||||
@@ -654,7 +726,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
data = {"url": "https://example.com/", "unread": True}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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.unread, True)
|
||||
@@ -664,7 +736,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
data = {"url": "https://example.com/", "shared": True}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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.shared, True)
|
||||
@@ -680,7 +752,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
@@ -695,17 +767,17 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
# 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])
|
||||
url = reverse("linkding: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])
|
||||
url = reverse("linkding: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])
|
||||
url = reverse("linkding:bookmark-detail", args=[edited_bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_patch_bookmark(self):
|
||||
@@ -713,55 +785,55 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
data = {"url": "https://example.com"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.url, data["url"])
|
||||
|
||||
data = {"title": "Updated title"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.title, data["title"])
|
||||
|
||||
data = {"description": "Updated description"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.description, data["description"])
|
||||
|
||||
data = {"notes": "Updated notes"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.notes, data["notes"])
|
||||
|
||||
data = {"unread": True}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.unread)
|
||||
|
||||
data = {"unread": False}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.unread)
|
||||
|
||||
data = {"shared": True}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
data = {"shared": False}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
data = {"tag_names": ["updated-tag-1", "updated-tag-2"]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
bookmark.refresh_from_db()
|
||||
tag_names = [tag.name for tag in bookmark.tags.all()]
|
||||
@@ -776,7 +848,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
"website_title": "test",
|
||||
"website_description": "test",
|
||||
}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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(
|
||||
@@ -793,7 +865,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
|
||||
)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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)
|
||||
@@ -816,7 +888,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
data = {"tag_names": [tag1.name]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding: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.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
@@ -825,7 +897,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(len(Bookmark.objects.filter(id=bookmark.id)), 0)
|
||||
|
||||
@@ -833,7 +905,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
url = reverse("bookmarks:bookmark-archive", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-archive", args=[bookmark.id])
|
||||
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertTrue(bookmark.is_archived)
|
||||
@@ -842,7 +914,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
|
||||
url = reverse("bookmarks:bookmark-unarchive", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-unarchive", args=[bookmark.id])
|
||||
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
@@ -850,7 +922,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
|
||||
self.authenticate()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -873,7 +945,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -897,7 +969,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
preview_image_file="preview.png",
|
||||
)
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -934,7 +1006,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -950,7 +1022,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
def test_check_returns_no_auto_tags_if_none_configured(self):
|
||||
self.authenticate()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -966,7 +1038,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
profile.auto_tagging_rules = "example.com tag1 tag2"
|
||||
profile.save()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -975,6 +1047,43 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertCountEqual(auto_tags, ["tag1", "tag2"])
|
||||
|
||||
def test_check_ignore_cache(self):
|
||||
self.authenticate()
|
||||
|
||||
with patch.object(
|
||||
website_loader, "load_website_metadata"
|
||||
) as mock_load_website_metadata:
|
||||
expected_metadata = WebsiteMetadata(
|
||||
"https://example.com",
|
||||
"Scraped metadata",
|
||||
"Scraped description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
# Does not ignore cache by default
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
self.get(
|
||||
f"{url}?url={check_url}",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
mock_load_website_metadata.assert_called_once_with(
|
||||
"https://example.com", ignore_cache=False
|
||||
)
|
||||
mock_load_website_metadata.reset_mock()
|
||||
|
||||
# Ignores cache based on query param
|
||||
self.get(
|
||||
f"{url}?url={check_url}&ignore_cache=true",
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
mock_load_website_metadata.assert_called_once_with(
|
||||
"https://example.com", ignore_cache=True
|
||||
)
|
||||
|
||||
def test_can_only_access_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
self.setup_bookmark()
|
||||
@@ -987,23 +1096,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
self.setup_bookmark(user=other_user, is_archived=True)
|
||||
|
||||
url = reverse("bookmarks:bookmark-list")
|
||||
url = reverse("linkding:bookmark-list")
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["results"]), 1)
|
||||
|
||||
url = reverse("bookmarks:bookmark-archived")
|
||||
url = reverse("linkding:bookmark-archived")
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["results"]), 1)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
"linkding:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
)
|
||||
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
self.put(
|
||||
url,
|
||||
{url: "https://example.com/"},
|
||||
@@ -1012,7 +1121,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
"linkding:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
)
|
||||
self.put(
|
||||
url,
|
||||
@@ -1021,31 +1130,31 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
"linkding:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||
)
|
||||
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse("bookmarks:bookmark-archive", args=[inaccessible_bookmark.id])
|
||||
url = reverse("linkding:bookmark-archive", args=[inaccessible_bookmark.id])
|
||||
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark-archive", args=[inaccessible_shared_bookmark.id]
|
||||
"linkding:bookmark-archive", args=[inaccessible_shared_bookmark.id]
|
||||
)
|
||||
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse("bookmarks:bookmark-unarchive", args=[inaccessible_bookmark.id])
|
||||
url = reverse("linkding:bookmark-unarchive", args=[inaccessible_bookmark.id])
|
||||
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse(
|
||||
"bookmarks:bookmark-unarchive", args=[inaccessible_shared_bookmark.id]
|
||||
"linkding:bookmark-unarchive", args=[inaccessible_shared_bookmark.id]
|
||||
)
|
||||
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
@@ -1074,13 +1183,14 @@ 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()
|
||||
|
||||
# default profile
|
||||
profile = self.user.profile
|
||||
url = reverse("bookmarks:user-profile")
|
||||
url = reverse("linkding:user-profile")
|
||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
self.assertUserProfile(response, profile)
|
||||
@@ -1103,7 +1213,123 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
}
|
||||
profile.save()
|
||||
|
||||
url = reverse("bookmarks:user-profile")
|
||||
url = reverse("linkding:user-profile")
|
||||
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("linkding: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("linkding: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("linkding: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("linkding: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("linkding: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("linkding: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("linkding:bookmark-singlefile"),
|
||||
self.create_singlefile_upload_body(),
|
||||
format="multipart",
|
||||
expected_status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-list"),
|
||||
reverse("linkding:bookmark-list"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-archived"),
|
||||
reverse("linkding:bookmark-archived"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-shared"),
|
||||
reverse("linkding:bookmark-shared"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,36 +16,36 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
|
||||
def test_list_bookmarks_requires_authentication(self):
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-list"),
|
||||
reverse("linkding:bookmark-list"),
|
||||
expected_status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
self.authenticate()
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
reverse("linkding:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
def test_list_archived_bookmarks_requires_authentication(self):
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-archived"),
|
||||
reverse("linkding:bookmark-archived"),
|
||||
expected_status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
self.authenticate()
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-archived"),
|
||||
reverse("linkding:bookmark-archived"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def test_list_shared_bookmarks_does_not_require_authentication(self):
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-shared"),
|
||||
reverse("linkding:bookmark-shared"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
self.authenticate()
|
||||
self.get(
|
||||
reverse("bookmarks:bookmark-shared"),
|
||||
reverse("linkding:bookmark-shared"),
|
||||
expected_status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -61,16 +61,14 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
"tag_names": ["tag1", "tag2"],
|
||||
}
|
||||
|
||||
self.post(
|
||||
reverse("bookmarks:bookmark-list"), data, status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
self.authenticate()
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
self.post(reverse("linkding:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
|
||||
def test_get_bookmark_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -80,7 +78,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
def test_update_bookmark_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
data = {"url": "https://example.com/"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
|
||||
self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -93,14 +91,14 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
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])
|
||||
url = reverse("linkding: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"}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
|
||||
self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -113,13 +111,13 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
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])
|
||||
url = reverse("linkding: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])
|
||||
url = reverse("linkding:bookmark-detail", args=[bookmark.id])
|
||||
|
||||
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -128,7 +126,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
|
||||
def test_archive_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
url = reverse("bookmarks:bookmark-archive", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-archive", args=[bookmark.id])
|
||||
|
||||
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -137,7 +135,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
|
||||
def test_unarchive_requires_authentication(self):
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
url = reverse("bookmarks:bookmark-unarchive", args=[bookmark.id])
|
||||
url = reverse("linkding:bookmark-unarchive", args=[bookmark.id])
|
||||
|
||||
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -145,7 +143,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def test_check_requires_authentication(self):
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
|
||||
self.get(
|
||||
@@ -156,9 +154,14 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
||||
self.get(f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_user_profile_requires_authentication(self):
|
||||
url = reverse("bookmarks:user-profile")
|
||||
url = reverse("linkding:user-profile")
|
||||
|
||||
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
self.authenticate()
|
||||
self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||
|
||||
def test_singlefile_upload_requires_authentication(self):
|
||||
url = reverse("linkding:bookmark-singlefile")
|
||||
|
||||
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@@ -43,7 +43,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
{label_content}
|
||||
</a>
|
||||
<span>|</span>
|
||||
@@ -65,18 +65,18 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
count=1,
|
||||
):
|
||||
if base_url is None:
|
||||
base_url = reverse("bookmarks:index")
|
||||
base_url = reverse("linkding:bookmarks.index")
|
||||
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,
|
||||
)
|
||||
|
||||
def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||
edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{edit_url}?return_url=/bookmarks">Edit</a>
|
||||
@@ -559,11 +559,39 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_self"
|
||||
)
|
||||
|
||||
def test_should_render_latest_snapshot_link_if_one_exists(self):
|
||||
bookmark = self.setup_date_format_test(
|
||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
||||
)
|
||||
bookmark.latest_snapshot = self.setup_asset(bookmark)
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_template()
|
||||
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
|
||||
snapshot_url = reverse(
|
||||
"linkding:assets.view", args=[bookmark.latest_snapshot.id]
|
||||
)
|
||||
|
||||
# Check that the snapshot link is rendered with the correct URL and title
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{snapshot_url}"
|
||||
title="View latest snapshot" target="_blank" rel="noopener">
|
||||
{formatted_date}
|
||||
</a>
|
||||
<span>|</span>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
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 +600,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 +613,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()
|
||||
@@ -651,7 +685,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
|
||||
self.assertViewLink(
|
||||
html, bookmark, base_url=reverse("linkding:bookmarks.shared")
|
||||
)
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertShareInfo(html, bookmark)
|
||||
|
||||
@@ -848,6 +884,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
)
|
||||
self.assertNotes(html, note_html, 1)
|
||||
|
||||
def test_note_renders_markdown_with_linkify(self):
|
||||
# Should linkify plain URL
|
||||
self.setup_bookmark(notes="Example: https://example.com")
|
||||
html = self.render_template()
|
||||
|
||||
note_html = '<p>Example: <a href="https://example.com" rel="nofollow">https://example.com</a></p>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
|
||||
# Should not linkify URL in markdown link
|
||||
self.setup_bookmark(notes="[https://example.com](https://example.com)")
|
||||
html = self.render_template()
|
||||
|
||||
note_html = '<p><a href="https://example.com" rel="nofollow">https://example.com</a></p>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
|
||||
def test_note_cleans_html(self):
|
||||
self.setup_bookmark(notes='<script>alert("test")</script>')
|
||||
self.setup_bookmark(
|
||||
@@ -943,7 +994,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertWebArchiveLink(
|
||||
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
||||
)
|
||||
self.assertViewLink(html, bookmark, base_url=reverse("bookmarks:shared"))
|
||||
self.assertViewLink(
|
||||
html, bookmark, base_url=reverse("linkding:bookmarks.shared")
|
||||
)
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertShareInfo(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user