Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016ff2da66 | ||
|
|
77d7e6e66a | ||
|
|
c5a300a435 | ||
|
|
0d4c47eb81 | ||
|
|
17442eeb9a | ||
|
|
2973812626 | ||
|
|
fc48b266a8 | ||
|
|
7b42241026 | ||
|
|
9c648dc67f | ||
|
|
1624128132 | ||
|
|
d1dd85538b | ||
|
|
c5aab3886e | ||
|
|
3f2739e5a6 | ||
|
|
f1ed89a0ba | ||
|
|
a59a7a777c | ||
|
|
9a5c535872 | ||
|
|
e6ebca1436 | ||
|
|
085d67e9f4 | ||
|
|
68825444fb | ||
|
|
b2ca16ec9c | ||
|
|
649f4154e5 | ||
|
|
d2e8a95e3c | ||
|
|
c3149409b0 | ||
|
|
4626fa1c67 | ||
|
|
6548e16baa | ||
|
|
c177de164a | ||
|
|
e9ecad38ac | ||
|
|
621aedd8eb | ||
|
|
4187141ac8 | ||
|
|
cf0cc32090 | ||
|
|
1f2cf21585 | ||
|
|
0dd05b9269 | ||
|
|
5cd6d773db | ||
|
|
d4c348cc5a | ||
|
|
791a5c73ca | ||
|
|
ebed0c050d | ||
|
|
f4dd2b53b5 | ||
|
|
b53fe09c39 | ||
|
|
ff88e726cc | ||
|
|
52400feacf | ||
|
|
c93709b549 | ||
|
|
ba904ed191 | ||
|
|
d1f81fee0e | ||
|
|
7b405c054d | ||
|
|
23ad52f75d | ||
|
|
c3a2305a5f | ||
|
|
d4006026db | ||
|
|
70bdf88791 |
2
.gitignore
vendored
@@ -192,7 +192,7 @@ typings/
|
|||||||
# Database file
|
# Database file
|
||||||
/data
|
/data
|
||||||
# ublock + chromium
|
# ublock + chromium
|
||||||
/uBlock0.chromium
|
/uBOLite.chromium.mv3
|
||||||
/chromium-profile
|
/chromium-profile
|
||||||
# direnv
|
# direnv
|
||||||
/.direnv
|
/.direnv
|
||||||
|
|||||||
80
CHANGELOG.md
@@ -1,5 +1,85 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.37.0 (26/01/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
|
||||||
|
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
|
||||||
|
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
|
||||||
|
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
|
||||||
|
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
|
||||||
|
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
|
||||||
|
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
|
||||||
|
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
|
||||||
|
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
|
||||||
|
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
|
||||||
|
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
|
||||||
|
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
|
||||||
|
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
|
||||||
|
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
|
||||||
|
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
|
||||||
|
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
|
||||||
|
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
|
||||||
|
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
|
||||||
|
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.36.0 (02/10/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
|
||||||
|
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
|
||||||
|
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
|
||||||
|
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
|
||||||
|
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
|
||||||
|
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
|
||||||
|
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
|
||||||
|
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
|
||||||
|
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
|
||||||
|
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
|
||||||
|
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
|
||||||
|
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
|
||||||
|
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
|
||||||
|
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.35.0 (23/09/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
|
||||||
|
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
|
||||||
|
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
|
||||||
|
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
|
||||||
|
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
|
||||||
|
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
|
||||||
|
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
|
||||||
|
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
|
||||||
|
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
|
||||||
|
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
|
||||||
|
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
|
||||||
|
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
|
||||||
|
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
|
||||||
|
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.34.0 (16/09/2024)
|
## v1.34.0 (16/09/2024)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
@@ -13,6 +13,29 @@
|
|||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||||
<text x="770.835px" y="299.13px" style="font-family:'HelveticaNeue', 'Helvetica Neue';font-size:50px;fill:rgb(94,94,219);">l<tspan x="782.685px 794.535px 823.085px 849.785px 880.185px 892.035px 920.585px " y="299.13px 299.13px 299.13px 299.13px 299.13px 299.13px 299.13px ">inkding</tspan></text>
|
<g transform="matrix(50,0,0,50,770.835,299.13)">
|
||||||
|
<rect x="0.064" y="-0.716" width="0.088" height="0.716" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,782.693,299.13)">
|
||||||
|
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,794.552,299.13)">
|
||||||
|
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,823.109,299.13)">
|
||||||
|
<path d="M0.066,-0L0.066,-0.716L0.154,-0.716L0.154,-0.308L0.362,-0.519L0.476,-0.519L0.278,-0.326L0.496,-0L0.388,-0L0.216,-0.265L0.154,-0.206L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,848.859,299.13)">
|
||||||
|
<path d="M0.402,-0L0.402,-0.065C0.369,-0.014 0.321,0.012 0.257,0.012C0.216,0.012 0.178,0 0.143,-0.022C0.109,-0.045 0.082,-0.077 0.063,-0.118C0.044,-0.159 0.034,-0.206 0.034,-0.259C0.034,-0.311 0.043,-0.357 0.06,-0.399C0.077,-0.442 0.103,-0.474 0.138,-0.497C0.172,-0.519 0.211,-0.53 0.253,-0.53C0.285,-0.53 0.313,-0.524 0.337,-0.51C0.361,-0.497 0.381,-0.48 0.396,-0.459L0.396,-0.716L0.484,-0.716L0.484,-0L0.402,-0ZM0.125,-0.259C0.125,-0.192 0.139,-0.143 0.167,-0.11C0.194,-0.077 0.228,-0.061 0.266,-0.061C0.304,-0.061 0.337,-0.076 0.363,-0.107C0.39,-0.139 0.404,-0.187 0.404,-0.251C0.404,-0.322 0.39,-0.375 0.363,-0.408C0.335,-0.441 0.302,-0.458 0.262,-0.458C0.223,-0.458 0.19,-0.442 0.164,-0.41C0.138,-0.378 0.125,-0.327 0.125,-0.259Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,877.417,299.13)">
|
||||||
|
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,889.275,299.13)">
|
||||||
|
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(50,0,0,50,917.833,299.13)">
|
||||||
|
<path d="M0.05,0.043L0.135,0.056C0.139,0.082 0.149,0.101 0.165,0.113C0.187,0.13 0.217,0.138 0.254,0.138C0.295,0.138 0.326,0.13 0.349,0.113C0.371,0.097 0.386,0.074 0.394,0.045C0.398,0.027 0.4,-0.011 0.4,-0.068C0.361,-0.023 0.314,-0 0.256,-0C0.185,-0 0.13,-0.026 0.091,-0.077C0.052,-0.129 0.032,-0.19 0.032,-0.262C0.032,-0.312 0.041,-0.357 0.059,-0.399C0.077,-0.441 0.103,-0.473 0.137,-0.496C0.171,-0.519 0.211,-0.53 0.257,-0.53C0.318,-0.53 0.368,-0.506 0.408,-0.456L0.408,-0.519L0.489,-0.519L0.489,-0.07C0.489,0.01 0.481,0.068 0.464,0.101C0.448,0.135 0.422,0.162 0.386,0.181C0.351,0.201 0.307,0.21 0.255,0.21C0.193,0.21 0.143,0.196 0.105,0.168C0.067,0.141 0.049,0.099 0.05,0.043ZM0.123,-0.269C0.123,-0.201 0.136,-0.151 0.163,-0.12C0.19,-0.088 0.224,-0.073 0.265,-0.073C0.305,-0.073 0.339,-0.088 0.366,-0.119C0.394,-0.15 0.407,-0.199 0.407,-0.266C0.407,-0.329 0.393,-0.377 0.365,-0.409C0.337,-0.441 0.303,-0.458 0.263,-0.458C0.224,-0.458 0.191,-0.442 0.164,-0.41C0.136,-0.378 0.123,-0.331 0.123,-0.269Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -49,6 +49,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"web_archive_snapshot_url",
|
"web_archive_snapshot_url",
|
||||||
"favicon_url",
|
"favicon_url",
|
||||||
"preview_image_url",
|
"preview_image_url",
|
||||||
|
"tag_names",
|
||||||
"date_added",
|
"date_added",
|
||||||
"date_modified",
|
"date_modified",
|
||||||
"website_title",
|
"website_title",
|
||||||
@@ -56,15 +57,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
list_serializer_class = BookmarkListSerializer
|
list_serializer_class = BookmarkListSerializer
|
||||||
|
|
||||||
# Override optional char fields to provide default value
|
# Custom tag_names field to allow passing a list of tag names to create/update
|
||||||
title = serializers.CharField(required=False, allow_blank=True, default="")
|
tag_names = TagListField(required=False)
|
||||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
# Custom fields to return URLs for favicon and preview image
|
||||||
notes = serializers.CharField(required=False, allow_blank=True, default="")
|
|
||||||
is_archived = serializers.BooleanField(required=False, default=False)
|
|
||||||
unread = serializers.BooleanField(required=False, default=False)
|
|
||||||
shared = serializers.BooleanField(required=False, default=False)
|
|
||||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
|
||||||
tag_names = TagListField(required=False, default=[])
|
|
||||||
favicon_url = serializers.SerializerMethodField()
|
favicon_url = serializers.SerializerMethodField()
|
||||||
preview_image_url = serializers.SerializerMethodField()
|
preview_image_url = serializers.SerializerMethodField()
|
||||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||||
@@ -94,15 +89,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
bookmark = Bookmark()
|
tag_names = validated_data.pop("tag_names", [])
|
||||||
bookmark.url = validated_data["url"]
|
tag_string = build_tag_string(tag_names)
|
||||||
bookmark.title = validated_data["title"]
|
bookmark = Bookmark(**validated_data)
|
||||||
bookmark.description = validated_data["description"]
|
|
||||||
bookmark.notes = validated_data["notes"]
|
|
||||||
bookmark.is_archived = validated_data["is_archived"]
|
|
||||||
bookmark.unread = validated_data["unread"]
|
|
||||||
bookmark.shared = validated_data["shared"]
|
|
||||||
tag_string = build_tag_string(validated_data["tag_names"])
|
|
||||||
|
|
||||||
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
|
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
|
||||||
# Unless scraping is explicitly disabled, enhance bookmark with website
|
# Unless scraping is explicitly disabled, enhance bookmark with website
|
||||||
@@ -113,18 +102,33 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
return saved_bookmark
|
return saved_bookmark
|
||||||
|
|
||||||
def update(self, instance: Bookmark, validated_data):
|
def update(self, instance: Bookmark, validated_data):
|
||||||
# Update fields if they were provided in the payload
|
tag_names = validated_data.pop("tag_names", instance.tag_names)
|
||||||
for key in ["url", "title", "description", "notes", "unread", "shared"]:
|
tag_string = build_tag_string(tag_names)
|
||||||
if key in validated_data:
|
|
||||||
setattr(instance, key, validated_data[key])
|
|
||||||
|
|
||||||
# Use tag string from payload, or use bookmark's current tags as fallback
|
for field_name, field in self.fields.items():
|
||||||
tag_string = build_tag_string(instance.tag_names)
|
if not field.read_only and field_name in validated_data:
|
||||||
if "tag_names" in validated_data:
|
setattr(instance, field_name, validated_data[field_name])
|
||||||
tag_string = build_tag_string(validated_data["tag_names"])
|
|
||||||
|
|
||||||
return update_bookmark(instance, tag_string, self.context["user"])
|
return update_bookmark(instance, tag_string, self.context["user"])
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||||
|
# updating the existing bookmark instead. When editing a bookmark,
|
||||||
|
# there is no assumption that it would update a different bookmark if
|
||||||
|
# the URL is a duplicate, so raise a validation error in that case.
|
||||||
|
if self.instance and "url" in attrs:
|
||||||
|
is_duplicate = (
|
||||||
|
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
|
||||||
|
.exclude(pk=self.instance.pk)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if is_duplicate:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"url": "A bookmark with this URL already exists."}
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class TagSerializer(serializers.ModelSerializer):
|
class TagSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
48
bookmarks/e2e/e2e_test_collapse_side_panel.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
def assertSidePanelIsVisible(self):
|
||||||
|
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
|
||||||
|
expect(
|
||||||
|
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||||
|
).not_to_be_visible()
|
||||||
|
|
||||||
|
def assertSidePanelIsHidden(self):
|
||||||
|
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
|
||||||
|
expect(
|
||||||
|
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||||
|
).to_be_visible()
|
||||||
|
|
||||||
|
def test_side_panel_should_be_visible_by_default(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
self.assertSidePanelIsVisible()
|
||||||
|
|
||||||
|
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
|
||||||
|
self.assertSidePanelIsVisible()
|
||||||
|
|
||||||
|
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
|
||||||
|
self.assertSidePanelIsVisible()
|
||||||
|
|
||||||
|
def test_side_panel_should_be_hidden_when_collapsed(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
user.profile.collapse_side_panel = True
|
||||||
|
user.profile.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
self.assertSidePanelIsHidden()
|
||||||
|
|
||||||
|
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
|
||||||
|
self.assertSidePanelIsHidden()
|
||||||
|
|
||||||
|
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
|
||||||
|
self.assertSidePanelIsHidden()
|
||||||
@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
|
|||||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||||
def test_show_modal_close_modal(self):
|
def test_show_modal_close_modal(self):
|
||||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||||
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
page = self.open(reverse("bookmarks:index"), p)
|
page = self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
# use smaller viewport to make tags button visible
|
# use smaller viewport to make filter button visible
|
||||||
page.set_viewport_size({"width": 375, "height": 812})
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
|
||||||
# open tag cloud modal
|
# open drawer
|
||||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||||
"button", name="Tags"
|
"button", name="Filters"
|
||||||
)
|
)
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# verify modal is visible
|
# verify drawer is visible
|
||||||
modal = page.locator(".modal")
|
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||||
expect(modal).to_be_visible()
|
expect(drawer).to_be_visible()
|
||||||
expect(modal.locator("h2")).to_have_text("Tags")
|
expect(drawer.locator("h2")).to_have_text("Filters")
|
||||||
|
|
||||||
# close with close button
|
# close with close button
|
||||||
modal.locator("button.close").click()
|
drawer.locator("button.close").click()
|
||||||
expect(modal).to_be_hidden()
|
expect(drawer).to_be_hidden()
|
||||||
|
|
||||||
# open modal again
|
# open drawer again
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# close with backdrop
|
# close with backdrop
|
||||||
backdrop = modal.locator(".modal-overlay")
|
backdrop = drawer.locator(".modal-overlay")
|
||||||
backdrop.click(position={"x": 0, "y": 0})
|
backdrop.click(position={"x": 0, "y": 0})
|
||||||
expect(modal).to_be_hidden()
|
expect(drawer).to_be_hidden()
|
||||||
|
|
||||||
def test_select_tag(self):
|
def test_select_tag(self):
|
||||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||||
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
page = self.open(reverse("bookmarks:index"), p)
|
page = self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
# use smaller viewport to make tags button visible
|
# use smaller viewport to make filter button visible
|
||||||
page.set_viewport_size({"width": 375, "height": 812})
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
|
||||||
# open tag cloud modal
|
# open tag cloud modal
|
||||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||||
"button", name="Tags"
|
"button", name="Filters"
|
||||||
)
|
)
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# verify tags are displayed
|
# verify tags are displayed
|
||||||
modal = page.locator(".modal")
|
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||||
unselected_tags = modal.locator(".unselected-tags")
|
unselected_tags = drawer.locator(".unselected-tags")
|
||||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||||
|
|
||||||
# select tag
|
# select tag
|
||||||
unselected_tags.get_by_text("cooking").click()
|
unselected_tags.get_by_text("cooking").click()
|
||||||
|
|
||||||
# open modal again
|
# open drawer again
|
||||||
modal_trigger.click()
|
drawer_trigger.click()
|
||||||
|
|
||||||
# verify tag is selected, other tag is not visible anymore
|
# verify tag is selected, other tag is not visible anymore
|
||||||
selected_tags = modal.locator(".selected-tags")
|
selected_tags = drawer.locator(".selected-tags")
|
||||||
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||||
|
|
||||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
||||||
@@ -1,61 +1,28 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { registerBehavior } from "./index";
|
||||||
|
import { isKeyboardActive } from "./focus-utils";
|
||||||
|
import { ModalBehavior } from "./modal";
|
||||||
|
|
||||||
class DetailsModalBehavior extends Behavior {
|
class DetailsModalBehavior extends ModalBehavior {
|
||||||
constructor(element) {
|
doClose() {
|
||||||
super(element);
|
super.doClose();
|
||||||
|
|
||||||
this.onClose = this.onClose.bind(this);
|
// Navigate to close URL
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
const closeUrl = this.element.dataset.closeUrl;
|
||||||
|
Turbo.visit(closeUrl, {
|
||||||
|
action: "replace",
|
||||||
|
frame: "details-modal",
|
||||||
|
});
|
||||||
|
|
||||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
// Try restore focus to view details to view details link of respective bookmark
|
||||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
const bookmarkId = this.element.dataset.bookmarkId;
|
||||||
|
const restoreFocusElement =
|
||||||
|
document.querySelector(
|
||||||
|
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||||
|
) ||
|
||||||
|
document.querySelector("ul.bookmark-list") ||
|
||||||
|
document.body;
|
||||||
|
|
||||||
this.overlayLink.addEventListener("click", this.onClose);
|
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
|
|||||||
this.opened = false;
|
this.opened = false;
|
||||||
this.onClick = this.onClick.bind(this);
|
this.onClick = this.onClick.bind(this);
|
||||||
this.onOutsideClick = this.onOutsideClick.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 = element.querySelector(".dropdown-toggle");
|
||||||
|
this.toggle.setAttribute("aria-expanded", "false");
|
||||||
this.toggle.addEventListener("click", this.onClick);
|
this.toggle.addEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.close();
|
this.close();
|
||||||
this.toggle.removeEventListener("click", this.onClick);
|
this.toggle.removeEventListener("click", this.onClick);
|
||||||
|
this.element.removeEventListener("keydown", this.onEscape);
|
||||||
|
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
this.opened = true;
|
||||||
this.element.classList.add("active");
|
this.element.classList.add("active");
|
||||||
|
this.toggle.setAttribute("aria-expanded", "true");
|
||||||
document.addEventListener("click", this.onOutsideClick);
|
document.addEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
this.opened = false;
|
||||||
this.element.classList.remove("active");
|
this.element.classList.remove("active");
|
||||||
|
this.toggle.setAttribute("aria-expanded", "false");
|
||||||
document.removeEventListener("click", this.onOutsideClick);
|
document.removeEventListener("click", this.onOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
|
|||||||
this.close();
|
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);
|
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||||
|
|||||||
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
import { ModalBehavior } from "./modal";
|
||||||
|
import { isKeyboardActive } from "./focus-utils";
|
||||||
|
|
||||||
|
class FilterDrawerTriggerBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClick = this.onClick.bind(this);
|
||||||
|
|
||||||
|
element.addEventListener("click", this.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.element.removeEventListener("click", this.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.classList.add("modal", "drawer", "filter-drawer");
|
||||||
|
modal.setAttribute("ld-filter-drawer", "");
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<button class="close" aria-label="Close dialog">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M18 6l-12 12"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<section class="content content-area"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.querySelector(".modals").appendChild(modal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FilterDrawerBehavior extends ModalBehavior {
|
||||||
|
init() {
|
||||||
|
// Teleport content before creating focus trap, otherwise it will not detect
|
||||||
|
// focusable content elements
|
||||||
|
this.teleport();
|
||||||
|
super.init();
|
||||||
|
// Add active class to start slide-in animation
|
||||||
|
this.element.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
super.destroy();
|
||||||
|
// Always close on destroy to restore drawer content to original location
|
||||||
|
// before turbo caches DOM
|
||||||
|
this.doClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
mapHeading(container, from, to) {
|
||||||
|
const headings = container.querySelectorAll(from);
|
||||||
|
headings.forEach((heading) => {
|
||||||
|
const newHeading = document.createElement(to);
|
||||||
|
newHeading.textContent = heading.textContent;
|
||||||
|
heading.replaceWith(newHeading);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
teleport() {
|
||||||
|
const content = this.element.querySelector(".content");
|
||||||
|
const sidePanel = document.querySelector("section.side-panel");
|
||||||
|
content.append(...sidePanel.children);
|
||||||
|
this.mapHeading(content, "h2", "h3");
|
||||||
|
}
|
||||||
|
|
||||||
|
teleportBack() {
|
||||||
|
const sidePanel = document.querySelector("section.side-panel");
|
||||||
|
const content = this.element.querySelector(".content");
|
||||||
|
sidePanel.append(...content.children);
|
||||||
|
this.mapHeading(sidePanel, "h3", "h2");
|
||||||
|
}
|
||||||
|
|
||||||
|
doClose() {
|
||||||
|
super.doClose();
|
||||||
|
this.teleportBack();
|
||||||
|
|
||||||
|
// Try restore focus to drawer trigger
|
||||||
|
const restoreFocusElement =
|
||||||
|
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
||||||
|
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
||||||
|
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
||||||
59
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
let keyboardActive = false;
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
() => {
|
||||||
|
keyboardActive = true;
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"mousedown",
|
||||||
|
() => {
|
||||||
|
keyboardActive = false;
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isKeyboardActive() {
|
||||||
|
return keyboardActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FocusTrapController {
|
||||||
|
constructor(element) {
|
||||||
|
this.element = element;
|
||||||
|
this.focusableElements = this.element.querySelectorAll(
|
||||||
|
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
|
||||||
|
);
|
||||||
|
this.firstFocusableElement = this.focusableElements[0];
|
||||||
|
this.lastFocusableElement =
|
||||||
|
this.focusableElements[this.focusableElements.length - 1];
|
||||||
|
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
|
||||||
|
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
|
||||||
|
this.element.addEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
if (event.key !== "Tab") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (document.activeElement === this.firstFocusableElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.lastFocusableElement.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === this.lastFocusableElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.firstFocusableElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
bookmarks/frontend/behaviors/modal.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Behavior } from "./index";
|
||||||
|
import { FocusTrapController } from "./focus-utils";
|
||||||
|
|
||||||
|
export class ModalBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
|
||||||
|
this.overlay = element.querySelector(".modal-overlay");
|
||||||
|
this.closeButton = element.querySelector(".modal-header .close");
|
||||||
|
|
||||||
|
this.overlay.addEventListener("click", this.onClose);
|
||||||
|
this.closeButton.addEventListener("click", this.onClose);
|
||||||
|
document.addEventListener("keydown", this.onKeyDown);
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.overlay.removeEventListener("click", this.onClose);
|
||||||
|
this.closeButton.removeEventListener("click", this.onClose);
|
||||||
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
|
||||||
|
this.clearInert();
|
||||||
|
this.focusTrap.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupInert();
|
||||||
|
this.focusTrap = new FocusTrapController(
|
||||||
|
this.element.querySelector(".modal-container"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupInert() {
|
||||||
|
// Inert all other elements on the page
|
||||||
|
document
|
||||||
|
.querySelectorAll("body > *:not(.modals)")
|
||||||
|
.forEach((el) => el.setAttribute("inert", ""));
|
||||||
|
// Lock scroll on the body
|
||||||
|
document.body.classList.add("scroll-lock");
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInert() {
|
||||||
|
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||||
|
document
|
||||||
|
.querySelectorAll("body > *")
|
||||||
|
.forEach((el) => el.removeAttribute("inert"));
|
||||||
|
// Remove scroll lock from the body
|
||||||
|
document.body.classList.remove("scroll-lock");
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget =
|
||||||
|
targetNodeName === "INPUT" ||
|
||||||
|
targetNodeName === "SELECT" ||
|
||||||
|
targetNodeName === "TEXTAREA";
|
||||||
|
|
||||||
|
if (isInputTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
this.onClose(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.element.classList.add("closing");
|
||||||
|
this.element.addEventListener(
|
||||||
|
"animationend",
|
||||||
|
(event) => {
|
||||||
|
if (event.animationName === "fade-out") {
|
||||||
|
this.doClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doClose() {
|
||||||
|
this.element.remove();
|
||||||
|
this.clearInert();
|
||||||
|
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
|
||||||
|
|
||||||
class TagModalBehavior extends Behavior {
|
|
||||||
constructor(element) {
|
|
||||||
super(element);
|
|
||||||
|
|
||||||
this.onClick = this.onClick.bind(this);
|
|
||||||
this.onClose = this.onClose.bind(this);
|
|
||||||
|
|
||||||
element.addEventListener("click", this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.onClose();
|
|
||||||
this.element.removeEventListener("click", this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick() {
|
|
||||||
const modal = document.createElement("div");
|
|
||||||
modal.classList.add("modal", "active");
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal-overlay" aria-label="Close"></div>
|
|
||||||
<div class="modal-container">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Tags</h2>
|
|
||||||
<button class="close" aria-label="Close">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
||||||
<path d="M18 6l-12 12"></path>
|
|
||||||
<path d="M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const tagCloud = document.querySelector(".tag-cloud");
|
|
||||||
const tagCloudContainer = tagCloud.parentElement;
|
|
||||||
|
|
||||||
const content = modal.querySelector(".content");
|
|
||||||
content.appendChild(tagCloud);
|
|
||||||
|
|
||||||
const overlay = modal.querySelector(".modal-overlay");
|
|
||||||
const closeButton = modal.querySelector(".close");
|
|
||||||
overlay.addEventListener("click", this.onClose);
|
|
||||||
closeButton.addEventListener("click", this.onClose);
|
|
||||||
|
|
||||||
this.modal = modal;
|
|
||||||
this.tagCloud = tagCloud;
|
|
||||||
this.tagCloudContainer = tagCloudContainer;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
if (!this.modal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.modal.remove();
|
|
||||||
this.tagCloudContainer.appendChild(this.tagCloud);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBehavior("ld-tag-modal", TagModalBehavior);
|
|
||||||
@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
|
|||||||
import "./behaviors/bulk-edit";
|
import "./behaviors/bulk-edit";
|
||||||
import "./behaviors/clear-button";
|
import "./behaviors/clear-button";
|
||||||
import "./behaviors/confirm-button";
|
import "./behaviors/confirm-button";
|
||||||
import "./behaviors/dropdown";
|
|
||||||
import "./behaviors/form";
|
|
||||||
import "./behaviors/details-modal";
|
import "./behaviors/details-modal";
|
||||||
|
import "./behaviors/dropdown";
|
||||||
|
import "./behaviors/filter-drawer";
|
||||||
|
import "./behaviors/form";
|
||||||
import "./behaviors/global-shortcuts";
|
import "./behaviors/global-shortcuts";
|
||||||
import "./behaviors/search-autocomplete";
|
import "./behaviors/search-autocomplete";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./behaviors/tag-autocomplete";
|
||||||
import "./behaviors/tag-modal";
|
|
||||||
|
|
||||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||||
|
|||||||
18
bookmarks/migrations/0042_userprofile_custom_css_hash.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-09-28 08:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0041_merge_metadata"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="custom_css_hash",
|
||||||
|
field=models.CharField(blank=True, max_length=32),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-02 09:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0042_userprofile_custom_css_hash"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="collapse_side_panel",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import binascii
|
import binascii
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -168,6 +169,24 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
self.instance and self.instance.notes
|
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:
|
class BookmarkSearch:
|
||||||
SORT_ADDED_ASC = "added_asc"
|
SORT_ADDED_ASC = "added_asc"
|
||||||
@@ -412,6 +431,7 @@ class UserProfile(models.Model):
|
|||||||
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
permanent_notes = models.BooleanField(default=False, null=False)
|
permanent_notes = models.BooleanField(default=False, null=False)
|
||||||
custom_css = models.TextField(blank=True, null=False)
|
custom_css = models.TextField(blank=True, null=False)
|
||||||
|
custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
|
||||||
auto_tagging_rules = models.TextField(blank=True, null=False)
|
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||||
search_preferences = models.JSONField(default=dict, null=False)
|
search_preferences = models.JSONField(default=dict, null=False)
|
||||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||||
@@ -420,6 +440,16 @@ class UserProfile(models.Model):
|
|||||||
null=False, default=30, validators=[MinValueValidator(10)]
|
null=False, default=30, validators=[MinValueValidator(10)]
|
||||||
)
|
)
|
||||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||||
|
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.custom_css:
|
||||||
|
self.custom_css_hash = hashlib.md5(
|
||||||
|
self.custom_css.encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
else:
|
||||||
|
self.custom_css_hash = ""
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
@@ -450,6 +480,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"auto_tagging_rules",
|
"auto_tagging_rules",
|
||||||
"items_per_page",
|
"items_per_page",
|
||||||
"sticky_pagination",
|
"sticky_pagination",
|
||||||
|
"collapse_side_panel",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ def get_tags(script: str, url: str):
|
|||||||
parsed_url = urlparse(url.lower())
|
parsed_url = urlparse(url.lower())
|
||||||
result = set()
|
result = set()
|
||||||
|
|
||||||
|
if not parsed_url.hostname:
|
||||||
|
return result
|
||||||
|
|
||||||
for line in script.lower().split("\n"):
|
for line in script.lower().split("\n"):
|
||||||
if "#" in line:
|
if "#" in line:
|
||||||
i = line.index("#")
|
i = line.index("#")
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
|||||||
if has_url_changed:
|
if has_url_changed:
|
||||||
# Update web archive snapshot, if URL changed
|
# Update web archive snapshot, if URL changed
|
||||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||||
bookmark.save()
|
|
||||||
|
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
|||||||
toread = "1" if bookmark.unread else "0"
|
toread = "1" if bookmark.unread else "0"
|
||||||
private = "0" if bookmark.shared else "1"
|
private = "0" if bookmark.shared else "1"
|
||||||
added = int(bookmark.date_added.timestamp())
|
added = int(bookmark.date_added.timestamp())
|
||||||
|
modified = int(bookmark.date_modified.timestamp())
|
||||||
|
|
||||||
doc.append(
|
doc.append(
|
||||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
f'<DT><A HREF="{url}" ADD_DATE="{added}" LAST_MODIFIED="{modified}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||||
)
|
)
|
||||||
|
|
||||||
if desc:
|
if desc:
|
||||||
|
|||||||
@@ -231,7 +231,10 @@ def _copy_bookmark_data(
|
|||||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||||
else:
|
else:
|
||||||
bookmark.date_added = timezone.now()
|
bookmark.date_added = timezone.now()
|
||||||
bookmark.date_modified = bookmark.date_added
|
if netscape_bookmark.date_modified:
|
||||||
|
bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)
|
||||||
|
else:
|
||||||
|
bookmark.date_modified = bookmark.date_added
|
||||||
bookmark.unread = netscape_bookmark.to_read
|
bookmark.unread = netscape_bookmark.to_read
|
||||||
if netscape_bookmark.title:
|
if netscape_bookmark.title:
|
||||||
bookmark.title = netscape_bookmark.title
|
bookmark.title = netscape_bookmark.title
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class NetscapeBookmark:
|
|||||||
description: str
|
description: str
|
||||||
notes: str
|
notes: str
|
||||||
date_added: str
|
date_added: str
|
||||||
|
date_modified: str
|
||||||
tag_names: List[str]
|
tag_names: List[str]
|
||||||
to_read: bool
|
to_read: bool
|
||||||
private: bool
|
private: bool
|
||||||
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
|
|||||||
self.bookmark = None
|
self.bookmark = None
|
||||||
self.href = ""
|
self.href = ""
|
||||||
self.add_date = ""
|
self.add_date = ""
|
||||||
|
self.last_modified = ""
|
||||||
self.tags = ""
|
self.tags = ""
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.description = ""
|
self.description = ""
|
||||||
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
|
|||||||
description="",
|
description="",
|
||||||
notes="",
|
notes="",
|
||||||
date_added=self.add_date,
|
date_added=self.add_date,
|
||||||
|
date_modified=self.last_modified,
|
||||||
tag_names=tag_names,
|
tag_names=tag_names,
|
||||||
to_read=self.toread == "1",
|
to_read=self.toread == "1",
|
||||||
# Mark as private by default, also when attribute is not specified
|
# Mark as private by default, also when attribute is not specified
|
||||||
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
|
|||||||
self.bookmark = None
|
self.bookmark = None
|
||||||
self.href = ""
|
self.href = ""
|
||||||
self.add_date = ""
|
self.add_date = ""
|
||||||
|
self.last_modified = ""
|
||||||
self.tags = ""
|
self.tags = ""
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.description = ""
|
self.description = ""
|
||||||
|
|||||||
2
bookmarks/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
& .preview-image {
|
& .preview-image {
|
||||||
margin: var(--unit-4 0);
|
margin: var(--unit-4) 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -36,8 +36,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& dl {
|
& .sections section {
|
||||||
margin-bottom: 0;
|
margin-top: var(--unit-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .sections h3 {
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
font-size: var(--font-size);
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .assets {
|
& .assets {
|
||||||
|
|||||||
@@ -10,8 +10,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Bookmark page grid */
|
/* Bookmark page grid */
|
||||||
.bookmarks-page.grid {
|
.bookmarks-page {
|
||||||
grid-gap: var(--unit-9);
|
&.grid {
|
||||||
|
grid-gap: var(--unit-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ld-filter-drawer-trigger] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 840px) {
|
||||||
|
section.side-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ld-filter-drawer-trigger] {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.collapse-side-panel {
|
||||||
|
section.main {
|
||||||
|
grid-column: span var(--grid-columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.side-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ld-filter-drawer-trigger] {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bookmark area header controls */
|
/* Bookmark area header controls */
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
/* Content area component */
|
/* Content area component */
|
||||||
section.content-area {
|
section.content-area {
|
||||||
h2 {
|
h2,
|
||||||
|
h3 {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ section.content-area {
|
|||||||
padding-bottom: var(--unit-2);
|
padding-bottom: var(--unit-2);
|
||||||
margin-bottom: var(--unit-4);
|
margin-bottom: var(--unit-4);
|
||||||
|
|
||||||
h2 {
|
h2,
|
||||||
|
h3 {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
line-height: var(--unit-9);
|
line-height: var(--unit-9);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -141,3 +141,10 @@
|
|||||||
--bookmark-actions-weight: 400;
|
--bookmark-actions-weight: 400;
|
||||||
--bulk-actions-bg-color: var(--contrast-5);
|
--bulk-actions-bg-color: var(--contrast-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Try to force dark color scheme for all native elements (e.g. upload button
|
||||||
|
in file inputs, native select dropdown). For the select dropdown some browsers
|
||||||
|
ignore this and use whatever users have configured in their system settings. */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ html {
|
|||||||
font-size: var(--html-font-size);
|
font-size: var(--html-font-size);
|
||||||
line-height: var(--html-line-height);
|
line-height: var(--html-line-height);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* Dropdown */
|
/* Dropdown */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
|
--dropdown-focus-display: block;
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -20,9 +22,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active .menu,
|
&:focus-within .menu {
|
||||||
.dropdown-toggle:focus + .menu,
|
/* Use custom CSS property to allow disabling opening on focus when using JS */
|
||||||
.menu:hover {
|
display: var(--dropdown-focus-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .menu {
|
||||||
|
/* Always show menu when class is added through JS */
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ textarea.form-input {
|
|||||||
no-repeat right 0.35rem center / 0.4rem 0.5rem;
|
no-repeat right 0.35rem center / 0.4rem 0.5rem;
|
||||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Options */
|
||||||
|
& option {
|
||||||
|
/* On Windows with Chrome / Edge, options seems to use the same
|
||||||
|
background color as the select. However for the dark theme the
|
||||||
|
background is a semi-transparent white, resulting in an opaque white
|
||||||
|
background for the dropdown. Use the modal background color to force
|
||||||
|
a dark background instead. */
|
||||||
|
background: var(--modal-container-bg-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form element: Checkbox and Radio */
|
/* Form element: Checkbox and Radio */
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
display: block;
|
display: block;
|
||||||
left: 0;
|
left: 0;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
@@ -62,13 +62,14 @@
|
|||||||
gap: var(--unit-4);
|
gap: var(--unit-4);
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
max-width: var(--control-width-md);
|
max-width: var(--control-width-md);
|
||||||
padding: var(--unit-6);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
& .modal-header {
|
& .modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--unit-2);
|
gap: var(--unit-2);
|
||||||
|
padding: var(--unit-6);
|
||||||
|
padding-bottom: 0;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
||||||
& h2 {
|
& h2 {
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& button.close {
|
& .close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -95,10 +96,53 @@
|
|||||||
|
|
||||||
& .modal-body {
|
& .modal-body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
padding: 0 var(--unit-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .modal-body:not(:has(+ .modal-footer)) {
|
||||||
|
margin-bottom: var(--unit-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .modal-footer {
|
& .modal-footer {
|
||||||
|
padding: var(--unit-6);
|
||||||
|
padding-top: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal.drawer {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
& .modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-left: solid 1px var(--modal-container-border-color);
|
||||||
|
border-radius: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
animation: fade-in 0.25s ease 1;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
& .modal-container {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active.closing {
|
||||||
|
& .modal-container {
|
||||||
|
animation: fade-out 0.25s ease 1;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-lock {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 #}
|
{# Bookmark list #}
|
||||||
<section class="content-area col-2">
|
<section class="main content-area col-2">
|
||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Archived bookmarks</h2>
|
<h2>Archived bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Tag cloud #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="side-panel content-area col-1">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,12 +39,14 @@
|
|||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Bookmark details #}
|
|
||||||
<turbo-frame id="details-modal" target="_top">
|
|
||||||
{% if details %}
|
|
||||||
{% include 'bookmarks/details/modal.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</turbo-frame>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlays %}
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
<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 }};"
|
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||||
{% for bookmark_item in bookmark_list.items %}
|
{% for bookmark_item in bookmark_list.items %}
|
||||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||||
|
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<label class="form-checkbox bulk-edit-checkbox">
|
<label class="form-checkbox bulk-edit-checkbox">
|
||||||
@@ -78,7 +80,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{# View link is visible for both owned and shared bookmarks #}
|
{# View link is visible for both owned and shared bookmarks #}
|
||||||
{% if bookmark_list.show_view_action %}
|
{% if bookmark_list.show_view_action %}
|
||||||
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||||
|
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.is_editable %}
|
{% if bookmark_item.is_editable %}
|
||||||
{# Bookmark owner actions #}
|
{# Bookmark owner actions #}
|
||||||
|
|||||||
@@ -40,14 +40,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||||
<div class="preview-image">
|
<div class="preview-image">
|
||||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if details.is_editable %}
|
||||||
<div class="status col-2">
|
<section class="status col-2">
|
||||||
<dt>Status</dt>
|
<h3>Status</h3>
|
||||||
<dd class="d-flex" style="gap: .8rem">
|
<div class="d-flex" style="gap: .8rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-switch">
|
<label class="form-switch">
|
||||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||||
@@ -71,44 +71,44 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.show_files %}
|
{% if details.show_files %}
|
||||||
<div class="files col-2">
|
<section class="files col-2">
|
||||||
<dt>Files</dt>
|
<h3>Files</h3>
|
||||||
<dd>
|
<div>
|
||||||
{% include 'bookmarks/details/assets.html' %}
|
{% include 'bookmarks/details/assets.html' %}
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.bookmark.tag_names %}
|
{% if details.bookmark.tag_names %}
|
||||||
<div class="tags col-1">
|
<section class="tags col-1">
|
||||||
<dt>Tags</dt>
|
<h3 id="details-modal-tags-title">Tags</h3>
|
||||||
<dd>
|
<div>
|
||||||
{% for tag_name in details.bookmark.tag_names %}
|
{% 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 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="date-added col-1">
|
<section class="date-added col-1">
|
||||||
<dt>Date added</dt>
|
<h3>Date added</h3>
|
||||||
<dd>
|
<div>
|
||||||
<span>{{ details.bookmark.date_added }}</span>
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
{% if details.bookmark.resolved_description %}
|
||||||
|
<section class="description col-2">
|
||||||
|
<h3>Description</h3>
|
||||||
|
<div>{{ details.bookmark.resolved_description }}</div>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.bookmark.notes %}
|
{% if details.bookmark.notes %}
|
||||||
<div class="notes col-2">
|
<section class="notes col-2">
|
||||||
<dt>Notes</dt>
|
<h3>Notes</h3>
|
||||||
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||||
</div>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
<div class="modal active bookmark-details"
|
<div class="modal active bookmark-details" ld-details-modal
|
||||||
ld-details-modal>
|
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
|
||||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
<div class="modal-overlay"></div>
|
||||||
<div class="modal-overlay" aria-label="Close"></div>
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
</a>
|
|
||||||
<div class="modal-container">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
<button class="close" aria-label="Close dialog">
|
||||||
<button class="close">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
<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">
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path d="M18 6l-12 12"></path>
|
||||||
<path d="M18 6l-12 12"></path>
|
<path d="M6 6l12 12"></path>
|
||||||
<path d="M6 6l12 12"></path>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -30,11 +30,14 @@
|
|||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.user_profile.custom_css %}
|
{% if request.user_profile.custom_css %}
|
||||||
<style>{{ request.user_profile.custom_css }}</style>
|
<link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<meta name="turbo-cache-control" content="no-preview">
|
<meta name="turbo-cache-control" content="no-preview">
|
||||||
{% if not request.global_settings.enable_link_prefetch %}
|
{% if not request.global_settings.enable_link_prefetch %}
|
||||||
<meta name="turbo-prefetch" content="false">
|
<meta name="turbo-prefetch" content="false">
|
||||||
{% endif %}
|
{% 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>
|
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -4,16 +4,17 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 #}
|
{# Bookmark list #}
|
||||||
<section class="content-area col-2">
|
<section class="main content-area col-2">
|
||||||
<div class="content-area-header mb-0">
|
<div class="content-area-header mb-0">
|
||||||
<h2>Bookmarks</h2>
|
<h2>Bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search %}
|
{% bookmark_search bookmark_list.search %}
|
||||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Tag cloud #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="side-panel content-area col-1">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,12 +39,14 @@
|
|||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Bookmark details #}
|
|
||||||
<turbo-frame id="details-modal" target="_top">
|
|
||||||
{% if details %}
|
|
||||||
{% include 'bookmarks/details/modal.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</turbo-frame>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlays %}
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
|
{% endblock %}
|
||||||
@@ -97,5 +97,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modals">
|
||||||
|
{% block overlays %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
{# Basic menu list #}
|
{# Basic menu list #}
|
||||||
<div class="hide-md">
|
<div class="hide-md">
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||||
<div class="dropdown">
|
<div ld-dropdown class="dropdown">
|
||||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||||
Bookmarks
|
Bookmarks
|
||||||
</button>
|
</button>
|
||||||
<ul class="menu">
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -35,21 +35,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{# Menu drop-down for smaller devices #}
|
{# Menu drop-down for smaller devices #}
|
||||||
<div class="show-md">
|
<div class="show-md">
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
<a href="{% url 'bookmarks:new' %}" aria-label="Add bookmark" class="btn btn-primary">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
style="width: 24px; height: 24px">
|
style="width: 24px; height: 24px">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<div ld-dropdown class="dropdown dropdown-right">
|
<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"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
style="width: 24px; height: 24px">
|
style="width: 24px; height: 24px">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- menu component -->
|
<!-- menu component -->
|
||||||
<ul class="menu">
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</form>
|
</form>
|
||||||
<div ld-dropdown class="search-options dropdown dropdown-right">
|
<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"
|
<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">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
@@ -41,8 +42,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'shared' in preferences_form.editable_fields %}
|
{% if 'shared' in preferences_form.editable_fields %}
|
||||||
<div class="form-group radio-group">
|
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
|
||||||
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
|
<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 %}
|
{% for radio in preferences_form.shared %}
|
||||||
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
||||||
{{ radio.tag }}
|
{{ radio.tag }}
|
||||||
@@ -53,8 +57,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'unread' in preferences_form.editable_fields %}
|
{% if 'unread' in preferences_form.editable_fields %}
|
||||||
<div class="form-group radio-group">
|
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
|
||||||
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
|
<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 %}
|
{% for radio in preferences_form.unread %}
|
||||||
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
|
||||||
{{ radio.tag }}
|
{{ radio.tag }}
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 #}
|
{# Bookmark list #}
|
||||||
<section class="content-area col-2">
|
<section class="main content-area col-2">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Shared bookmarks</h2>
|
<h2>Shared bookmarks</h2>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Filters #}
|
{# Filters #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="side-panel content-area col-1">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>User</h2>
|
<h2>User</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,12 +43,14 @@
|
|||||||
{% include 'bookmarks/tag_cloud.html' %}
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Bookmark details #}
|
|
||||||
<turbo-frame id="details-modal" target="_top">
|
|
||||||
{% if details %}
|
|
||||||
{% include 'bookmarks/details/modal.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</turbo-frame>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlays %}
|
||||||
|
{# Bookmark details #}
|
||||||
|
<turbo-frame id="details-modal" target="_top">
|
||||||
|
{% if details %}
|
||||||
|
{% include 'bookmarks/details/modal.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -124,6 +124,16 @@
|
|||||||
visible without having to scroll to the end of the page first.
|
visible without having to scroll to the end of the page first.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
<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" }}
|
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class BookmarkFactoryMixin:
|
|||||||
favicon_file: str = "",
|
favicon_file: str = "",
|
||||||
preview_image_file: str = "",
|
preview_image_file: str = "",
|
||||||
added: datetime = None,
|
added: datetime = None,
|
||||||
|
modified: datetime = None,
|
||||||
):
|
):
|
||||||
if title is None:
|
if title is None:
|
||||||
title = get_random_string(length=32)
|
title = get_random_string(length=32)
|
||||||
@@ -57,13 +58,15 @@ class BookmarkFactoryMixin:
|
|||||||
url = "https://example.com/" + unique_id
|
url = "https://example.com/" + unique_id
|
||||||
if added is None:
|
if added is None:
|
||||||
added = timezone.now()
|
added = timezone.now()
|
||||||
|
if modified is None:
|
||||||
|
modified = timezone.now()
|
||||||
bookmark = Bookmark(
|
bookmark = Bookmark(
|
||||||
url=url,
|
url=url,
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
date_added=added,
|
date_added=added,
|
||||||
date_modified=timezone.now(),
|
date_modified=modified,
|
||||||
owner=user,
|
owner=user,
|
||||||
is_archived=is_archived,
|
is_archived=is_archived,
|
||||||
unread=unread,
|
unread=unread,
|
||||||
@@ -320,6 +323,7 @@ class BookmarkHtmlTag:
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
description: str = "",
|
description: str = "",
|
||||||
add_date: str = "",
|
add_date: str = "",
|
||||||
|
last_modified: str = "",
|
||||||
tags: str = "",
|
tags: str = "",
|
||||||
to_read: bool = False,
|
to_read: bool = False,
|
||||||
private: bool = True,
|
private: bool = True,
|
||||||
@@ -328,6 +332,7 @@ class BookmarkHtmlTag:
|
|||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.add_date = add_date
|
self.add_date = add_date
|
||||||
|
self.last_modified = last_modified
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.to_read = to_read
|
self.to_read = to_read
|
||||||
self.private = private
|
self.private = private
|
||||||
@@ -339,6 +344,7 @@ class ImportTestMixin:
|
|||||||
<DT>
|
<DT>
|
||||||
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
||||||
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
||||||
|
{f'LAST_MODIFIED="{tag.last_modified}"' if tag.last_modified else ''}
|
||||||
{f'TAGS="{tag.tags}"' if tag.tags else ''}
|
{f'TAGS="{tag.tags}"' if tag.tags else ''}
|
||||||
TOREAD="{1 if tag.to_read else 0}"
|
TOREAD="{1 if tag.to_read else 0}"
|
||||||
PRIVATE="{1 if tag.private else 0}">
|
PRIVATE="{1 if tag.private else 0}">
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(tags, {"example"})
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_handles_invalid_urls(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
test.com test
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = "https://"
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
self.assertEqual(tags, set([]))
|
||||||
|
|
||||||
|
url = "example.com"
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
self.assertEqual(tags, set([]))
|
||||||
|
|
||||||
def test_auto_tag_by_domain_works_with_port(self):
|
def test_auto_tag_by_domain_works_with_port(self):
|
||||||
script = """
|
script = """
|
||||||
example.com example
|
example.com example
|
||||||
|
|||||||
@@ -503,3 +503,10 @@ class BookmarkArchivedViewTestCase(
|
|||||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||||
|
|
||||||
|
def test_does_not_include_rss_feed(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:archived"))
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
|
||||||
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
|
self.assertIsNone(feed)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
from bookmarks.models import GlobalSettings
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
class BookmarkArchivedViewPerformanceTestCase(
|
class BookmarkArchivedViewPerformanceTestCase(
|
||||||
TransactionTestCase, BookmarkFactoryMixin
|
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||||
):
|
):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
|
|||||||
context = CaptureQueriesContext(self.get_connection())
|
context = CaptureQueriesContext(self.get_connection())
|
||||||
with context:
|
with context:
|
||||||
response = self.client.get(reverse("bookmarks:archived"))
|
response = self.client.get(reverse("bookmarks:archived"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
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
|
number_of_queries = context.final_queries
|
||||||
|
|
||||||
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
|
|||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
with self.assertNumQueries(number_of_queries):
|
with self.assertNumQueries(number_of_queries):
|
||||||
response = self.client.get(reverse("bookmarks:archived"))
|
response = self.client.get(reverse("bookmarks:archived"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response,
|
soup = self.make_soup(html)
|
||||||
"<li ld-bookmark-item>",
|
list_items = soup.select("li[ld-bookmark-item]")
|
||||||
num_initial_bookmarks + num_additional_bookmarks,
|
self.assertEqual(
|
||||||
|
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
modal = soup.find("turbo-frame", {"id": "details-modal"})
|
||||||
return modal
|
return modal
|
||||||
|
|
||||||
def find_section(self, soup, section_name):
|
def find_section_content(self, soup, section_name):
|
||||||
dt = soup.find("dt", string=section_name)
|
h3 = soup.find("h3", string=section_name)
|
||||||
dd = dt.find_next_sibling("dd") if dt else None
|
content = h3.find_next_sibling("div") if h3 else None
|
||||||
return dd
|
return content
|
||||||
|
|
||||||
def get_section(self, soup, section_name):
|
def get_section_content(self, soup, section_name):
|
||||||
dd = self.find_section(soup, section_name)
|
content = self.find_section_content(soup, section_name)
|
||||||
self.assertIsNotNone(dd)
|
self.assertIsNotNone(content)
|
||||||
return dd
|
return content
|
||||||
|
|
||||||
def find_weblink(self, soup, url):
|
def find_weblink(self, soup, url):
|
||||||
return soup.find("a", {"class": "weblink", "href": url})
|
return soup.find("a", {"class": "weblink", "href": url})
|
||||||
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# sharing disabled
|
# sharing disabled
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertIsNotNone(archived)
|
self.assertIsNotNone(archived)
|
||||||
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
|
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertIsNotNone(archived)
|
self.assertIsNotNone(archived)
|
||||||
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# unchecked
|
# unchecked
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertFalse(archived.has_attr("checked"))
|
self.assertFalse(archived.has_attr("checked"))
|
||||||
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# checked
|
# checked
|
||||||
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||||
soup = self.get_index_details_modal(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"})
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
self.assertTrue(archived.has_attr("checked"))
|
self.assertTrue(archived.has_attr("checked"))
|
||||||
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# own bookmark
|
# own bookmark
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section_content(soup, "Status")
|
||||||
self.assertIsNotNone(section)
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
# other user's bookmark
|
# other user's bookmark
|
||||||
other_user = self.setup_user(enable_sharing=True)
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
soup = self.get_shared_details_modal(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section_content(soup, "Status")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# guest user
|
# guest user
|
||||||
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
other_user.profile.save()
|
other_user.profile.save()
|
||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
soup = self.get_shared_details_modal(bookmark)
|
soup = self.get_shared_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Status")
|
section = self.find_section_content(soup, "Status")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
def test_date_added(self):
|
def test_date_added(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(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")
|
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||||
date = section.find("span", string=expected_date)
|
date = section.find("span", string=expected_date)
|
||||||
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Tags")
|
section = self.find_section_content(soup, "Tags")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with tags
|
# with tags
|
||||||
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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():
|
for tag in bookmark.tags.all():
|
||||||
tag_link = section.find("a", string=f"#{tag.name}")
|
tag_link = section.find("a", string=f"#{tag.name}")
|
||||||
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark(description="")
|
bookmark = self.setup_bookmark(description="")
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Description")
|
section = self.find_section_content(soup, "Description")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with description
|
# with description
|
||||||
bookmark = self.setup_bookmark(description="Test description")
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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)
|
self.assertEqual(section.text.strip(), bookmark.description)
|
||||||
|
|
||||||
def test_notes(self):
|
def test_notes(self):
|
||||||
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
section = self.find_section(soup, "Notes")
|
section = self.find_section_content(soup, "Notes")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
# with notes
|
# with notes
|
||||||
bookmark = self.setup_bookmark(notes="Test notes")
|
bookmark = self.setup_bookmark(notes="Test notes")
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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>")
|
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||||
|
|
||||||
def test_edit_link(self):
|
def test_edit_link(self):
|
||||||
@@ -568,7 +568,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Files")
|
section = self.find_section_content(soup, "Files")
|
||||||
self.assertIsNone(section)
|
self.assertIsNone(section)
|
||||||
|
|
||||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
@@ -576,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
section = self.find_section(soup, "Files")
|
section = self.find_section_content(soup, "Files")
|
||||||
self.assertIsNotNone(section)
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
@@ -585,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(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"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
self.assertIsNone(asset_list)
|
self.assertIsNone(asset_list)
|
||||||
|
|
||||||
@@ -594,7 +594,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
self.setup_asset(bookmark)
|
self.setup_asset(bookmark)
|
||||||
|
|
||||||
soup = self.get_index_details_modal(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"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
self.assertIsNotNone(asset_list)
|
self.assertIsNotNone(asset_list)
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
]
|
]
|
||||||
|
|
||||||
soup = self.get_index_details_modal(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"})
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
@@ -738,7 +738,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
|
|
||||||
# no pending asset
|
# no pending asset
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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(
|
create_button = files_section.find(
|
||||||
"button", string=re.compile("Create HTML snapshot")
|
"button", string=re.compile("Create HTML snapshot")
|
||||||
)
|
)
|
||||||
@@ -749,7 +749,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
soup = self.get_index_details_modal(bookmark)
|
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(
|
create_button = files_section.find(
|
||||||
"button", string=re.compile("Create HTML snapshot")
|
"button", string=re.compile("Create HTML snapshot")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
}
|
}
|
||||||
return {**form_data, **overrides}
|
return {**form_data, **overrides}
|
||||||
|
|
||||||
|
def test_should_render_successfully(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_should_edit_bookmark(self):
|
def test_should_edit_bookmark(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
form_data = self.create_form_data({"id": bookmark.id})
|
form_data = self.create_form_data({"id": bookmark.id})
|
||||||
@@ -46,6 +51,14 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(tags[0].name, "editedtag1")
|
self.assertEqual(tags[0].name, "editedtag1")
|
||||||
self.assertEqual(tags[1].name, "editedtag2")
|
self.assertEqual(tags[1].name, "editedtag2")
|
||||||
|
|
||||||
|
def test_should_return_422_with_invalid_form(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
form_data = self.create_form_data({"id": bookmark.id, "url": ""})
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:edit", args=[bookmark.id]), form_data
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
def test_should_edit_unread_state(self):
|
def test_should_edit_unread_state(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
@@ -128,6 +141,40 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_should_prevent_duplicate_urls(self):
|
||||||
|
edited_bookmark = self.setup_bookmark(url="http://example.com/edited")
|
||||||
|
existing_bookmark = self.setup_bookmark(url="http://example.com/existing")
|
||||||
|
other_user_bookmark = self.setup_bookmark(
|
||||||
|
url="http://example.com/other-user", user=User.objects.create_user("other")
|
||||||
|
)
|
||||||
|
|
||||||
|
# if the URL isn't modified it's not a duplicate
|
||||||
|
form_data = self.create_form_data({"url": edited_bookmark.url})
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
# if the URL is already bookmarked by another user, it's not a duplicate
|
||||||
|
form_data = self.create_form_data({"url": other_user_bookmark.url})
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
# if the URL is already bookmarked by the same user, it's a duplicate
|
||||||
|
form_data = self.create_form_data({"url": existing_bookmark.url})
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertInHTML(
|
||||||
|
"<li>A bookmark with this URL already exists.</li>",
|
||||||
|
response.content.decode(),
|
||||||
|
)
|
||||||
|
edited_bookmark.refresh_from_db()
|
||||||
|
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
|
||||||
|
|
||||||
def test_should_redirect_to_return_url(self):
|
def test_should_redirect_to_return_url(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
form_data = self.create_form_data()
|
form_data = self.create_form_data()
|
||||||
|
|||||||
@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||||
|
|
||||||
|
def test_does_not_include_rss_feed(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
|
||||||
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
|
self.assertIsNone(feed)
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
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:
|
def setUp(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
|||||||
context = CaptureQueriesContext(self.get_connection())
|
context = CaptureQueriesContext(self.get_connection())
|
||||||
with context:
|
with context:
|
||||||
response = self.client.get(reverse("bookmarks:index"))
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
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
|
number_of_queries = context.final_queries
|
||||||
|
|
||||||
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
|
|||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
with self.assertNumQueries(number_of_queries):
|
with self.assertNumQueries(number_of_queries):
|
||||||
response = self.client.get(reverse("bookmarks:index"))
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response,
|
soup = self.make_soup(html)
|
||||||
"<li ld-bookmark-item>",
|
list_items = soup.select("li[ld-bookmark-item]")
|
||||||
num_initial_bookmarks + num_additional_bookmarks,
|
self.assertEqual(
|
||||||
|
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(tags[0].name, "tag1")
|
self.assertEqual(tags[0].name, "tag1")
|
||||||
self.assertEqual(tags[1].name, "tag2")
|
self.assertEqual(tags[1].name, "tag2")
|
||||||
|
|
||||||
|
def test_should_return_422_with_invalid_form(self):
|
||||||
|
form_data = self.create_form_data({"url": ""})
|
||||||
|
response = self.client.post(reverse("bookmarks:new"), form_data)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
def test_should_create_new_unread_bookmark(self):
|
def test_should_create_new_unread_bookmark(self):
|
||||||
form_data = self.create_form_data({"unread": True})
|
form_data = self.create_form_data({"unread": True})
|
||||||
|
|
||||||
|
|||||||
@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
radios = form.select(f'input[name="{name}"][type="radio"]')
|
radios = form.select(f'input[name="{name}"][type="radio"]')
|
||||||
self.assertTrue(len(radios) == 0)
|
self.assertTrue(len(radios) == 0)
|
||||||
|
|
||||||
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
|
def assertUnmodifiedLabel(self, html: str, text: str):
|
||||||
id_attr = f'for="{id}"' if id else ""
|
soup = self.make_soup(html)
|
||||||
tag = "label" if id else "div"
|
label = soup.find("label", string=lambda s: s and s.strip() == text)
|
||||||
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
|
self.assertEqual(label["class"], ["form-label"])
|
||||||
|
|
||||||
self.assertInHTML(needle, html)
|
def assertModifiedLabel(self, html: str, text: str):
|
||||||
|
soup = self.make_soup(html)
|
||||||
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
|
label = soup.find("label", string=lambda s: s and s.strip() == text)
|
||||||
id_attr = f'for="{id}"' if id else ""
|
self.assertEqual(label["class"], ["form-label", "text-bold"])
|
||||||
tag = "label" if id else "div"
|
|
||||||
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
|
|
||||||
|
|
||||||
self.assertInHTML(needle, html)
|
|
||||||
|
|
||||||
def test_search_form_inputs(self):
|
def test_search_form_inputs(self):
|
||||||
# Without params
|
# Without params
|
||||||
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
# Without modifications
|
# Without modifications
|
||||||
url = "/test"
|
url = "/test"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
soup = self.make_soup(rendered_template)
|
||||||
|
button = soup.select_one("button[aria-label='Search preferences']")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertNotIn("badge", button["class"])
|
||||||
'<button type="button" class="btn dropdown-toggle">', rendered_template
|
|
||||||
)
|
|
||||||
|
|
||||||
# With modifications
|
# With modifications
|
||||||
url = "/test?sort=title_asc"
|
url = "/test?sort=title_asc"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
soup = self.make_soup(rendered_template)
|
||||||
|
button = soup.select_one("button[aria-label='Search preferences']")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn("badge", button["class"])
|
||||||
'<button type="button" class="btn dropdown-toggle badge">',
|
|
||||||
rendered_template,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ignores non-preferences modifications
|
# Ignores non-preferences modifications
|
||||||
url = "/test?q=foo&user=john"
|
url = "/test?q=foo&user=john"
|
||||||
rendered_template = self.render_template(url)
|
rendered_template = self.render_template(url)
|
||||||
|
soup = self.make_soup(rendered_template)
|
||||||
|
button = soup.select_one("button[aria-label='Search preferences']")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertNotIn("badge", button["class"])
|
||||||
'<button type="button" class="btn dropdown-toggle">', rendered_template
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_modified_labels(self):
|
def test_modified_labels(self):
|
||||||
# Without modifications
|
# Without modifications
|
||||||
url = "/test"
|
url = "/test"
|
||||||
rendered_template = self.render_template(url)
|
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, "Shared filter")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|
||||||
# Modified sort
|
# Modified sort
|
||||||
url = "/test?sort=title_asc"
|
url = "/test?sort=title_asc"
|
||||||
rendered_template = self.render_template(url)
|
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, "Shared filter")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|
||||||
# Modified shared
|
# Modified shared
|
||||||
url = "/test?shared=yes"
|
url = "/test?shared=yes"
|
||||||
rendered_template = self.render_template(url)
|
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.assertModifiedLabel(rendered_template, "Shared filter")
|
||||||
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|
||||||
# Modified unread
|
# Modified unread
|
||||||
url = "/test?unread=yes"
|
url = "/test?unread=yes"
|
||||||
rendered_template = self.render_template(url)
|
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, "Shared filter")
|
||||||
self.assertModifiedLabel(rendered_template, "Unread filter")
|
self.assertModifiedLabel(rendered_template, "Unread filter")
|
||||||
|
|||||||
@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
|
|||||||
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
|
||||||
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
self.assertIsNone(soup.select_one("#bookmark-list-container"))
|
||||||
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
self.assertIsNone(soup.select_one("#tag-cloud-container"))
|
||||||
|
|
||||||
|
def test_includes_public_shared_rss_feed(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:shared"))
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
|
||||||
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
|
self.assertIsNotNone(feed)
|
||||||
|
self.assertEqual(feed.attrs["href"], reverse("bookmarks:feeds.public_shared"))
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
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:
|
def setUp(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
|||||||
context = CaptureQueriesContext(self.get_connection())
|
context = CaptureQueriesContext(self.get_connection())
|
||||||
with context:
|
with context:
|
||||||
response = self.client.get(reverse("bookmarks:shared"))
|
response = self.client.get(reverse("bookmarks:shared"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
|
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
|
number_of_queries = context.final_queries
|
||||||
|
|
||||||
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
|||||||
# assert num queries doesn't increase
|
# assert num queries doesn't increase
|
||||||
with self.assertNumQueries(number_of_queries):
|
with self.assertNumQueries(number_of_queries):
|
||||||
response = self.client.get(reverse("bookmarks:shared"))
|
response = self.client.get(reverse("bookmarks:shared"))
|
||||||
self.assertContains(
|
html = response.content.decode("utf-8")
|
||||||
response,
|
soup = self.make_soup(html)
|
||||||
'<li ld-bookmark-item class="shared">',
|
list_items = soup.select("li[ld-bookmark-item]")
|
||||||
num_initial_bookmarks + num_additional_bookmarks,
|
self.assertEqual(
|
||||||
|
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -480,7 +480,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.authenticate()
|
self.authenticate()
|
||||||
|
|
||||||
data = {"url": "https://example.com/"}
|
data = {"url": "https://example.com/"}
|
||||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
self.post(
|
||||||
|
reverse("bookmarks:bookmark-list") + "?disable_scraping",
|
||||||
|
data,
|
||||||
|
status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
bookmark = Bookmark.objects.get(url=data["url"])
|
||||||
|
self.assertEqual(data["url"], bookmark.url)
|
||||||
|
self.assertEqual("", bookmark.title)
|
||||||
|
self.assertEqual("", bookmark.description)
|
||||||
|
self.assertEqual("", bookmark.notes)
|
||||||
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
self.assertFalse(bookmark.unread)
|
||||||
|
self.assertFalse(bookmark.shared)
|
||||||
|
self.assertBookmarkListEqual([], bookmark.tag_names)
|
||||||
|
|
||||||
def test_create_archived_bookmark(self):
|
def test_create_archived_bookmark(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
@@ -586,6 +600,28 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
self.assertEqual(updated_bookmark.url, data["url"])
|
self.assertEqual(updated_bookmark.url, data["url"])
|
||||||
|
|
||||||
|
def test_update_bookmark_ignores_readonly_fields(self):
|
||||||
|
self.authenticate()
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"url": "https://example.com/updated",
|
||||||
|
"web_archive_snapshot_url": "test",
|
||||||
|
"website_title": "test",
|
||||||
|
"website_description": "test",
|
||||||
|
}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
|
self.assertEqual(data["url"], updated_bookmark.url)
|
||||||
|
self.assertNotEqual(
|
||||||
|
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
|
||||||
|
)
|
||||||
|
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
|
||||||
|
self.assertNotEqual(
|
||||||
|
data["website_description"], updated_bookmark.website_description
|
||||||
|
)
|
||||||
|
|
||||||
def test_update_bookmark_fails_without_required_fields(self):
|
def test_update_bookmark_fails_without_required_fields(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
@@ -594,19 +630,24 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
|
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark(
|
||||||
|
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
|
||||||
|
)
|
||||||
|
|
||||||
data = {"url": "https://example.com/"}
|
data = {"url": "https://example.com/"}
|
||||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
self.assertEqual(updated_bookmark.url, data["url"])
|
self.assertEqual(updated_bookmark.url, data["url"])
|
||||||
self.assertEqual(updated_bookmark.title, "")
|
self.assertEqual(updated_bookmark.title, bookmark.title)
|
||||||
self.assertEqual(updated_bookmark.description, "")
|
self.assertEqual(updated_bookmark.description, bookmark.description)
|
||||||
self.assertEqual(updated_bookmark.notes, "")
|
self.assertEqual(updated_bookmark.notes, bookmark.notes)
|
||||||
self.assertEqual(updated_bookmark.tag_names, [])
|
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
|
||||||
|
self.assertEqual(updated_bookmark.unread, bookmark.unread)
|
||||||
|
self.assertEqual(updated_bookmark.shared, bookmark.shared)
|
||||||
|
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
||||||
|
|
||||||
def test_update_bookmark_unread_flag(self):
|
def test_update_bookmark_unread_flag(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
@@ -644,6 +685,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_update_bookmark_should_prevent_duplicate_urls(self):
|
||||||
|
self.authenticate()
|
||||||
|
edited_bookmark = self.setup_bookmark(url="https://example.com/edited")
|
||||||
|
existing_bookmark = self.setup_bookmark(url="https://example.com/existing")
|
||||||
|
other_user_bookmark = self.setup_bookmark(
|
||||||
|
url="https://example.com/other", user=self.setup_user()
|
||||||
|
)
|
||||||
|
|
||||||
|
# if the URL isn't modified it's not a duplicate
|
||||||
|
data = {"url": edited_bookmark.url}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# if the URL is already bookmarked by another user, it's not a duplicate
|
||||||
|
data = {"url": other_user_bookmark.url}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# if the URL is already bookmarked by the same user, it's a duplicate
|
||||||
|
data = {"url": existing_bookmark.url}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def test_patch_bookmark(self):
|
def test_patch_bookmark(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
@@ -703,16 +767,42 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
tag_names = [tag.name for tag in bookmark.tags.all()]
|
tag_names = [tag.name for tag in bookmark.tags.all()]
|
||||||
self.assertListEqual(tag_names, ["updated-tag-1", "updated-tag-2"])
|
self.assertListEqual(tag_names, ["updated-tag-1", "updated-tag-2"])
|
||||||
|
|
||||||
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
def test_patch_ignores_readonly_fields(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"web_archive_snapshot_url": "test",
|
||||||
|
"website_title": "test",
|
||||||
|
"website_description": "test",
|
||||||
|
}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
|
self.assertNotEqual(
|
||||||
|
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
|
||||||
|
)
|
||||||
|
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
|
||||||
|
self.assertNotEqual(
|
||||||
|
data["website_description"], updated_bookmark.website_description
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
|
||||||
|
self.authenticate()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
|
||||||
|
)
|
||||||
|
|
||||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
|
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
|
||||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
self.assertEqual(updated_bookmark.url, bookmark.url)
|
self.assertEqual(updated_bookmark.url, bookmark.url)
|
||||||
self.assertEqual(updated_bookmark.title, bookmark.title)
|
self.assertEqual(updated_bookmark.title, bookmark.title)
|
||||||
self.assertEqual(updated_bookmark.description, bookmark.description)
|
self.assertEqual(updated_bookmark.description, bookmark.description)
|
||||||
|
self.assertEqual(updated_bookmark.notes, bookmark.notes)
|
||||||
|
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
|
||||||
|
self.assertEqual(updated_bookmark.unread, bookmark.unread)
|
||||||
|
self.assertEqual(updated_bookmark.shared, bookmark.shared)
|
||||||
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
||||||
|
|
||||||
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
|
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
|
||||||
@@ -919,6 +1009,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
{url: "https://example.com/"},
|
{url: "https://example.com/"},
|
||||||
expected_status_code=status.HTTP_404_NOT_FOUND,
|
expected_status_code=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
url = reverse(
|
url = reverse(
|
||||||
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
|
||||||
@@ -928,6 +1019,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
{url: "https://example.com/"},
|
{url: "https://example.com/"},
|
||||||
expected_status_code=status.HTTP_404_NOT_FOUND,
|
expected_status_code=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
|
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
|
||||||
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
|||||||
self.authenticate()
|
self.authenticate()
|
||||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_update_bookmark_only_updates_own_bookmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
data = {"url": "https://example.com/"}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
def test_patch_bookmark_requires_authentication(self):
|
def test_patch_bookmark_requires_authentication(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
data = {"url": "https://example.com"}
|
data = {"url": "https://example.com"}
|
||||||
@@ -97,6 +107,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
|
|||||||
self.authenticate()
|
self.authenticate()
|
||||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_patch_bookmark_only_updates_own_bookmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
data = {"url": "https://example.com"}
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
|
||||||
|
self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
def test_delete_bookmark_requires_authentication(self):
|
def test_delete_bookmark_requires_authentication(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
details_url = base_url + f"?details={bookmark.id}"
|
details_url = base_url + f"?details={bookmark.id}"
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
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,
|
html,
|
||||||
count=count,
|
count=count,
|
||||||
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
def test_should_reflect_unread_state_as_css_class(self):
|
def test_should_reflect_unread_state_as_css_class(self):
|
||||||
self.setup_bookmark(unread=True)
|
self.setup_bookmark(unread=True)
|
||||||
html = self.render_template()
|
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):
|
def test_should_reflect_shared_state_as_css_class(self):
|
||||||
profile = self.get_or_create_test_user().profile
|
profile = self.get_or_create_test_user().profile
|
||||||
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
|
|
||||||
self.setup_bookmark(shared=True)
|
self.setup_bookmark(shared=True)
|
||||||
html = self.render_template()
|
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):
|
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
|
||||||
profile = self.get_or_create_test_user().profile
|
profile = self.get_or_create_test_user().profile
|
||||||
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
|
|
||||||
self.setup_bookmark(unread=True, shared=True)
|
self.setup_bookmark(unread=True, shared=True)
|
||||||
html = self.render_template()
|
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):
|
def test_show_bookmark_actions_for_owned_bookmarks(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|
||||||
|
|
||||||
|
|
||||||
class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
|
|
||||||
def setUp(self):
|
|
||||||
self.client.force_login(self.get_or_create_test_user())
|
|
||||||
|
|
||||||
def test_does_not_render_custom_style_tag_by_default(self):
|
|
||||||
response = self.client.get(reverse("bookmarks:index"))
|
|
||||||
self.assertNotContains(response, "<style>")
|
|
||||||
|
|
||||||
def test_renders_custom_style_tag_if_user_has_custom_css(self):
|
|
||||||
profile = self.get_or_create_test_user().profile
|
|
||||||
profile.custom_css = "body { background-color: red; }"
|
|
||||||
profile.save()
|
|
||||||
|
|
||||||
response = self.client.get(reverse("bookmarks:index"))
|
|
||||||
self.assertContains(response, "<style>body { background-color: red; }</style>")
|
|
||||||
28
bookmarks/tests/test_custom_css_view.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
def test_with_empty_css(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:custom_css"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/css")
|
||||||
|
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
|
||||||
|
self.assertEqual(response.content.decode(), "")
|
||||||
|
|
||||||
|
def test_with_custom_css(self):
|
||||||
|
css = "body { background-color: red; }"
|
||||||
|
self.user.profile.custom_css = css
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:custom_css"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/css")
|
||||||
|
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
|
||||||
|
self.assertEqual(response.content.decode(), css)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from bookmarks.services import exporter
|
from bookmarks.services import exporter
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
@@ -7,20 +8,19 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|||||||
|
|
||||||
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
def test_export_bookmarks(self):
|
def test_export_bookmarks(self):
|
||||||
added = timezone.now()
|
|
||||||
timestamp = int(added.timestamp())
|
|
||||||
|
|
||||||
bookmarks = [
|
bookmarks = [
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/1",
|
url="https://example.com/1",
|
||||||
title="Title 1",
|
title="Title 1",
|
||||||
added=added,
|
added=datetime.fromtimestamp(1, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(11, timezone.utc),
|
||||||
description="Example description",
|
description="Example description",
|
||||||
),
|
),
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/2",
|
url="https://example.com/2",
|
||||||
title="Title 2",
|
title="Title 2",
|
||||||
added=added,
|
added=datetime.fromtimestamp(2, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(22, timezone.utc),
|
||||||
tags=[
|
tags=[
|
||||||
self.setup_tag(name="tag1"),
|
self.setup_tag(name="tag1"),
|
||||||
self.setup_tag(name="tag2"),
|
self.setup_tag(name="tag2"),
|
||||||
@@ -28,15 +28,24 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/3", title="Title 3", added=added, unread=True
|
url="https://example.com/3",
|
||||||
|
title="Title 3",
|
||||||
|
added=datetime.fromtimestamp(3, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(33, timezone.utc),
|
||||||
|
unread=True,
|
||||||
),
|
),
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/4", title="Title 4", added=added, shared=True
|
url="https://example.com/4",
|
||||||
|
title="Title 4",
|
||||||
|
added=datetime.fromtimestamp(4, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(44, timezone.utc),
|
||||||
|
shared=True,
|
||||||
),
|
),
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/5",
|
url="https://example.com/5",
|
||||||
title="Title 5",
|
title="Title 5",
|
||||||
added=added,
|
added=datetime.fromtimestamp(5, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(55, timezone.utc),
|
||||||
shared=True,
|
shared=True,
|
||||||
description="Example description",
|
description="Example description",
|
||||||
notes="Example notes",
|
notes="Example notes",
|
||||||
@@ -44,20 +53,23 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/6",
|
url="https://example.com/6",
|
||||||
title="Title 6",
|
title="Title 6",
|
||||||
added=added,
|
added=datetime.fromtimestamp(6, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(66, timezone.utc),
|
||||||
shared=True,
|
shared=True,
|
||||||
notes="Example notes",
|
notes="Example notes",
|
||||||
),
|
),
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/7",
|
url="https://example.com/7",
|
||||||
title="Title 7",
|
title="Title 7",
|
||||||
added=added,
|
added=datetime.fromtimestamp(7, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(77, timezone.utc),
|
||||||
is_archived=True,
|
is_archived=True,
|
||||||
),
|
),
|
||||||
self.setup_bookmark(
|
self.setup_bookmark(
|
||||||
url="https://example.com/8",
|
url="https://example.com/8",
|
||||||
title="Title 8",
|
title="Title 8",
|
||||||
added=added,
|
added=datetime.fromtimestamp(8, timezone.utc),
|
||||||
|
modified=datetime.fromtimestamp(88, timezone.utc),
|
||||||
tags=[self.setup_tag(name="tag4"), self.setup_tag(name="tag5")],
|
tags=[self.setup_tag(name="tag4"), self.setup_tag(name="tag5")],
|
||||||
is_archived=True,
|
is_archived=True,
|
||||||
),
|
),
|
||||||
@@ -65,17 +77,17 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = exporter.export_netscape_html(bookmarks)
|
html = exporter.export_netscape_html(bookmarks)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
|
'<DT><A HREF="https://example.com/1" ADD_DATE="1" LAST_MODIFIED="11" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
|
||||||
"<DD>Example description",
|
"<DD>Example description",
|
||||||
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
|
'<DT><A HREF="https://example.com/2" ADD_DATE="2" LAST_MODIFIED="22" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
|
||||||
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
|
'<DT><A HREF="https://example.com/3" ADD_DATE="3" LAST_MODIFIED="33" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
|
||||||
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
|
'<DT><A HREF="https://example.com/4" ADD_DATE="4" LAST_MODIFIED="44" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
|
||||||
f'<DT><A HREF="https://example.com/5" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
|
'<DT><A HREF="https://example.com/5" ADD_DATE="5" LAST_MODIFIED="55" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
|
||||||
"<DD>Example description[linkding-notes]Example notes[/linkding-notes]",
|
"<DD>Example description[linkding-notes]Example notes[/linkding-notes]",
|
||||||
f'<DT><A HREF="https://example.com/6" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
|
'<DT><A HREF="https://example.com/6" ADD_DATE="6" LAST_MODIFIED="66" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
|
||||||
"<DD>[linkding-notes]Example notes[/linkding-notes]",
|
"<DD>[linkding-notes]Example notes[/linkding-notes]",
|
||||||
f'<DT><A HREF="https://example.com/7" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
|
'<DT><A HREF="https://example.com/7" ADD_DATE="7" LAST_MODIFIED="77" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
|
||||||
f'<DT><A HREF="https://example.com/8" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
|
'<DT><A HREF="https://example.com/8" ADD_DATE="8" LAST_MODIFIED="88" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
|
||||||
]
|
]
|
||||||
self.assertIn("\n\r".join(lines), html)
|
self.assertIn("\n\r".join(lines), html)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
self.assertEqual(bookmark.title, html_tag.title)
|
self.assertEqual(bookmark.title, html_tag.title)
|
||||||
self.assertEqual(bookmark.description, html_tag.description)
|
self.assertEqual(bookmark.description, html_tag.description)
|
||||||
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
|
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
|
||||||
|
self.assertEqual(
|
||||||
|
bookmark.date_modified, parse_timestamp(html_tag.last_modified)
|
||||||
|
)
|
||||||
self.assertEqual(bookmark.unread, html_tag.to_read)
|
self.assertEqual(bookmark.unread, html_tag.to_read)
|
||||||
self.assertEqual(bookmark.shared, not html_tag.private)
|
self.assertEqual(bookmark.shared, not html_tag.private)
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Example title",
|
title="Example title",
|
||||||
description="Example description",
|
description="Example description",
|
||||||
add_date="1",
|
add_date="1",
|
||||||
|
last_modified="11",
|
||||||
tags="example-tag",
|
tags="example-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -52,6 +56,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Foo title",
|
title="Foo title",
|
||||||
description="",
|
description="",
|
||||||
add_date="2",
|
add_date="2",
|
||||||
|
last_modified="22",
|
||||||
tags="",
|
tags="",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -59,6 +64,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Bar title",
|
title="Bar title",
|
||||||
description="Bar description",
|
description="Bar description",
|
||||||
add_date="3",
|
add_date="3",
|
||||||
|
last_modified="33",
|
||||||
tags="bar-tag, other-tag",
|
tags="bar-tag, other-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -66,6 +72,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Baz title",
|
title="Baz title",
|
||||||
description="Baz description",
|
description="Baz description",
|
||||||
add_date="4",
|
add_date="4",
|
||||||
|
last_modified="44",
|
||||||
to_read=True,
|
to_read=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -90,6 +97,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Example title",
|
title="Example title",
|
||||||
description="Example description",
|
description="Example description",
|
||||||
add_date="1",
|
add_date="1",
|
||||||
|
last_modified="11",
|
||||||
tags="example-tag",
|
tags="example-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -97,6 +105,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Foo title",
|
title="Foo title",
|
||||||
description="",
|
description="",
|
||||||
add_date="2",
|
add_date="2",
|
||||||
|
last_modified="22",
|
||||||
tags="",
|
tags="",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -104,20 +113,23 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Bar title",
|
title="Bar title",
|
||||||
description="Bar description",
|
description="Bar description",
|
||||||
add_date="3",
|
add_date="3",
|
||||||
|
last_modified="33",
|
||||||
tags="bar-tag, other-tag",
|
tags="bar-tag, other-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
href="https://example.com/unread",
|
href="https://example.com/unread",
|
||||||
title="Unread title",
|
title="Unread title",
|
||||||
description="Unread description",
|
description="Unread description",
|
||||||
add_date="3",
|
add_date="4",
|
||||||
|
last_modified="44",
|
||||||
to_read=True,
|
to_read=True,
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
href="https://example.com/private",
|
href="https://example.com/private",
|
||||||
title="Private title",
|
title="Private title",
|
||||||
description="Private description",
|
description="Private description",
|
||||||
add_date="4",
|
add_date="5",
|
||||||
|
last_modified="55",
|
||||||
private=True,
|
private=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -136,6 +148,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Updated Example title",
|
title="Updated Example title",
|
||||||
description="Updated Example description",
|
description="Updated Example description",
|
||||||
add_date="111",
|
add_date="111",
|
||||||
|
last_modified="1111",
|
||||||
tags="updated-example-tag",
|
tags="updated-example-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -143,6 +156,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Updated Foo title",
|
title="Updated Foo title",
|
||||||
description="Updated Foo description",
|
description="Updated Foo description",
|
||||||
add_date="222",
|
add_date="222",
|
||||||
|
last_modified="2222",
|
||||||
tags="new-tag",
|
tags="new-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -150,6 +164,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Updated Bar title",
|
title="Updated Bar title",
|
||||||
description="Updated Bar description",
|
description="Updated Bar description",
|
||||||
add_date="333",
|
add_date="333",
|
||||||
|
last_modified="3333",
|
||||||
tags="updated-bar-tag, updated-other-tag",
|
tags="updated-bar-tag, updated-other-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -157,6 +172,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Unread title",
|
title="Unread title",
|
||||||
description="Unread description",
|
description="Unread description",
|
||||||
add_date="3",
|
add_date="3",
|
||||||
|
last_modified="3",
|
||||||
to_read=False,
|
to_read=False,
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -164,9 +180,15 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
title="Private title",
|
title="Private title",
|
||||||
description="Private description",
|
description="Private description",
|
||||||
add_date="4",
|
add_date="4",
|
||||||
|
last_modified="4",
|
||||||
private=False,
|
private=False,
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(href="https://baz.com", add_date="444", tags="baz-tag"),
|
BookmarkHtmlTag(
|
||||||
|
href="https://baz.com",
|
||||||
|
add_date="444",
|
||||||
|
last_modified="4444",
|
||||||
|
tags="baz-tag",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Import updated data
|
# Import updated data
|
||||||
@@ -291,6 +313,19 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1)
|
Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_use_add_date_when_no_last_modified(self):
|
||||||
|
test_html = self.render_html(
|
||||||
|
tags_html=f"""
|
||||||
|
<DT><A HREF="https://example.com" ADD_DATE="1">Example.com</A>
|
||||||
|
<DD>Example.com
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
import_netscape_html(test_html, self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 1)
|
||||||
|
self.assertEqual(Bookmark.objects.all()[0].date_modified, parse_timestamp("1"))
|
||||||
|
|
||||||
def test_keep_title_if_imported_bookmark_has_empty_title(self):
|
def test_keep_title_if_imported_bookmark_has_empty_title(self):
|
||||||
test_html = self.render_html(
|
test_html = self.render_html(
|
||||||
tags=[BookmarkHtmlTag(href="https://example.com", title="Example.com")]
|
tags=[BookmarkHtmlTag(href="https://example.com", title="Example.com")]
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import GlobalSettings
|
from bookmarks.models import GlobalSettings
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
class LayoutTestCase(TestCase, BookmarkFactoryMixin):
|
class LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
@@ -63,3 +63,38 @@ class LayoutTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
count=0,
|
count=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_does_not_link_custom_css_when_empty(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
|
||||||
|
self.assertIsNone(link)
|
||||||
|
|
||||||
|
def test_does_link_custom_css_when_not_empty(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.custom_css = "body { background-color: red; }"
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
|
||||||
|
def test_custom_css_link_href(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.custom_css = "body { background-color: red; }"
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
|
||||||
|
expected_url = (
|
||||||
|
reverse("bookmarks:custom_css") + f"?hash={profile.custom_css_hash}"
|
||||||
|
)
|
||||||
|
self.assertEqual(link["href"], expected_url)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import os
|
|||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import URLResolver
|
from django.urls import URLResolver
|
||||||
|
|
||||||
|
from bookmarks import utils
|
||||||
|
|
||||||
|
|
||||||
class OidcSupportTest(TestCase):
|
class OidcSupportTest(TestCase):
|
||||||
def test_should_not_add_oidc_urls_by_default(self):
|
def test_should_not_add_oidc_urls_by_default(self):
|
||||||
@@ -55,9 +57,83 @@ class OidcSupportTest(TestCase):
|
|||||||
base_settings = importlib.import_module("siteroot.settings.base")
|
base_settings = importlib.import_module("siteroot.settings.base")
|
||||||
importlib.reload(base_settings)
|
importlib.reload(base_settings)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
|
||||||
True,
|
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
|
||||||
base_settings.OIDC_VERIFY_SSL,
|
self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM)
|
||||||
)
|
|
||||||
|
|
||||||
del os.environ["LD_ENABLE_OIDC"]
|
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email")
|
||||||
|
def test_username_should_use_email_by_default(self):
|
||||||
|
claims = {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "test name",
|
||||||
|
"given_name": "test given name",
|
||||||
|
"preferred_username": "test preferred username",
|
||||||
|
"nickname": "test nickname",
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
username = utils.generate_username(claims["email"], claims)
|
||||||
|
|
||||||
|
self.assertEqual(claims["email"], username)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||||
|
def test_username_should_use_custom_claim(self):
|
||||||
|
claims = {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "test name",
|
||||||
|
"given_name": "test given name",
|
||||||
|
"preferred_username": "test preferred username",
|
||||||
|
"nickname": "test nickname",
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
username = utils.generate_username(claims["email"], claims)
|
||||||
|
|
||||||
|
self.assertEqual(claims["preferred_username"], username)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim")
|
||||||
|
def test_username_should_fallback_to_email_for_non_existing_claim(self):
|
||||||
|
claims = {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "test name",
|
||||||
|
"given_name": "test given name",
|
||||||
|
"preferred_username": "test preferred username",
|
||||||
|
"nickname": "test nickname",
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
username = utils.generate_username(claims["email"], claims)
|
||||||
|
|
||||||
|
self.assertEqual(claims["email"], username)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||||
|
def test_username_should_fallback_to_email_for_empty_claim(self):
|
||||||
|
claims = {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "test name",
|
||||||
|
"given_name": "test given name",
|
||||||
|
"preferred_username": "",
|
||||||
|
"nickname": "test nickname",
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
username = utils.generate_username(claims["email"], claims)
|
||||||
|
|
||||||
|
self.assertEqual(claims["email"], username)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
|
||||||
|
def test_username_should_be_normalized(self):
|
||||||
|
claims = {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "test name",
|
||||||
|
"given_name": "test given name",
|
||||||
|
"preferred_username": "NormalizedUser",
|
||||||
|
"nickname": "test nickname",
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
username = utils.generate_username(claims["email"], claims)
|
||||||
|
|
||||||
|
self.assertEqual("NormalizedUser", username)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
self.assertEqual(bookmark.href, html_tag.href)
|
self.assertEqual(bookmark.href, html_tag.href)
|
||||||
self.assertEqual(bookmark.title, html_tag.title)
|
self.assertEqual(bookmark.title, html_tag.title)
|
||||||
self.assertEqual(bookmark.date_added, html_tag.add_date)
|
self.assertEqual(bookmark.date_added, html_tag.add_date)
|
||||||
|
self.assertEqual(bookmark.date_modified, html_tag.last_modified)
|
||||||
self.assertEqual(bookmark.description, html_tag.description)
|
self.assertEqual(bookmark.description, html_tag.description)
|
||||||
self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))
|
self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))
|
||||||
self.assertEqual(bookmark.to_read, html_tag.to_read)
|
self.assertEqual(bookmark.to_read, html_tag.to_read)
|
||||||
@@ -30,6 +31,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
title="Example title",
|
title="Example title",
|
||||||
description="Example description",
|
description="Example description",
|
||||||
add_date="1",
|
add_date="1",
|
||||||
|
last_modified="11",
|
||||||
tags="example-tag",
|
tags="example-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -37,6 +39,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
title="Foo title",
|
title="Foo title",
|
||||||
description="",
|
description="",
|
||||||
add_date="2",
|
add_date="2",
|
||||||
|
last_modified="22",
|
||||||
tags="",
|
tags="",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
@@ -44,13 +47,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
title="Bar title",
|
title="Bar title",
|
||||||
description="Bar description",
|
description="Bar description",
|
||||||
add_date="3",
|
add_date="3",
|
||||||
|
last_modified="33",
|
||||||
tags="bar-tag, other-tag",
|
tags="bar-tag, other-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(
|
BookmarkHtmlTag(
|
||||||
href="https://example.com/baz",
|
href="https://example.com/baz",
|
||||||
title="Baz title",
|
title="Baz title",
|
||||||
description="Baz description",
|
description="Baz description",
|
||||||
add_date="3",
|
add_date="4",
|
||||||
to_read=True,
|
to_read=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -72,9 +76,17 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
|||||||
title="Example title",
|
title="Example title",
|
||||||
description="Example description",
|
description="Example description",
|
||||||
add_date="1",
|
add_date="1",
|
||||||
|
last_modified="1",
|
||||||
tags="example-tag",
|
tags="example-tag",
|
||||||
),
|
),
|
||||||
BookmarkHtmlTag(href="", title="", description="", add_date="", tags=""),
|
BookmarkHtmlTag(
|
||||||
|
href="",
|
||||||
|
title="",
|
||||||
|
description="",
|
||||||
|
add_date="",
|
||||||
|
last_modified="",
|
||||||
|
tags="",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
html = self.render_html(html_tags)
|
html = self.render_html(html_tags)
|
||||||
bookmarks = parse(html)
|
bookmarks = parse(html)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
response = self.client.post(reverse("change_password"), form_data)
|
response = self.client.post(reverse("change_password"), form_data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
self.assertIn("old_password", response.context_data["form"].errors)
|
self.assertIn("old_password", response.context_data["form"].errors)
|
||||||
|
|
||||||
def test_should_return_error_for_mismatching_new_password(self):
|
def test_should_return_error_for_mismatching_new_password(self):
|
||||||
@@ -54,4 +55,5 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
response = self.client.post(reverse("change_password"), form_data)
|
response = self.client.post(reverse("change_password"), form_data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
self.assertIn("new_password2", response.context_data["form"].errors)
|
self.assertIn("new_password2", response.context_data["form"].errors)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import random
|
import random
|
||||||
from unittest.mock import patch, Mock
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
if not overrides:
|
if not overrides:
|
||||||
overrides = {}
|
overrides = {}
|
||||||
form_data = {
|
form_data = {
|
||||||
|
"update_profile": "",
|
||||||
"theme": UserProfile.THEME_AUTO,
|
"theme": UserProfile.THEME_AUTO,
|
||||||
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||||
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
||||||
@@ -45,6 +47,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
"auto_tagging_rules": "",
|
"auto_tagging_rules": "",
|
||||||
"items_per_page": "30",
|
"items_per_page": "30",
|
||||||
"sticky_pagination": False,
|
"sticky_pagination": False,
|
||||||
|
"collapse_side_panel": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {**form_data, **overrides}
|
return {**form_data, **overrides}
|
||||||
@@ -115,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
"auto_tagging_rules": "example.com tag",
|
"auto_tagging_rules": "example.com tag",
|
||||||
"items_per_page": "10",
|
"items_per_page": "10",
|
||||||
"sticky_pagination": True,
|
"sticky_pagination": True,
|
||||||
|
"collapse_side_panel": True,
|
||||||
}
|
}
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("bookmarks:settings.update"), form_data, follow=True
|
reverse("bookmarks:settings.update"), form_data, follow=True
|
||||||
@@ -192,9 +196,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
|
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
||||||
|
)
|
||||||
|
|
||||||
self.assertSuccessMessage(html, "Profile updated")
|
self.assertSuccessMessage(html, "Profile updated")
|
||||||
|
|
||||||
|
def test_update_profile_with_invalid_form_returns_422(self):
|
||||||
|
form_data = self.create_profile_form_data({"items_per_page": "-1"})
|
||||||
|
response = self.client.post(reverse("bookmarks:settings.update"), form_data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
||||||
form_data = {
|
form_data = {
|
||||||
"theme": UserProfile.THEME_DARK,
|
"theme": UserProfile.THEME_DARK,
|
||||||
@@ -210,6 +223,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
|
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
|
||||||
self.assertSuccessMessage(html, "Profile updated", count=0)
|
self.assertSuccessMessage(html, "Profile updated", count=0)
|
||||||
|
|
||||||
|
def test_update_profile_updates_custom_css_hash(self):
|
||||||
|
form_data = self.create_profile_form_data(
|
||||||
|
{
|
||||||
|
"custom_css": "body { background-color: #000; }",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
|
||||||
|
self.user.profile.refresh_from_db()
|
||||||
|
|
||||||
|
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
|
||||||
|
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
|
||||||
|
|
||||||
|
form_data["custom_css"] = "body { background-color: #fff; }"
|
||||||
|
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
|
||||||
|
self.user.profile.refresh_from_db()
|
||||||
|
|
||||||
|
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
|
||||||
|
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
|
||||||
|
|
||||||
|
form_data["custom_css"] = ""
|
||||||
|
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
|
||||||
|
self.user.profile.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual("", self.user.profile.custom_css_hash)
|
||||||
|
|
||||||
def test_enable_favicons_should_schedule_icon_update(self):
|
def test_enable_favicons_should_schedule_icon_update(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
tasks, "schedule_bookmarks_without_favicons"
|
tasks, "schedule_bookmarks_without_favicons"
|
||||||
@@ -217,7 +255,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
# Enabling favicons schedules update
|
# Enabling favicons schedules update
|
||||||
form_data = self.create_profile_form_data(
|
form_data = self.create_profile_form_data(
|
||||||
{
|
{
|
||||||
"update_profile": "",
|
|
||||||
"enable_favicons": True,
|
"enable_favicons": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -331,7 +368,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
# Enabling favicons schedules update
|
# Enabling favicons schedules update
|
||||||
form_data = self.create_profile_form_data(
|
form_data = self.create_profile_form_data(
|
||||||
{
|
{
|
||||||
"update_profile": "",
|
|
||||||
"enable_preview_images": True,
|
"enable_preview_images": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
import gzip
|
import gzip
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -9,9 +10,10 @@ from bookmarks.services import singlefile
|
|||||||
|
|
||||||
|
|
||||||
class SingleFileServiceTestCase(TestCase):
|
class SingleFileServiceTestCase(TestCase):
|
||||||
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
|
def setUp(self):
|
||||||
html_filepath = "temp.html.gz"
|
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
|
||||||
temp_html_filepath = "temp.html.gz.tmp"
|
self.html_filepath = secrets.token_hex(8) + ".html.gz"
|
||||||
|
self.temp_html_filepath = self.html_filepath + ".tmp"
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if os.path.exists(self.html_filepath):
|
if os.path.exists(self.html_filepath):
|
||||||
@@ -64,7 +66,7 @@ class SingleFileServiceTestCase(TestCase):
|
|||||||
'--browser-arg="--headless=new"',
|
'--browser-arg="--headless=new"',
|
||||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||||
'--browser-arg="--no-sandbox"',
|
'--browser-arg="--no-sandbox"',
|
||||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||||
"http://example.com",
|
"http://example.com",
|
||||||
self.html_filepath + ".tmp",
|
self.html_filepath + ".tmp",
|
||||||
]
|
]
|
||||||
@@ -86,7 +88,7 @@ class SingleFileServiceTestCase(TestCase):
|
|||||||
'--browser-arg="--headless=new"',
|
'--browser-arg="--headless=new"',
|
||||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||||
'--browser-arg="--no-sandbox"',
|
'--browser-arg="--no-sandbox"',
|
||||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||||
"--some-option",
|
"--some-option",
|
||||||
"some value",
|
"some value",
|
||||||
"--another-option",
|
"--another-option",
|
||||||
|
|||||||
@@ -65,4 +65,6 @@ urlpatterns = [
|
|||||||
path("health", views.health, name="health"),
|
path("health", views.health, name="health"),
|
||||||
# Manifest
|
# Manifest
|
||||||
path("manifest.json", views.manifest, name="manifest"),
|
path("manifest.json", views.manifest, name="manifest"),
|
||||||
|
# Custom CSS
|
||||||
|
path("custom_css", views.custom_css, name="custom_css"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.template.defaultfilters import pluralize
|
from django.template.defaultfilters import pluralize
|
||||||
from django.utils import timezone, formats
|
from django.utils import timezone, formats
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open("version.txt", "r") as f:
|
with open("version.txt", "r") as f:
|
||||||
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
|
|||||||
return HttpResponseRedirect(redirect_url)
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
def generate_username(email):
|
def generate_username(email, claims):
|
||||||
# taken from mozilla-django-oidc docs :)
|
# taken from mozilla-django-oidc docs :)
|
||||||
|
|
||||||
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||||
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||||
# it and slice at 150 characters.
|
# it and slice at 150 characters.
|
||||||
return unicodedata.normalize("NFKC", email)[:150]
|
if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:
|
||||||
|
username = claims[settings.OIDC_USERNAME_CLAIM]
|
||||||
|
else:
|
||||||
|
username = email
|
||||||
|
return unicodedata.normalize("NFKC", username)[:150]
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ from .settings import *
|
|||||||
from .toasts import *
|
from .toasts import *
|
||||||
from .health import health
|
from .health import health
|
||||||
from .manifest import manifest
|
from .manifest import manifest
|
||||||
|
from .custom_css import custom_css
|
||||||
from .root import root
|
from .root import root
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ def shared(request):
|
|||||||
"tag_cloud": tag_cloud,
|
"tag_cloud": tag_cloud,
|
||||||
"details": bookmark_details,
|
"details": bookmark_details,
|
||||||
"users": users,
|
"users": users,
|
||||||
|
"rss_feed_url": reverse("bookmarks:feeds.public_shared"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,7 +151,6 @@ def convert_tag_string(tag_string: str):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def new(request):
|
def new(request):
|
||||||
status = 200
|
|
||||||
initial_url = request.GET.get("url")
|
initial_url = request.GET.get("url")
|
||||||
initial_title = request.GET.get("title")
|
initial_title = request.GET.get("title")
|
||||||
initial_description = request.GET.get("description")
|
initial_description = request.GET.get("description")
|
||||||
@@ -169,8 +169,6 @@ def new(request):
|
|||||||
return HttpResponseRedirect(reverse("bookmarks:close"))
|
return HttpResponseRedirect(reverse("bookmarks:close"))
|
||||||
else:
|
else:
|
||||||
return HttpResponseRedirect(reverse("bookmarks:index"))
|
return HttpResponseRedirect(reverse("bookmarks:index"))
|
||||||
else:
|
|
||||||
status = 422
|
|
||||||
else:
|
else:
|
||||||
form = BookmarkForm()
|
form = BookmarkForm()
|
||||||
if initial_url:
|
if initial_url:
|
||||||
@@ -186,6 +184,7 @@ def new(request):
|
|||||||
if initial_mark_unread:
|
if initial_mark_unread:
|
||||||
form.initial["unread"] = "true"
|
form.initial["unread"] = "true"
|
||||||
|
|
||||||
|
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"auto_close": initial_auto_close,
|
"auto_close": initial_auto_close,
|
||||||
@@ -216,9 +215,10 @@ def edit(request, bookmark_id: int):
|
|||||||
|
|
||||||
form.initial["tag_string"] = build_tag_string(bookmark.tag_names, " ")
|
form.initial["tag_string"] = build_tag_string(bookmark.tag_names, " ")
|
||||||
|
|
||||||
|
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||||
context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url}
|
context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url}
|
||||||
|
|
||||||
return render(request, "bookmarks/edit.html", context)
|
return render(request, "bookmarks/edit.html", context, status=status)
|
||||||
|
|
||||||
|
|
||||||
def remove(request, bookmark_id: int):
|
def remove(request, bookmark_id: int):
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ class BookmarkListContext:
|
|||||||
self.show_favicons = user_profile.enable_favicons
|
self.show_favicons = user_profile.enable_favicons
|
||||||
self.show_preview_images = user_profile.enable_preview_images
|
self.show_preview_images = user_profile.enable_preview_images
|
||||||
self.show_notes = user_profile.permanent_notes
|
self.show_notes = user_profile.permanent_notes
|
||||||
|
self.collapse_side_panel = user_profile.collapse_side_panel
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
|
||||||
|
|||||||
10
bookmarks/views/custom_css.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
custom_css_cache_max_age = 2592000 # 30 days
|
||||||
|
|
||||||
|
|
||||||
|
def custom_css(request):
|
||||||
|
css = request.user_profile.custom_css
|
||||||
|
response = HttpResponse(css, content_type="text/css")
|
||||||
|
response["Cache-Control"] = f"public, max-age={custom_css_cache_max_age}"
|
||||||
|
return response
|
||||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12.6-alpine3.20 AS python-base
|
FROM python:3.12.8-alpine3.21 AS build-deps
|
||||||
# Add required packages
|
# Add required packages
|
||||||
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
||||||
# libpq-dev: build Postgres client from source
|
# libpq-dev: build Postgres client from source
|
||||||
@@ -18,24 +18,8 @@ FROM python:3.12.6-alpine3.20 AS python-base
|
|||||||
# libffi-dev openssl-dev rust cargo: build Python cryptography from source
|
# libffi-dev openssl-dev rust cargo: build Python cryptography from source
|
||||||
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo
|
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
|
# install python dependencies
|
||||||
|
|
||||||
FROM python-base AS python-build
|
|
||||||
# install build dependencies
|
|
||||||
COPY requirements.txt requirements.txt
|
COPY requirements.txt requirements.txt
|
||||||
RUN pip install -U pip && pip install -r requirements.txt
|
|
||||||
# copy files needed for Django build
|
|
||||||
COPY . .
|
|
||||||
COPY --from=node-build /etc/linkding .
|
|
||||||
# remove style sources
|
|
||||||
RUN rm -rf bookmarks/styles
|
|
||||||
# run Django part of the build
|
|
||||||
RUN mkdir data && \
|
|
||||||
python manage.py collectstatic
|
|
||||||
|
|
||||||
|
|
||||||
FROM python-base AS prod-deps
|
|
||||||
COPY requirements.txt ./requirements.txt
|
|
||||||
# Need to build psycopg2 from source for ARM platforms
|
# Need to build psycopg2 from source for ARM platforms
|
||||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||||
RUN mkdir /opt/venv && \
|
RUN mkdir /opt/venv && \
|
||||||
@@ -44,7 +28,7 @@ RUN mkdir /opt/venv && \
|
|||||||
/opt/venv/bin/pip install -r requirements.txt
|
/opt/venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
FROM python-base AS compile-icu
|
FROM build-deps AS compile-icu
|
||||||
# Defines SQLite version
|
# Defines SQLite version
|
||||||
# Since this is only needed for downloading the header files this probably
|
# Since this is only needed for downloading the header files this probably
|
||||||
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
||||||
@@ -65,7 +49,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
|||||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12.6-alpine3.20 AS linkding
|
FROM python:3.12.8-alpine3.21 AS linkding
|
||||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||||
# install runtime dependencies
|
# install runtime dependencies
|
||||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||||
@@ -74,19 +58,23 @@ RUN set -x ; \
|
|||||||
addgroup -g 82 -S www-data ; \
|
addgroup -g 82 -S www-data ; \
|
||||||
adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1
|
adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# copy prod dependencies
|
# copy python dependencies
|
||||||
COPY --from=prod-deps /opt/venv /opt/venv
|
COPY --from=build-deps /opt/venv /opt/venv
|
||||||
# copy output from build stage
|
# copy output from node build
|
||||||
COPY --from=python-build /etc/linkding/static static/
|
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
|
||||||
# copy compiled icu extension
|
# copy compiled icu extension
|
||||||
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||||
# copy application code
|
# copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Activate virtual env
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH=/opt/venv/bin:$PATH
|
||||||
|
# Generate static files, remove source styles that are not needed
|
||||||
|
RUN mkdir data && \
|
||||||
|
python manage.py collectstatic
|
||||||
|
|
||||||
# Expose uwsgi server at port 9090
|
# Expose uwsgi server at port 9090
|
||||||
EXPOSE 9090
|
EXPOSE 9090
|
||||||
# Activate virtual env
|
|
||||||
ENV VIRTUAL_ENV /opt/venv
|
|
||||||
ENV PATH /opt/venv/bin:$PATH
|
|
||||||
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
||||||
RUN chmod g+w . && \
|
RUN chmod g+w . && \
|
||||||
chmod +x ./bootstrap.sh
|
chmod +x ./bootstrap.sh
|
||||||
@@ -100,18 +88,20 @@ CMD ["./bootstrap.sh"]
|
|||||||
FROM node:18-alpine AS ublock-build
|
FROM node:18-alpine AS ublock-build
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# Install necessary tools
|
# Install necessary tools
|
||||||
RUN apk add --no-cache curl jq unzip
|
# Download and unzip the latest uBlock Origin Lite release
|
||||||
# Fetch the latest release tag
|
# Patch manifest to enable annoyances by default
|
||||||
# Download the library
|
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
|
||||||
# Unzip the library
|
RUN apk add --no-cache curl jq unzip && \
|
||||||
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
|
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
|
||||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
|
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
|
||||||
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
|
echo "Downloading $DOWNLOAD_URL" && \
|
||||||
unzip uBlock0.zip
|
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
|
||||||
# Patch assets.json to enable easylist-cookies by default
|
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
|
||||||
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
rm uBOLite.zip && \
|
||||||
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
|
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
|
||||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
uBOLite.chromium.mv3/manifest.json > temp.json && \
|
||||||
|
mv temp.json uBOLite.chromium.mv3/manifest.json && \
|
||||||
|
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||||
|
|
||||||
|
|
||||||
FROM linkding AS linkding-plus
|
FROM linkding AS linkding-plus
|
||||||
@@ -119,9 +109,11 @@ FROM linkding AS linkding-plus
|
|||||||
RUN apk update && apk add nodejs npm chromium
|
RUN apk update && apk add nodejs npm chromium
|
||||||
# install single-file from fork for now, which contains several hotfixes
|
# install single-file from fork for now, which contains several hotfixes
|
||||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||||
# copy uBlock0
|
# copy uBlock
|
||||||
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
|
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
|
||||||
# create chromium profile folder for user running background tasks
|
# create chromium profile folder for user running background tasks and set permissions
|
||||||
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
|
RUN mkdir -p chromium-profile && \
|
||||||
|
chown -R www-data:www-data chromium-profile && \
|
||||||
|
chown -R www-data:www-data uBOLite.chromium.mv3
|
||||||
# enable snapshot support
|
# enable snapshot support
|
||||||
ENV LD_ENABLE_SNAPSHOTS=True
|
ENV LD_ENABLE_SNAPSHOTS=True
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12.6-slim-bookworm AS python-base
|
FROM python:3.12.8-slim-bookworm AS build-deps
|
||||||
# Add required packages
|
# Add required packages
|
||||||
# build-essential pkg-config: build Python packages from source
|
# build-essential pkg-config: build Python packages from source
|
||||||
# libpq-dev: build Postgres client from source
|
# libpq-dev: build Postgres client from source
|
||||||
@@ -20,24 +20,8 @@ RUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev li
|
|||||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
|
# install python dependencies
|
||||||
|
|
||||||
FROM python-base AS python-build
|
|
||||||
# install build dependencies
|
|
||||||
COPY requirements.txt requirements.txt
|
COPY requirements.txt requirements.txt
|
||||||
RUN pip install -U pip && pip install -r requirements.txt
|
|
||||||
# copy files needed for Django build
|
|
||||||
COPY . .
|
|
||||||
COPY --from=node-build /etc/linkding .
|
|
||||||
# remove style sources
|
|
||||||
RUN rm -rf bookmarks/styles
|
|
||||||
# run Django part of the build
|
|
||||||
RUN mkdir data && \
|
|
||||||
python manage.py collectstatic
|
|
||||||
|
|
||||||
|
|
||||||
FROM python-base AS prod-deps
|
|
||||||
COPY requirements.txt ./requirements.txt
|
|
||||||
# Need to build psycopg2 from source for ARM platforms
|
# Need to build psycopg2 from source for ARM platforms
|
||||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||||
RUN mkdir /opt/venv && \
|
RUN mkdir /opt/venv && \
|
||||||
@@ -46,7 +30,7 @@ RUN mkdir /opt/venv && \
|
|||||||
/opt/venv/bin/pip install -r requirements.txt
|
/opt/venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
FROM python-base AS compile-icu
|
FROM build-deps AS compile-icu
|
||||||
# Defines SQLite version
|
# Defines SQLite version
|
||||||
# Since this is only needed for downloading the header files this probably
|
# Since this is only needed for downloading the header files this probably
|
||||||
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
||||||
@@ -67,27 +51,31 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
|||||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12.6-slim-bookworm as linkding
|
FROM python:3.12.8-slim-bookworm AS linkding
|
||||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||||
|
# install runtime dependencies
|
||||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# copy prod dependencies
|
# copy python dependencies
|
||||||
COPY --from=prod-deps /opt/venv /opt/venv
|
COPY --from=build-deps /opt/venv /opt/venv
|
||||||
# copy output from build stage
|
# copy output from node build
|
||||||
COPY --from=python-build /etc/linkding/static static/
|
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
|
||||||
# copy compiled icu extension
|
# copy compiled icu extension
|
||||||
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||||
# copy application code
|
# copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Activate virtual env
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH=/opt/venv/bin:$PATH
|
||||||
|
# Generate static files
|
||||||
|
RUN mkdir data && \
|
||||||
|
python manage.py collectstatic
|
||||||
|
|
||||||
# Expose uwsgi server at port 9090
|
# Expose uwsgi server at port 9090
|
||||||
EXPOSE 9090
|
EXPOSE 9090
|
||||||
# Activate virtual env
|
|
||||||
ENV VIRTUAL_ENV /opt/venv
|
|
||||||
ENV PATH /opt/venv/bin:$PATH
|
|
||||||
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
||||||
RUN ["chmod", "g+w", "."]
|
RUN chmod g+w . && \
|
||||||
# Run bootstrap logic
|
chmod +x ./bootstrap.sh
|
||||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||||
@@ -98,18 +86,20 @@ CMD ["./bootstrap.sh"]
|
|||||||
FROM node:18-alpine AS ublock-build
|
FROM node:18-alpine AS ublock-build
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# Install necessary tools
|
# Install necessary tools
|
||||||
RUN apk add --no-cache curl jq unzip
|
# Download and unzip the latest uBlock Origin Lite release
|
||||||
# Fetch the latest release tag
|
# Patch manifest to enable annoyances by default
|
||||||
# Download the library
|
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
|
||||||
# Unzip the library
|
RUN apk add --no-cache curl jq unzip && \
|
||||||
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
|
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
|
||||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
|
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
|
||||||
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
|
echo "Downloading $DOWNLOAD_URL" && \
|
||||||
unzip uBlock0.zip
|
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
|
||||||
# Patch assets.json to enable easylist-cookies by default
|
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
|
||||||
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
rm uBOLite.zip && \
|
||||||
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
|
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
|
||||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
uBOLite.chromium.mv3/manifest.json > temp.json && \
|
||||||
|
mv temp.json uBOLite.chromium.mv3/manifest.json && \
|
||||||
|
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||||
|
|
||||||
|
|
||||||
FROM linkding AS linkding-plus
|
FROM linkding AS linkding-plus
|
||||||
@@ -123,9 +113,11 @@ RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
|
|||||||
apt-get update && apt-get install -y nodejs
|
apt-get update && apt-get install -y nodejs
|
||||||
# install single-file from fork for now, which contains several hotfixes
|
# install single-file from fork for now, which contains several hotfixes
|
||||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||||
# create chromium profile folder for user running background tasks
|
# copy uBlock
|
||||||
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
|
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
|
||||||
# copy uBlock0
|
# create chromium profile folder for user running background tasks and set permissions
|
||||||
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
|
RUN mkdir -p chromium-profile && \
|
||||||
|
chown -R www-data:www-data chromium-profile && \
|
||||||
|
chown -R www-data:www-data uBOLite.chromium.mv3
|
||||||
# enable snapshot support
|
# enable snapshot support
|
||||||
ENV LD_ENABLE_SNAPSHOTS=True
|
ENV LD_ENABLE_SNAPSHOTS=True
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export default defineConfig({
|
|||||||
customCss: [
|
customCss: [
|
||||||
'./src/styles/custom.css',
|
'./src/styles/custom.css',
|
||||||
],
|
],
|
||||||
|
editLink: {
|
||||||
|
baseUrl: 'https://github.com/sissbruecker/linkding/edit/master/docs/',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
1199
docs/package-lock.json
generated
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.3",
|
"@astrojs/check": "^0.9.3",
|
||||||
"@astrojs/starlight": "^0.27.1",
|
"@astrojs/starlight": "^0.27.1",
|
||||||
"astro": "^4.15.8",
|
"astro": "^4.16.18",
|
||||||
"sharp": "^0.32.5",
|
"sharp": "^0.32.5",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
BIN
docs/public/donations/2024-10-04-django.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/public/donations/2024-10-04-internet-archive.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/public/donations/2024-10-04-noyb.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/public/donations/2024-10-04-singlefile.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
@@ -9,9 +9,35 @@ description: "Acknowledgements and thanks to contributors and sponsors"
|
|||||||
|
|
||||||
See the table below for a list of donations.
|
See the table below for a list of donations.
|
||||||
|
|
||||||
| Source | Description | Amount | Donated to |
|
<table>
|
||||||
|---------------------------------------|---------------------------------------------|---------|------------------------------------------------------------------|
|
<thead>
|
||||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/2023-10-11-internet-archive.png) |
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Donated to</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
|
||||||
|
<td>Linkding hosting June 2022 - September 2023</td>
|
||||||
|
<td>$163.50</td>
|
||||||
|
<td><a href="/donations/2023-10-11-internet-archive.png">Internet Archive</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
|
||||||
|
<td>Linkding hosting October 2023 - September 2024</td>
|
||||||
|
<td>$287.04</td>
|
||||||
|
<td>
|
||||||
|
<a href="/donations/2024-10-04-django.png">Django</a><br>
|
||||||
|
<a href="/donations/2024-10-04-singlefile.png">SingleFile</a><br>
|
||||||
|
<a href="/donations/2024-10-04-internet-archive.png">Internet Archive</a><br>
|
||||||
|
<a href="/donations/2024-10-04-noyb.png">NOYB</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## JetBrains
|
## JetBrains
|
||||||
|
|
||||||
|
|||||||
@@ -127,11 +127,9 @@ POST /api/bookmarks/
|
|||||||
Creates a new bookmark. Tags are simply assigned using their names. Including
|
Creates a new bookmark. Tags are simply assigned using their names. Including
|
||||||
`is_archived: true` saves a bookmark directly to the archive.
|
`is_archived: true` saves a bookmark directly to the archive.
|
||||||
|
|
||||||
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request. If you have an application where you want to keep using scraped metadata, but also allow users to leave the title or description empty, you should:
|
If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in the future to return an error instead.
|
||||||
|
|
||||||
- Fetch the scraped title and description using the `/check` endpoint.
|
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
|
||||||
- Prefill the title and description fields in your app with the fetched values and allow users to clear those values.
|
|
||||||
- Add the `disable_scraping` query parameter to prevent the API from adding them back again.
|
|
||||||
|
|
||||||
Example payload:
|
Example payload:
|
||||||
|
|
||||||
@@ -155,36 +153,17 @@ Example payload:
|
|||||||
|
|
||||||
```
|
```
|
||||||
PUT /api/bookmarks/<id>/
|
PUT /api/bookmarks/<id>/
|
||||||
```
|
|
||||||
|
|
||||||
Updates a bookmark.
|
|
||||||
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
|
|
||||||
Tags are simply assigned using their names.
|
|
||||||
|
|
||||||
Example payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "https://example.com",
|
|
||||||
"title": "Example title",
|
|
||||||
"description": "Example description",
|
|
||||||
"tag_names": [
|
|
||||||
"tag1",
|
|
||||||
"tag2"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Patch**
|
|
||||||
|
|
||||||
```
|
|
||||||
PATCH /api/bookmarks/<id>/
|
PATCH /api/bookmarks/<id>/
|
||||||
```
|
```
|
||||||
|
|
||||||
Updates a bookmark partially.
|
Updates a bookmark.
|
||||||
Allows to modify individual fields of a bookmark.
|
When using `POST`, at least all required fields must be provided (currently only `url`).
|
||||||
|
When using `PATCH`, only the fields that should be updated need to be provided.
|
||||||
|
Regardless which method is used, any field that is not provided is not modified.
|
||||||
Tags are simply assigned using their names.
|
Tags are simply assigned using their names.
|
||||||
|
|
||||||
|
If the provided URL is already bookmarked this returns an error.
|
||||||
|
|
||||||
Example payload:
|
Example payload:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -6,17 +6,24 @@ description: "Community projects around linkding"
|
|||||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.
|
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.
|
||||||
|
|
||||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||||
|
- [cosmicding](https://github.com/vkhitrin/cosmicding) Desktop client built using [libcosmic](https://github.com/pop-os/libcosmic). By [vkhitrin](https://github.com/vkhitrin)
|
||||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||||
- [go-linkding](https://github.com/piero-vic/go-linkding) A Go client library to interact with the linkding REST API. By [piero-vic](https://github.com/piero-vic)
|
- [go-linkding](https://github.com/piero-vic/go-linkding) A Go client library to interact with the linkding REST API. By [piero-vic](https://github.com/piero-vic)
|
||||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||||
|
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
|
||||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||||
|
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)
|
||||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||||
|
- [linkding-healthcheck](https://github.com/sebw/linkding-healthcheck) A Go application that checks the health of your bookmarks and add a tag on dead and problematic URLs. By [sebw](https://github.com/sebw)
|
||||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
- [linkding-reminder](https://github.com/sebw/linkding-reminder) A Python application that will send an email reminder for links with a specific tag. By [sebw](https://github.com/sebw)
|
||||||
|
- [linkding-rs](https://github.com/zbrox/linkding-rs) A Rust client library to interact with the linkding REST API with cross platform support to be easily used in Android or iOS apps. By [zbrox](https://github.com/zbrox)
|
||||||
|
- [Linkdy](https://github.com/JGeek00/linkdy): An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). By [JGeek00](https://github.com/JGeek00).
|
||||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||||
|
- [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ You can also check the [Community section](/community) for other pre-made shortc
|
|||||||
The font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:
|
The font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
html {
|
:root {
|
||||||
--font-size: 0.75rem;
|
--font-size: 0.75rem;
|
||||||
--font-size-sm: 0.7rem;
|
--font-size-sm: 0.7rem;
|
||||||
--font-size-lg: 0.9rem;
|
--font-size-lg: 0.9rem;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ Values: `True`, `False` | Default = `False`
|
|||||||
|
|
||||||
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
|
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
|
||||||
When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.
|
When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.
|
||||||
Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding.
|
Users are associated by the email address provided from the OIDC provider, which is by default also used as username in linkding. You can configure a custom claim to be used as username with `OIDC_USERNAME_CLAIM`.
|
||||||
If there is no user with that email address as username, a new user is created automatically.
|
If there is no user with that email address as username, a new user is created automatically.
|
||||||
|
|
||||||
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
|
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
|
||||||
@@ -124,6 +124,8 @@ The following options can be configured:
|
|||||||
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
|
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
|
||||||
- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.
|
- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.
|
||||||
- `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`.
|
- `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`.
|
||||||
|
- `OIDC_RP_SCOPES` - Scopes asked for on the authorization flow. Default is `oidc email profile`.
|
||||||
|
- `OIDC_USERNAME_CLAIM` - A custom claim to used as username for new accounts, for example `preferred_username`. If the configured claim does not exist or is empty, the email claim is used as fallback. Default is `email`.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
||||||
@@ -268,3 +270,9 @@ When creating HTML archive snapshots, pass additional options to the `single-fil
|
|||||||
See `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js
|
See `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js
|
||||||
|
|
||||||
Example: `LD_SINGLEFILE_OPTIONS=--user-agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"`
|
Example: `LD_SINGLEFILE_OPTIONS=--user-agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"`
|
||||||
|
|
||||||
|
### `LD_DISABLE_REQUEST_LOGS`
|
||||||
|
|
||||||
|
Values: `true` or `false` | Default = `false`
|
||||||
|
|
||||||
|
Set uWSGI [disable-logging](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#disable-logging) parameter to disable request logs, except for requests with a client (4xx) or server (5xx) error response.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
[data-has-hero] header {
|
[data-has-hero] header {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-bottom: solid 1px transparent;
|
border-bottom: solid 1px transparent;
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
162
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.34.0",
|
"version": "1.36.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.34.0",
|
"version": "1.36.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/turbo": "^8.0.6",
|
"@hotwired/turbo": "^8.0.6",
|
||||||
@@ -238,9 +238,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
|
||||||
"integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==",
|
"integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -251,9 +251,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
|
||||||
"integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==",
|
"integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -264,9 +264,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
|
||||||
"integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==",
|
"integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -277,9 +277,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
|
||||||
"integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==",
|
"integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -290,9 +290,22 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
|
||||||
"integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==",
|
"integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -303,9 +316,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
|
||||||
"integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==",
|
"integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -316,9 +329,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
|
||||||
"integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==",
|
"integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -328,10 +341,23 @@
|
|||||||
],
|
],
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
|
||||||
"integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==",
|
"integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -341,10 +367,23 @@
|
|||||||
],
|
],
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
|
||||||
"integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==",
|
"integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -355,9 +394,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
|
||||||
"integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==",
|
"integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -368,9 +407,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
|
||||||
"integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==",
|
"integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -381,9 +420,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
|
||||||
"integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==",
|
"integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -394,9 +433,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
|
||||||
"integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==",
|
"integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1271,9 +1310,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2041,9 +2080,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.13.0",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
|
||||||
"integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==",
|
"integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.5"
|
"@types/estree": "1.0.5"
|
||||||
@@ -2056,19 +2095,22 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.13.0",
|
"@rollup/rollup-android-arm-eabi": "4.22.4",
|
||||||
"@rollup/rollup-android-arm64": "4.13.0",
|
"@rollup/rollup-android-arm64": "4.22.4",
|
||||||
"@rollup/rollup-darwin-arm64": "4.13.0",
|
"@rollup/rollup-darwin-arm64": "4.22.4",
|
||||||
"@rollup/rollup-darwin-x64": "4.13.0",
|
"@rollup/rollup-darwin-x64": "4.22.4",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.13.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.13.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.22.4",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.13.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.22.4",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.13.0",
|
"@rollup/rollup-linux-arm64-musl": "4.22.4",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.13.0",
|
"@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.13.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.22.4",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.13.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.22.4",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.13.0",
|
"@rollup/rollup-linux-x64-gnu": "4.22.4",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.13.0",
|
"@rollup/rollup-linux-x64-musl": "4.22.4",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.22.4",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.22.4",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.22.4",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.35.0",
|
"version": "1.38.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ click==8.1.7
|
|||||||
# via black
|
# via black
|
||||||
coverage==7.6.1
|
coverage==7.6.1
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
django==5.1.1
|
django==5.1.5
|
||||||
# via django-debug-toolbar
|
# via django-debug-toolbar
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ cryptography==43.0.1
|
|||||||
# josepy
|
# josepy
|
||||||
# mozilla-django-oidc
|
# mozilla-django-oidc
|
||||||
# pyopenssl
|
# pyopenssl
|
||||||
django==5.1.1
|
django==5.1.5
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# django-registration
|
# django-registration
|
||||||
@@ -76,7 +76,7 @@ urllib3==2.2.3
|
|||||||
# via
|
# via
|
||||||
# requests
|
# requests
|
||||||
# waybackpy
|
# waybackpy
|
||||||
uwsgi==2.0.26
|
uwsgi==2.0.28
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
waybackpy==3.0.6
|
waybackpy==3.0.6
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
rm -rf ublock0.chromium
|
rm -rf uBOLite.chromium.mv3
|
||||||
|
|
||||||
TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name')
|
# Download uBlock Origin Lite
|
||||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip
|
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name')
|
||||||
curl -L -o uBlock0.zip $DOWNLOAD_URL
|
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip
|
||||||
unzip uBlock0.zip
|
echo "Downloading $DOWNLOAD_URL"
|
||||||
rm uBlock0.zip
|
curl -L -o uBOLite.zip $DOWNLOAD_URL
|
||||||
|
unzip uBOLite.zip -d uBOLite.chromium.mv3
|
||||||
|
rm uBOLite.zip
|
||||||
|
|
||||||
curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
# Patch uBlock Origin Lite to respect rulesets enabled in manifest.json
|
||||||
jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json
|
sed -i '' "s/const out = \[ 'default' \];/const out = await dnr.getEnabledRulesets();/" uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
|
||||||
|
# Enable annoyances rulesets in manifest.json
|
||||||
|
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' uBOLite.chromium.mv3/manifest.json > temp.json
|
||||||
|
mv temp.json uBOLite.chromium.mv3/manifest.json
|
||||||
|
|
||||||
mkdir -p chromium-profile
|
mkdir -p chromium-profile
|
||||||
|
|||||||
@@ -128,14 +128,6 @@ STATIC_URL = "/" + LD_CONTEXT_PATH + "static/"
|
|||||||
# Collect static files in static folder
|
# Collect static files in static folder
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
|
||||||
# Resolve theme files from style source folder
|
|
||||||
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
|
||||||
# Resolve downloaded files in dev environment
|
|
||||||
os.path.join(BASE_DIR, "data", "favicons"),
|
|
||||||
os.path.join(BASE_DIR, "data", "previews"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# REST framework
|
# REST framework
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
@@ -202,8 +194,10 @@ if LD_ENABLE_OIDC:
|
|||||||
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
||||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
||||||
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
||||||
|
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
|
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_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
|
||||||
|
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
|
||||||
|
|
||||||
# Enable authentication proxy support if configured
|
# 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", "1")
|
||||||
@@ -304,7 +298,7 @@ LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
|
|||||||
'--browser-arg="--headless=new"',
|
'--browser-arg="--headless=new"',
|
||||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||||
'--browser-arg="--no-sandbox"',
|
'--browser-arg="--no-sandbox"',
|
||||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ INTERNAL_IPS = [
|
|||||||
# Allow access through ngrok
|
# Allow access through ngrok
|
||||||
CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"]
|
CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"]
|
||||||
|
|
||||||
|
STATICFILES_DIRS = [
|
||||||
|
# Resolve theme files from style source folder
|
||||||
|
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
||||||
|
# Resolve downloaded files in dev environment
|
||||||
|
os.path.join(BASE_DIR, "data", "favicons"),
|
||||||
|
os.path.join(BASE_DIR, "data", "previews"),
|
||||||
|
]
|
||||||
|
|
||||||
# Enable debug logging
|
# Enable debug logging
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ class LinkdingLoginView(auth_views.LoginView):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingPasswordChangeView(auth_views.PasswordChangeView):
|
||||||
|
def form_invalid(self, form):
|
||||||
|
response = super().form_invalid(form)
|
||||||
|
response.status_code = 422
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", linkding_admin_site.urls),
|
path("admin/", linkding_admin_site.urls),
|
||||||
path(
|
path(
|
||||||
@@ -50,7 +57,7 @@ urlpatterns = [
|
|||||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||||
path(
|
path(
|
||||||
"change-password/",
|
"change-password/",
|
||||||
auth_views.PasswordChangeView.as_view(),
|
LinkdingPasswordChangeView.as_view(),
|
||||||
name="change_password",
|
name="change_password",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
|||||||
static-map = /static=static
|
static-map = /static=static
|
||||||
static-map = /static=data/favicons
|
static-map = /static=data/favicons
|
||||||
static-map = /static=data/previews
|
static-map = /static=data/previews
|
||||||
|
static-map = /robots.txt=static/robots.txt
|
||||||
processes = 2
|
processes = 2
|
||||||
threads = 2
|
threads = 2
|
||||||
pidfile = /tmp/linkding.pid
|
pidfile = /tmp/linkding.pid
|
||||||
@@ -18,6 +19,7 @@ if-env = LD_CONTEXT_PATH
|
|||||||
static-map = /%(_)static=static
|
static-map = /%(_)static=static
|
||||||
static-map = /%(_)static=data/favicons
|
static-map = /%(_)static=data/favicons
|
||||||
static-map = /%(_)static=data/previews
|
static-map = /%(_)static=data/previews
|
||||||
|
static-map = /%(_)robots.txt=static/robots.txt
|
||||||
endif =
|
endif =
|
||||||
|
|
||||||
if-env = LD_REQUEST_TIMEOUT
|
if-env = LD_REQUEST_TIMEOUT
|
||||||
@@ -29,3 +31,9 @@ endif =
|
|||||||
if-env = LD_LOG_X_FORWARDED_FOR
|
if-env = LD_LOG_X_FORWARDED_FOR
|
||||||
log-x-forwarded-for = %(_)
|
log-x-forwarded-for = %(_)
|
||||||
endif =
|
endif =
|
||||||
|
|
||||||
|
if-env = LD_DISABLE_REQUEST_LOGS=true
|
||||||
|
disable-logging = true
|
||||||
|
log-4xx = true
|
||||||
|
log-5xx = true
|
||||||
|
endif =
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.35.0
|
1.38.0
|
||||||
|
|||||||