Compare commits

...

26 Commits

Author SHA1 Message Date
Sascha Ißbrücker
b736464f3f Bump version 2024-09-10 21:39:48 +02:00
Sascha Ißbrücker
7572aa5bc9 Fix auto-tagging when URL includes port (#820) 2024-09-10 21:19:20 +02:00
Sascha Ißbrücker
cb0301fd9e Fix inconsistent tag order in bookmarks (#819) 2024-09-10 21:06:57 +02:00
Sascha Ißbrücker
b30486317d Allow pre-filling notes in new bookmark form (#812) 2024-08-31 23:20:44 +02:00
Sascha Ißbrücker
1c6e5902db Additional filter parameters for RSS feeds (#811) 2024-08-31 22:58:41 +02:00
Sascha Ißbrücker
20fe88dd57 Return bookmark tags in RSS feeds (#810) 2024-08-31 22:41:22 +02:00
Sascha Ißbrücker
aad62f61c9 Allow configuring guest user profile (#809) 2024-08-31 20:25:43 +02:00
Sascha Ißbrücker
79bf4b38c6 remove unused context processor 2024-08-31 19:10:42 +02:00
Sascha Ißbrücker
5eadb3ede3 Allow configuring landing page for unauthenticated users (#808)
* allow configuring landing page

* add tests
2024-08-31 15:39:22 +02:00
Sascha Ißbrücker
36749c398b Update CHANGELOG.md 2024-08-30 19:51:39 +02:00
Sascha Ißbrücker
190b5aeeca Bump version 2024-08-30 18:20:58 +02:00
Sascha Ißbrücker
1122d18e18 Show web archive fallback link in details modal 2024-08-29 23:39:07 +02:00
Sascha Ißbrücker
0fe6304328 Fix overflow in settings page (#805) 2024-08-29 23:04:11 +02:00
Sascha Ißbrücker
7d4e65976f Run tests in parallel 2024-08-29 22:45:43 +02:00
Sascha Ißbrücker
749bc1ef63 Generate fallback URLs for web archive links (#804)
* generate fallback web archive URL if none exists

* remove fallback web archive snapshot creation

* fix test
2024-08-29 22:45:10 +02:00
Casey Link
36a84276a2 Add OCI source annotation to link back to source repo (#701)
* Add OCI source annotation to link back to source repo

This commit adds the `org.opencontainers.image.source` label to the
built container images.

This label is helpful for tools to be able to link back from the
container image to the source repo.

For example, for those that use Renovate to help auto update
dependencies, this will result in the latest releases release
notes/changelog being included in the PR which is very handy!

* move label to base image

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-08-28 22:57:58 +02:00
Howard Wilson
b72697b819 Allow use of standard docker TZ env var (#765)
* Allow use of standard docker TZ env var

* use getenv api

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-08-28 22:57:15 +02:00
Meng Sen
d9362c9b9c Add resource linkding logo (#788)
* Add resource linkding logo

If you need to use the icon, you can download ` logo.png` yourself. If you need to limit the size, you can use `logo.svg` to convert it to png.

* move to assets

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-08-28 22:38:32 +02:00
dependabot[bot]
b0610db406 Bump urllib3 from 2.1.0 to 2.2.2 (#762)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.1.0 to 2.2.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.1.0...2.2.2)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 22:30:19 +02:00
dependabot[bot]
af16a9e727 Bump djangorestframework from 3.14.0 to 3.15.2 (#769)
Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.14.0 to 3.15.2.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 22:30:09 +02:00
dependabot[bot]
d898c1be4d Bump certifi from 2023.11.17 to 2024.7.4 (#775)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 22:29:37 +02:00
dependabot[bot]
0282220307 Bump django from 5.0.3 to 5.0.8 (#795)
Bumps [django](https://github.com/django/django) from 5.0.3 to 5.0.8.
- [Commits](https://github.com/django/django/compare/5.0.3...5.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-28 22:29:08 +02:00
VolumeData21
bb243b382d removed version line from docker compose yaml (#800)
Co-authored-by: andrew <110792083+beboprocky@users.noreply.github.com>
2024-08-28 22:28:47 +02:00
Filipe Belatti
fbc97a3841 Add Pinkt to the Community section (#772)
Pinkt is an Android app which added support for Linkding starting from version 3.0
2024-07-02 21:28:57 +02:00
Sascha Ißbrücker
380f5ed19c Include favicons and thumbnails in REST API (#763)
* Include favicons and thumbnails in REST API

* Fix serialization for custom endpoints
2024-06-18 23:07:14 +02:00
Sascha Ißbrücker
b28352fb28 Update CHANGELOG.md 2024-06-16 22:45:01 +02:00
56 changed files with 1015 additions and 743 deletions

View File

@@ -1,5 +1,52 @@
# Changelog
## v1.31.1 (30/08/2024)
### What's Changed
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
### New Contributors
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
---
## v1.31.0 (16/06/2024)
### What's Changed
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
### New Contributors
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
---
## v1.30.0 (20/04/2024)
### What's Changed

View File

@@ -7,7 +7,7 @@ tasks:
python manage.py process_tasks
test:
pytest
pytest -n auto
format:
black bookmarks

View File

@@ -237,6 +237,7 @@ This section lists community projects around using linkding, in alphabetical ord
- [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).
- [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)
- [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)
## Acknowledgements + Donations

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

1
assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>

After

Width:  |  Height:  |  Size: 688 B

View File

@@ -1,3 +1,5 @@
import logging
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
@@ -19,6 +21,8 @@ from bookmarks.services.bookmarks import (
)
from bookmarks.services.website_loader import WebsiteMetadata
logger = logging.getLogger(__name__)
class BookmarkViewSet(
viewsets.GenericViewSet,
@@ -52,7 +56,7 @@ class BookmarkViewSet(
return Bookmark.objects.all().filter(owner=user)
def get_serializer_context(self):
return {"user": self.request.user}
return {"request": self.request, "user": self.request.user}
@action(methods=["get"], detail=False)
def archived(self, request):
@@ -60,8 +64,8 @@ class BookmarkViewSet(
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["get"], detail=False)
@@ -73,8 +77,8 @@ class BookmarkViewSet(
user, request.user_profile, search, public_only
)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
serializer = self.get_serializer(page, many=True)
data = serializer.data
return self.get_paginated_response(data)
@action(methods=["post"], detail=True)
@@ -112,7 +116,13 @@ class BookmarkViewSet(
profile = request.user.profile
auto_tags = []
if profile.auto_tagging_rules:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
try:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
exc_info=e,
)
return Response(
{

View File

@@ -1,4 +1,5 @@
from django.db.models import prefetch_related_objects
from django.templatetags.static import static
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
@@ -31,6 +32,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
"website_title",
"website_description",
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
"is_archived",
"unread",
"shared",
@@ -42,6 +45,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
"website_title",
"website_description",
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
"date_added",
"date_modified",
]
@@ -56,6 +61,24 @@ class BookmarkSerializer(serializers.ModelSerializer):
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()
preview_image_url = serializers.SerializerMethodField()
def get_favicon_url(self, obj: Bookmark):
if not obj.favicon_file:
return None
request = self.context.get("request")
favicon_file_path = static(obj.favicon_file)
favicon_url = request.build_absolute_uri(favicon_file_path)
return favicon_url
def get_preview_image_url(self, obj: Bookmark):
if not obj.preview_image_file:
return None
request = self.context.get("request")
preview_image_file_path = static(obj.preview_image_file)
preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url
def create(self, validated_data):
bookmark = Bookmark()

View File

@@ -18,19 +18,5 @@ def toasts(request):
}
def public_shares(request):
# Only check for public shares for anonymous users
if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(
None, request.user_profile, BookmarkSearch(), True
)
has_public_shares = query_set.count() > 0
return {
"has_public_shares": has_public_shares,
}
return {}
def app_version(request):
return {"app_version": utils.app_version}

View File

@@ -2,7 +2,8 @@ import unicodedata
from dataclasses import dataclass
from django.contrib.syndication.views import Feed
from django.db.models import QuerySet
from django.db.models import QuerySet, prefetch_related_objects
from django.http import HttpRequest
from django.urls import reverse
from bookmarks import queries
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass
class FeedContext:
request: HttpRequest
feed_token: FeedToken | None
query_set: QuerySet[Bookmark]
@@ -26,13 +28,27 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
search = BookmarkSearch(
q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""),
)
return FeedContext(feed_token, query_set)
query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set)
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
raise NotImplementedError
def items(self, context: FeedContext):
limit = context.request.GET.get("limit", 100)
if limit:
data = context.query_set[: int(limit)]
else:
data = list(context.query_set)
prefetch_related_objects(data, "tags")
return data
def item_title(self, item: Bookmark):
return sanitize(item.resolved_title)
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
def item_pubdate(self, item: Bookmark):
return item.date_added
def item_categories(self, item: Bookmark):
return item.tag_names
class AllBookmarksFeed(BaseBookmarksFeed):
title = "All bookmarks"
description = "All bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class UnreadBookmarksFeed(BaseBookmarksFeed):
title = "Unread bookmarks"
description = "All unread bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
).filter(unread=True)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)
class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks"
description = "All shared bookmarks"
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False
)
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks"
description = "All public shared bookmarks"
def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", ""))
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
return FeedContext(None, query_set)
return super().get_object(request, None)
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -1,13 +1,17 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
from bookmarks.models import UserProfile
from bookmarks.models import UserProfile, GlobalSettings
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
standard_profile = UserProfile()
standard_profile.enable_favicons = True
class UserProfileMiddleware:
def __init__(self, get_response):
self.get_response = get_response
@@ -16,8 +20,16 @@ class UserProfileMiddleware:
if request.user.is_authenticated:
request.user_profile = request.user.profile
else:
request.user_profile = UserProfile()
request.user_profile.enable_favicons = True
# check if a custom profile for guests exists, otherwise use standard profile
guest_profile = None
try:
global_settings = GlobalSettings.get()
if global_settings.guest_profile_user:
guest_profile = global_settings.guest_profile_user.profile
except:
pass
request.user_profile = guest_profile or standard_profile
response = self.get_response(request)

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.0.8 on 2024-08-31 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0036_userprofile_auto_tagging_rules"),
]
operations = [
migrations.CreateModel(
name="GlobalSettings",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"landing_page",
models.CharField(
choices=[
("login", "Login"),
("shared_bookmarks", "Shared Bookmarks"),
],
default="login",
max_length=50,
),
),
],
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.0.8 on 2024-08-31 17:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0037_globalsettings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="globalsettings",
name="guest_profile_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -84,7 +84,8 @@ class Bookmark(models.Model):
@property
def tag_names(self):
return [tag.name for tag in self.tags.all()]
names = [tag.name for tag in self.tags.all()]
return sorted(names)
def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)"
@@ -169,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
@property
def has_notes(self):
return self.instance and self.instance.notes
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
class BookmarkSearch:
@@ -492,3 +495,45 @@ class FeedToken(models.Model):
def __str__(self):
return self.key
class GlobalSettings(models.Model):
LANDING_PAGE_LOGIN = "login"
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
LANDING_PAGE_CHOICES = [
(LANDING_PAGE_LOGIN, "Login"),
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
]
landing_page = models.CharField(
max_length=50,
choices=LANDING_PAGE_CHOICES,
blank=False,
default=LANDING_PAGE_LOGIN,
)
guest_profile_user = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
)
@classmethod
def get(cls):
instance = GlobalSettings.objects.first()
if not instance:
instance = GlobalSettings()
instance.save()
return instance
def save(self, *args, **kwargs):
if not self.pk and GlobalSettings.objects.exists():
raise Exception("There is already one instance of GlobalSettings")
return super(GlobalSettings, self).save(*args, **kwargs)
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user"]
def __init__(self, *args, **kwargs):
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

View File

@@ -16,27 +16,21 @@ def get_tags(script: str, url: str):
if len(parts) < 2:
continue
domain_pattern = re.sub("^https?://", "", parts[0])
path_pattern = None
qs_pattern = None
# to parse a host name from the pattern URL, ensure it has a scheme
pattern_url = "//" + re.sub("^https?://", "", parts[0])
parsed_pattern = urlparse(pattern_url)
if "/" in domain_pattern:
i = domain_pattern.index("/")
path_pattern = domain_pattern[i:]
domain_pattern = domain_pattern[:i]
if path_pattern and "?" in path_pattern:
i = path_pattern.index("?")
qs_pattern = path_pattern[i + 1 :]
path_pattern = path_pattern[:i]
if not _domains_matches(domain_pattern, parsed_url.netloc):
if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):
continue
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
if parsed_pattern.path and not _path_matches(
parsed_pattern.path, parsed_url.path
):
continue
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
if parsed_pattern.query and not _qs_matches(
parsed_pattern.query, parsed_url.query
):
continue
for tag in parts[1:]:

View File

@@ -245,12 +245,18 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string)
if user.profile.auto_tagging_rules:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
try:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
exc_info=e,
)
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)

View File

@@ -79,8 +79,6 @@ def import_netscape_html(
for batch in batches:
_import_batch(batch, user, options, tag_cache, result)
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user)
# Load previews for newly imported bookmarks

View File

@@ -12,9 +12,8 @@ from django.utils import timezone, formats
from huey import crontab
from huey.contrib.djhuey import HUEY as huey
from huey.exceptions import TaskLockedException
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
from waybackpy.exceptions import WaybackError, TooManyRequestsError
import bookmarks.services.wayback
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
@@ -66,29 +65,6 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
_create_web_archive_snapshot_task(bookmark.id, force_update)
def _load_newest_snapshot(bookmark: Bookmark):
try:
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
bookmark.url
)
existing_snapshot = cdx_api.newest()
if existing_snapshot:
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
bookmark.save(update_fields=["web_archive_snapshot_url"])
logger.info(
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
)
except NoCDXRecordFound:
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
except WaybackError as error:
logger.error(
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
)
def _create_snapshot(bookmark: Bookmark):
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
archive = waybackpy.WaybackMachineSaveAPI(
@@ -117,48 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
return
except TooManyRequestsError:
logger.error(
f"Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}"
f"Failed to create snapshot due to rate limiting. url={bookmark.url}"
)
except WaybackError as error:
logger.error(
f"Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}",
f"Failed to create snapshot. url={bookmark.url}",
exc_info=error,
)
# Load the newest snapshot as fallback
_load_newest_snapshot(bookmark)
@task()
def _load_web_archive_snapshot_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
# Skip if snapshot exists
if bookmark.web_archive_snapshot_url:
return
# Load the newest snapshot
_load_newest_snapshot(bookmark)
def schedule_bookmarks_without_snapshots(user: User):
if is_web_archive_integration_active(user):
_schedule_bookmarks_without_snapshots_task(user.id)
# Loading snapshots from CDX API has been removed, keeping the task function
# for now to prevent errors when huey tries to run the task
pass
@task()
def _schedule_bookmarks_without_snapshots_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks_without_snapshots = Bookmark.objects.filter(
web_archive_snapshot_url__exact="", owner=user
)
# TODO: Implement bulk task creation
for bookmark in bookmarks_without_snapshots:
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
# new ones when processing bookmarks in bulk
_load_web_archive_snapshot_task(bookmark.id)
# Loading snapshots from CDX API has been removed, keeping the task function
# for now to prevent errors when huey tries to run the task
pass
def is_favicon_feature_active(user: User) -> bool:

View File

@@ -1,42 +1,20 @@
import time
from typing import Dict
import datetime
import waybackpy
import waybackpy.utils
from waybackpy.exceptions import NoCDXRecordFound
from django.utils import timezone
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
def generate_fallback_webarchive_url(
url: str, timestamp: datetime.datetime
) -> str | None:
"""
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
See https://github.com/akamhy/waybackpy/issues/176
Generate a URL to the web archive for the given URL and timestamp.
A snapshot for the specific timestamp might not exist, in which case the
web archive will show the closest snapshot to the given timestamp.
If there is no snapshot at all the URL will be invalid.
"""
if not url:
return None
if not timestamp:
timestamp = timezone.now()
def newest(self):
unix_timestamp = int(time.time())
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
unix_timestamp
)
self.sort = "closest"
self.limit = -5
newest_snapshot = None
for snapshot in self.snapshots():
newest_snapshot = snapshot
break
if not newest_snapshot:
raise NoCDXRecordFound(
"Wayback Machine's CDX server did not return any records "
+ "for the query. The URL may not have any archives "
+ " on the Wayback Machine or the URL may have been recently "
+ "archived and is still not available on the CDX server."
)
return newest_snapshot
def add_payload(self, payload: Dict[str, str]) -> None:
super().add_payload(payload)
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
# makes searching for latest snapshots faster
payload["fastLatest"] = "true"
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"

View File

@@ -1,15 +1,7 @@
from django.conf import settings
from django.contrib.auth import user_logged_in
from django.db.backends.signals import connection_created
from django.dispatch import receiver
from bookmarks.services import tasks
@receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs):
tasks.schedule_bookmarks_without_snapshots(user)
@receiver(connection_created)
def extend_sqlite(connection=None, **kwargs):

View File

@@ -7,8 +7,9 @@
}
}
textarea.custom-css {
textarea.monospace {
font-family: monospace;
box-sizing: border-box;
}
.input-group > input[type=submit] {

View File

@@ -195,3 +195,10 @@ ul.menu li:first-child {
font-size: 16px;
}
}
// Hide tooltips on mobile
@media (pointer:coarse) {
.tooltip::after {
display: none;
}
}

View File

@@ -23,8 +23,8 @@
<span>Reader mode</span>
</a>
{% endif %}
{% if details.bookmark.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
{% if details.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %}
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">

View File

@@ -114,16 +114,16 @@
</div>
{% endif %}
<div class="d-flex justify-between">
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>LINKDING</h1>
</a>
{% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
{% include 'bookmarks/nav_menu.html' %}
{% elif has_public_shares %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
{% else %}
{# Otherwise show login link #}
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %}
</div>
</header>

View File

@@ -7,13 +7,14 @@
{% include 'settings/nav.html' %}
{# Profile section #}
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<section class="content-area">
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<h2>Profile</h2>
<p>
<a href="{% url 'change_password' %}">Change password</a>
@@ -123,7 +124,7 @@
<summary>Auto Tagging</summary>
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
<div class="mt-2">
{{ form.auto_tagging_rules|add_class:"form-input custom-css"|attr:"rows:6" }}
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
</div>
</details>
<div class="form-input-hint">
@@ -225,7 +226,7 @@ reddit.com/r/Music music reddit</pre>
<summary>Custom CSS</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div class="mt-2">
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
</div>
</details>
<div class="form-input-hint">
@@ -238,6 +239,37 @@ reddit.com/r/Music music reddit</pre>
</form>
</section>
{# Global settings section #}
{% if global_settings_form %}
<section class="content-area">
<h2>Global settings</h2>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The page that unauthenticated users are redirected to when accessing the root URL.
</div>
</div>
<div class="form-group">
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
profile</label>
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary mt-2">
</div>
</form>
</section>
{% endif %}
{# Import section #}
<section class="content-area">
<h2>Import</h2>

View File

@@ -1,70 +1,90 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
<div class="settings-page">
{% include 'settings/nav.html' %}
{% include 'settings/nav.html' %}
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
application first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
<section class="content-area">
<h2>REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column width-50 width-md-100">
<input class="form-input" value="{{ api_token }}" readonly>
</div>
</div>
</div>
<p>
<strong>Please treat this token as you would any other credential.</strong>
Any party with access to this token can access and manage all your bookmarks.
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page.
</p>
</section>
<section class="content-area">
<h2>REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column width-50 width-md-100">
<input class="form-input" value="{{ api_token }}" readonly>
</div>
</div>
</div>
<p>
<strong>Please treat this token as you would any other credential.</strong>
Any party with access to this token can access and manage all your bookmarks.
If you think that a token was compromised you can revoke (delete) it in the <a
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page.
</p>
</section>
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
</ul>
<p>
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
</p>
<p>
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
Any party with access to these URLs can read all your bookmarks.
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page.
</p>
</section>
</div>
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
</li>
</ul>
<p>
All URLs support the following URL parameters:
</p>
<ul style="list-style-position: outside;">
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
default, only the latest 100 matching bookmarks are included.
</li>
<li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
the bookmarks view and then copying the parameter from the URL.
</li>
<li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
bookmarks and <code>no</code> for read bookmarks.
</li>
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
shared bookmarks and <code>no</code> for unshared bookmarks.
</li>
</ul>
<p>
<strong>Please note that these URLs include an authentication token that should be treated like any other
credential.</strong>
Any party with access to these URLs can read all your bookmarks.
If you think that a URL was compromised you can delete the feed token for your user in the <a
href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page.
</p>
</section>
</div>
{% endblock %}

View File

@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
return self.user
def setup_superuser(self):
return User.objects.create_superuser(
"superuser", "superuser@example.com", "password123"
)
def setup_bookmark(
self,
is_archived: bool = False,
@@ -87,6 +92,8 @@ class BookmarkFactoryMixin:
shared: bool = False,
with_tags: bool = False,
with_web_archive_snapshot_url: bool = False,
with_favicon_file: bool = False,
with_preview_image_file: bool = False,
user: User = None,
):
user = user or self.get_or_create_test_user()
@@ -118,6 +125,12 @@ class BookmarkFactoryMixin:
web_archive_snapshot_url = ""
if with_web_archive_snapshot_url:
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
favicon_file = ""
if with_favicon_file:
favicon_file = f"favicon_{i}.png"
preview_image_file = ""
if with_preview_image_file:
preview_image_file = f"preview_image_{i}.png"
bookmark = self.setup_bookmark(
url=url,
title=title,
@@ -126,6 +139,8 @@ class BookmarkFactoryMixin:
shared=shared,
tags=tags,
web_archive_snapshot_url=web_archive_snapshot_url,
favicon_file=favicon_file,
preview_image_file=preview_image_file,
user=user,
)
bookmarks.append(bookmark)

View File

@@ -1,29 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
def assertSharedBookmarksLinkCount(self, response, count):
url = reverse("bookmarks:shared")
self.assertContains(
response,
f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
count=count,
)
def test_publicly_shared_bookmarks_link(self):
# should not render link if no public shares exist
user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True)
response = self.client.get(reverse("login"))
self.assertSharedBookmarksLinkCount(response, 0)
# should render link if public shares exist
user.profile.enable_public_sharing = True
user.profile.save()
response = self.client.get(reverse("login"))
self.assertSharedBookmarksLinkCount(response, 1)

View File

@@ -12,7 +12,18 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"]))
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_works_with_port(self):
script = """
example.com example
test.com test
"""
url = "https://example.com:8080/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_ignores_case(self):
script = """
@@ -22,7 +33,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"]))
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_should_add_all_tags(self):
script = """
@@ -32,7 +43,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one", "two", "three"]))
self.assertEqual(tags, {"one", "two", "three"})
def test_auto_tag_by_domain_work_with_idn_domains(self):
script = """
@@ -42,7 +53,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
self.assertEqual(tags, {"tag1"})
script = """
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
@@ -51,7 +62,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
self.assertEqual(tags, {"tag1"})
def test_auto_tag_by_domain_and_path(self):
script = """
@@ -63,7 +74,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_and_path_ignores_case(self):
script = """
@@ -73,7 +84,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
script = """
@@ -85,7 +96,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_ignores_domain_in_path(self):
script = """
@@ -107,7 +118,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example", "test"]))
self.assertEqual(tags, {"example", "test"})
def test_auto_tag_by_domain_matches_domain_rtl(self):
script = """
@@ -128,7 +139,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["https", "http"]))
self.assertEqual(tags, {"https", "http"})
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
script = """
@@ -154,7 +165,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"]))
self.assertEqual(tags, {"tag1", "tag2", "tag5", "tag6", "tag7"})
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
script = """
@@ -165,7 +176,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
self.assertEqual(tags, {"tag1"})
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
script = """
@@ -176,4 +187,4 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2"]))
self.assertEqual(tags, {"tag1", "tag2"})

View File

@@ -1,10 +1,11 @@
import datetime
import re
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import formats
from django.utils import formats, timezone
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import bookmarks, tasks
@@ -180,7 +181,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no latest snapshot
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
self.assertEqual(self.count_weblinks(soup), 2)
# snapshot is not complete
self.setup_asset(
@@ -194,7 +195,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
status=BookmarkAsset.STATUS_FAILURE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
self.assertEqual(self.count_weblinks(soup), 2)
# not a snapshot
self.setup_asset(
@@ -203,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 1)
self.assertEqual(self.count_weblinks(soup), 2)
# snapshot is complete
asset = self.setup_asset(
@@ -212,20 +213,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
status=BookmarkAsset.STATUS_COMPLETE,
)
soup = self.get_details(bookmark)
self.assertEqual(self.count_weblinks(soup), 2)
self.assertEqual(self.count_weblinks(soup), 3)
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
link = self.find_weblink(soup, reader_mode_url)
self.assertIsNotNone(link)
def test_internet_archive_link(self):
# without snapshot url
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNone(link)
# with snapshot url
def test_internet_archive_link_with_snapshot_url(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
soup = self.get_details(bookmark)
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
@@ -264,6 +258,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
image = link.select_one("svg")
self.assertIsNotNone(image)
def test_internet_archive_link_with_fallback_url(self):
date_added = timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(url="https://example.com/", added=date_added)
fallback_web_archive_url = (
"https://web.archive.org/web/20230811214511/https://example.com/"
)
soup = self.get_details(bookmark)
link = self.find_weblink(soup, fallback_web_archive_url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], fallback_web_archive_url)
self.assertEqual(link.text.strip(), "Internet Archive")
def test_weblinks_respect_target_setting(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")

View File

@@ -6,11 +6,7 @@ from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
HtmlTestMixin,
collapse_whitespace,
)
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):

View File

@@ -100,6 +100,29 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html,
)
def test_should_prefill_notes_from_url_parameter(self):
response = self.client.get(
reverse("bookmarks:new")
+ "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29"
)
html = response.content.decode()
self.assertInHTML(
"""
<details class="notes" open="">
<summary>
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="id_notes" class="text-assistive">Notes</label>
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
</details>
""",
html,
)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(reverse("bookmarks:new") + "?auto_close")
html = response.content.decode()

View File

@@ -36,6 +36,16 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["website_title"] = bookmark.website_title
expectation["website_description"] = bookmark.website_description
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["favicon_url"] = (
f"http://testserver/static/{bookmark.favicon_file}"
if bookmark.favicon_file
else None
)
expectation["preview_image_url"] = (
f"http://testserver/static/{bookmark.preview_image_file}"
if bookmark.preview_image_file
else None
)
expectation["is_archived"] = bookmark.is_archived
expectation["unread"] = bookmark.unread
expectation["shared"] = bookmark.shared
@@ -65,7 +75,11 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_list_bookmarks_with_more_details(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(
5, with_tags=True, with_web_archive_snapshot_url=True
5,
with_tags=True,
with_web_archive_snapshot_url=True,
with_favicon_file=True,
with_preview_image_file=True,
)
response = self.get(
@@ -171,6 +185,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
def test_list_archived_bookmarks_with_more_details(self):
self.authenticate()
archived_bookmarks = self.setup_numbered_bookmarks(
5,
archived=True,
with_tags=True,
with_web_archive_snapshot_url=True,
with_favicon_file=True,
with_preview_image_file=True,
)
response = self.get(
reverse("bookmarks:bookmark-archived"),
expected_status_code=status.HTTP_200_OK,
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
def test_list_archived_bookmarks_should_filter_by_query(self):
self.authenticate()
search_value = self.get_random_string()
@@ -220,6 +251,26 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
def test_list_shared_bookmarks_with_more_details(self):
self.authenticate()
other_user = self.setup_user(enable_sharing=True)
shared_bookmarks = self.setup_numbered_bookmarks(
5,
shared=True,
user=other_user,
with_tags=True,
with_web_archive_snapshot_url=True,
with_favicon_file=True,
with_preview_image_file=True,
)
response = self.get(
reverse("bookmarks:bookmark-shared"),
expected_status_code=status.HTTP_200_OK,
)
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -701,6 +752,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url="https://example.com",
title="Example title",
description="Example description",
favicon_file="favicon.png",
preview_image_file="preview.png",
)
url = reverse("bookmarks:bookmark-check")
@@ -715,6 +768,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, bookmark_data["url"])
self.assertEqual(bookmark.title, bookmark_data["title"])
self.assertEqual(bookmark.description, bookmark_data["description"])
self.assertEqual(
"http://testserver/static/favicon.png", bookmark_data["favicon_url"]
)
self.assertEqual(
"http://testserver/static/preview.png", bookmark_data["preview_image_url"]
)
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
self.authenticate()

View File

@@ -1,3 +1,4 @@
import datetime
from typing import Type
from dateutil.relativedelta import relativedelta
@@ -36,15 +37,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
html,
)
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(
f"""
<span>{label_content}</span>
<span>|</span>
""",
html,
)
def assertWebArchiveLink(
self, html: str, label_content: str, url: str, link_target: str = "_blank"
):
@@ -465,14 +457,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
)
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
def test_bookmark_tag_ordering(self):
bookmark = self.setup_bookmark()
tag3 = self.setup_tag(name="tag3")
tag1 = self.setup_tag(name="tag1")
tag2 = self.setup_tag(name="tag2")
bookmark.tags.add(tag3, tag1, tag2)
self.assertDateLabel(html, formatted_date)
html = self.render_template()
soup = self.make_soup(html)
tags = soup.select_one(".tags")
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), 3)
self.assertEqual(tag_links[0].text, "#tag1")
self.assertEqual(tag_links[1].text, "#tag2")
self.assertEqual(tag_links[2].text, "#tag3")
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(
@@ -486,12 +485,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
html, formatted_date, bookmark.web_archive_snapshot_url
)
def test_should_respect_relative_date_setting(self):
self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_template()
self.assertDateLabel(html, "1 week ago")
def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
@@ -501,6 +494,27 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
def test_should_render_generated_web_archive_link_without_saved_snapshot_url(self):
user = self.get_or_create_test_user()
user.profile.bookmark_date_display = UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
user.profile.save()
date_added = timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(
url="https://example.com/article", added=date_added
)
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
self.assertWebArchiveLink(
html,
formatted_date,
"https://web.archive.org/web/20230811214511/https://example.com/article",
)
def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
html = self.render_template()

View File

@@ -1,6 +1,4 @@
import datetime
import os.path
from dataclasses import dataclass
from unittest import mock
import waybackpy
@@ -10,8 +8,6 @@ from django.test import TestCase, override_settings
from huey.contrib.djhuey import HUEY as huey
from waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks, singlefile
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -29,12 +25,6 @@ def create_wayback_machine_save_api_mock(
return mock_api
@dataclass
class MockCdxSnapshot:
archive_url: str
datetime_timestamp: datetime.datetime
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
@@ -50,17 +40,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
)
self.mock_save_api_patcher.start()
self.mock_cdx_api = mock.Mock()
self.mock_cdx_api.newest.return_value = MockCdxSnapshot(
"https://example.com/newest_snapshot", datetime.datetime.now()
)
self.mock_cdx_api_patcher = mock.patch.object(
bookmarks.services.wayback,
"CustomWaybackMachineCDXServerAPI",
return_value=self.mock_cdx_api,
)
self.mock_cdx_api_patcher.start()
self.mock_load_favicon_patcher = mock.patch(
"bookmarks.services.favicon_loader.load_favicon"
)
@@ -90,7 +69,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
self.mock_save_api_patcher.stop()
self.mock_cdx_api_patcher.stop()
self.mock_load_favicon_patcher.stop()
self.mock_singlefile_create_snapshot_patcher.stop()
self.mock_load_preview_image_patcher.stop()
@@ -142,45 +120,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.web_archive_snapshot_url, "https://other.com")
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
bookmark = self.setup_bookmark()
self.mock_save_api.save.side_effect = WaybackError
tasks.create_web_archive_snapshot(
self.get_or_create_test_user(), bookmark, False
)
bookmark.refresh_from_db()
self.mock_cdx_api.newest.assert_called_once()
self.assertEqual(
"https://example.com/newest_snapshot",
bookmark.web_archive_snapshot_url,
)
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
bookmark = self.setup_bookmark()
self.mock_save_api.save.side_effect = WaybackError
self.mock_cdx_api.newest.return_value = None
tasks.create_web_archive_snapshot(
self.get_or_create_test_user(), bookmark, False
)
bookmark.refresh_from_db()
self.assertEqual("", bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
bookmark = self.setup_bookmark()
self.mock_save_api.save.side_effect = WaybackError
self.mock_cdx_api.newest.side_effect = WaybackError
tasks.create_web_archive_snapshot(
self.get_or_create_test_user(), bookmark, False
)
bookmark.refresh_from_db()
self.assertEqual("", bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
@@ -203,69 +142,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
bookmark.web_archive_snapshot_url,
)
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark()
tasks._load_web_archive_snapshot_task(bookmark.id)
bookmark.refresh_from_db()
self.assertEqual(self.executed_count(), 1)
self.mock_cdx_api.newest.assert_called_once()
self.assertEqual(
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
)
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
tasks._load_web_archive_snapshot_task(123)
self.assertEqual(self.executed_count(), 1)
self.mock_cdx_api.newest.assert_not_called()
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com")
tasks._load_web_archive_snapshot_task(bookmark.id)
self.assertEqual(self.executed_count(), 1)
self.mock_cdx_api.newest.assert_not_called()
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
bookmark = self.setup_bookmark()
self.mock_cdx_api.newest.return_value = None
tasks._load_web_archive_snapshot_task(bookmark.id)
self.assertEqual("", bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
bookmark = self.setup_bookmark()
self.mock_cdx_api.newest.side_effect = WaybackError
tasks._load_web_archive_snapshot_task(bookmark.id)
self.assertEqual("", bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
# update bookmark during API call to check that saving
# the snapshot does not overwrite updated bookmark data
def mock_newest_impl():
bookmark.title = "Updated title"
bookmark.save()
return mock.DEFAULT
self.mock_cdx_api.newest.side_effect = mock_newest_impl
tasks._load_web_archive_snapshot_task(bookmark.id)
bookmark.refresh_from_db()
self.assertEqual("Updated title", bookmark.title)
self.assertEqual(
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(
self,
@@ -292,59 +168,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.executed_count(), 0)
def test_schedule_bookmarks_without_snapshots_should_load_snapshot_for_all_bookmarks_without_snapshot(
self,
):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
tasks.schedule_bookmarks_without_snapshots(user)
self.assertEqual(self.executed_count(), 4)
self.assertEqual(self.mock_cdx_api.newest.call_count, 3)
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(
self,
):
user = self.get_or_create_test_user()
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
tasks.schedule_bookmarks_without_snapshots(user)
self.assertEqual(self.mock_cdx_api.newest.call_count, 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(
self,
):
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(self.executed_count(), 0)
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(
self,
):
self.user.profile.web_archive_integration = (
UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
)
self.user.profile.save()
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(self.executed_count(), 0)
def test_load_favicon_should_create_favicon_file(self):
bookmark = self.setup_bookmark()

View File

@@ -23,6 +23,26 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0]
def assertFeedItems(self, response, bookmarks):
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
categories = []
for tag in bookmark.tag_names:
categories.append(f"<category>{tag}</category>")
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
f"{''.join(categories)}"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"]))
@@ -54,51 +74,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertFeedItems(response, bookmarks)
def test_all_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user(
@@ -115,23 +91,6 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertContains(response, "<item>", count=0)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))
def test_unread_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"]))
@@ -169,51 +128,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.unread", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(unread_bookmarks))
for bookmark in unread_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_unread_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True, tags=[tag1])
bookmark3 = self.setup_bookmark(unread=True, tags=[tag1])
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertFeedItems(response, unread_bookmarks)
def test_unread_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user(
@@ -265,53 +180,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.shared", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(shared_bookmarks))
for bookmark in shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_shared_with_query(self):
user = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertFeedItems(response, shared_bookmarks)
def test_public_shared_does_not_require_auth(self):
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
@@ -351,34 +220,19 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, public_shared_bookmarks)
self.assertContains(response, "<item>", count=len(public_shared_bookmarks))
def test_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
for bookmark in public_shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
def test_public_shared_with_query(self):
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
@@ -398,3 +252,117 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_unread_parameter(self):
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
# without unread parameter
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=6)
# with unread=yes
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=yes"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
# with unread=no
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=no"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=4)
def test_shared_parameter(self):
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
# without shared parameter
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=6)
# with shared=yes
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=yes"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
# with shared=no
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=no"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=4)
def test_with_tags(self):
bookmarks = [
self.setup_bookmark(description="test description"),
self.setup_bookmark(
description="test description",
tags=[self.setup_tag(), self.setup_tag()],
),
]
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, bookmarks)
def test_with_limit(self):
self.setup_numbered_bookmarks(200)
# without limit - defaults to 100
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=100)
# with increased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=200"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=200)
# with decreased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=5"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=5)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))

View File

@@ -444,17 +444,6 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(Bookmark.objects.all()[0].description, "Example description")
self.assertEqual(Bookmark.objects.all()[0].notes, "Updated notes")
def test_schedule_snapshot_creation(self):
user = self.get_or_create_test_user()
test_html = self.render_html(tags_html="")
with patch.object(
tasks, "schedule_bookmarks_without_snapshots"
) as mock_schedule_bookmarks_without_snapshots:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
def test_schedule_favicon_loading(self):
user = self.get_or_create_test_user()
test_html = self.render_html(tags_html="")

View File

@@ -0,0 +1,40 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
class RootViewTestCase(TestCase, BookmarkFactoryMixin):
def test_unauthenticated_user_redirect_to_login_by_default(self):
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("login"))
def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings(
self,
):
settings = GlobalSettings.get()
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:shared"))
def test_authenticated_user_always_redirected_to_bookmarks(self):
self.client.force_login(self.get_or_create_test_user())
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))
settings = GlobalSettings.get()
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))
settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))

View File

@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException
from bookmarks.models import UserProfile
from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
@@ -465,3 +465,82 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0
)
def test_update_global_settings(self):
superuser = self.setup_superuser()
self.client.force_login(superuser)
selectable_user = self.setup_user()
# Update global settings
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
"guest_profile_user": selectable_user.id,
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
global_settings = GlobalSettings.get()
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
self.assertEqual(global_settings.guest_profile_user, selectable_user)
# Revert settings
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_LOGIN,
"guest_profile_user": "",
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
global_settings = GlobalSettings.get()
global_settings.refresh_from_db()
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
self.assertIsNone(global_settings.guest_profile_user)
def test_update_global_settings_should_not_be_called_without_respective_form_action(
self,
):
superuser = self.setup_superuser()
self.client.force_login(superuser)
form_data = {
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(
response.content.decode(), "Global settings updated", count=0
)
def test_update_global_settings_checks_for_superuser(self):
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 403)
def test_global_settings_only_visible_for_superuser(self):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"<h2>Global settings</h2>",
html,
count=0,
)
superuser = self.setup_superuser()
self.client.force_login(superuser)
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"<h2>Global settings</h2>",
html,
count=1,
)

View File

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

View File

@@ -0,0 +1,47 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.middlewares import standard_profile
class UserProfileMiddlewareTestCase(TestCase, BookmarkFactoryMixin):
def test_unauthenticated_user_should_use_standard_profile_by_default(self):
response = self.client.get(reverse("login"))
self.assertEqual(standard_profile, response.wsgi_request.user_profile)
def test_unauthenticated_user_should_use_custom_configured_profile(self):
guest_user = self.setup_user()
guest_user_profile = guest_user.profile
guest_user_profile.theme = UserProfile.THEME_DARK
guest_user_profile.save()
global_settings = GlobalSettings.get()
global_settings.guest_profile_user = guest_user
global_settings.save()
response = self.client.get(reverse("login"))
self.assertEqual(guest_user_profile, response.wsgi_request.user_profile)
def test_authenticated_user_should_use_own_profile(self):
guest_user = self.setup_user()
guest_user_profile = guest_user.profile
guest_user_profile.theme = UserProfile.THEME_DARK
guest_user_profile.save()
global_settings = GlobalSettings.get()
global_settings.guest_profile_user = guest_user
global_settings.save()
user = self.get_or_create_test_user()
user_profile = user.profile
user_profile.theme = UserProfile.THEME_LIGHT
user_profile.save()
self.client.force_login(user)
response = self.client.get(reverse("login"), follow=True)
self.assertEqual(user_profile, response.wsgi_request.user_profile)

View File

@@ -1,6 +1,5 @@
from django.urls import path, include
from django.urls import re_path
from django.views.generic import RedirectView
from bookmarks import views
from bookmarks.api.routes import router
@@ -14,10 +13,8 @@ from bookmarks.views import partials
app_name = "bookmarks"
urlpatterns = [
# Redirect root to bookmarks index
re_path(
r"^$", RedirectView.as_view(pattern_name="bookmarks:index", permanent=False)
),
# Root view handling redirection based on user authentication
re_path(r"^$", views.root, name="root"),
# Bookmarks
path("bookmarks", views.bookmarks.index, name="index"),
path("bookmarks/action", views.bookmarks.index_action, name="index.action"),

View File

@@ -4,3 +4,4 @@ from .settings import *
from .toasts import *
from .health import health
from .manifest import manifest
from .root import root

View File

@@ -192,6 +192,7 @@ def new(request):
initial_url = request.GET.get("url")
initial_title = request.GET.get("title")
initial_description = request.GET.get("description")
initial_notes = request.GET.get("notes")
initial_auto_close = "auto_close" in request.GET
initial_mark_unread = request.user.profile.default_mark_unread
@@ -214,6 +215,8 @@ def new(request):
form.initial["title"] = initial_title
if initial_description:
form.initial["description"] = initial_description
if initial_notes:
form.initial["notes"] = initial_notes
if initial_auto_close:
form.initial["auto_close"] = "true"
if initial_mark_unread:

View File

@@ -1,12 +1,12 @@
import re
import urllib.parse
from typing import Set, List
import re
from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import models
from django.urls import reverse
from django.conf import settings
from bookmarks import queries
from bookmarks import utils
@@ -18,6 +18,7 @@ from bookmarks.models import (
UserProfile,
Tag,
)
from bookmarks.services.wayback import generate_fallback_webarchive_url
DEFAULT_PAGE_SIZE = 30
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
@@ -144,6 +145,10 @@ class BookmarkItem:
self.notes = bookmark.notes
self.tag_names = bookmark.tag_names
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
if not self.web_archive_snapshot_url:
self.web_archive_snapshot_url = generate_fallback_webarchive_url(
bookmark.url, bookmark.date_added
)
self.favicon_file = bookmark.favicon_file
self.preview_image_file = bookmark.preview_image_file
self.is_archived = bookmark.is_archived
@@ -412,6 +417,12 @@ class BookmarkDetailsContext:
# For now hide files section if snapshots are not supported
self.show_files = settings.LD_ENABLE_SNAPSHOTS
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
if not self.web_archive_snapshot_url:
self.web_archive_snapshot_url = generate_fallback_webarchive_url(
bookmark.url, bookmark.date_added
)
self.assets = [
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
]

18
bookmarks/views/root.py Normal file
View File

@@ -0,0 +1,18 @@
from django.http import HttpResponseRedirect
from django.urls import reverse
from bookmarks.models import GlobalSettings
def root(request):
# Redirect unauthenticated users to the configured landing page
if not request.user.is_authenticated:
settings = GlobalSettings.get()
if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS:
return HttpResponseRedirect(reverse("bookmarks:shared"))
else:
return HttpResponseRedirect(reverse("login"))
# Redirect authenticated users to the bookmarks page
return HttpResponseRedirect(reverse("bookmarks:index"))

View File

@@ -6,13 +6,20 @@ import requests
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark, UserProfileForm, FeedToken
from bookmarks.models import (
Bookmark,
UserProfileForm,
FeedToken,
GlobalSettings,
GlobalSettingsForm,
)
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
from bookmarks.utils import app_version
@@ -23,6 +30,7 @@ logger = logging.getLogger(__name__)
@login_required
def general(request):
profile_form = None
global_settings_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
success_message = _find_message_with_tag(
@@ -37,6 +45,9 @@ def general(request):
if "update_profile" in request.POST:
profile_form = update_profile(request)
success_message = "Profile updated"
if "update_global_settings" in request.POST:
global_settings_form = update_global_settings(request)
success_message = "Global settings updated"
if "refresh_favicons" in request.POST:
tasks.schedule_refresh_favicons(request.user)
success_message = "Scheduled favicon update. This may take a while..."
@@ -52,11 +63,15 @@ def general(request):
if not profile_form:
profile_form = UserProfileForm(instance=request.user_profile)
if request.user.is_superuser and not global_settings_form:
global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get())
return render(
request,
"settings/general.html",
{
"form": profile_form,
"global_settings_form": global_settings_form,
"enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support,
"success_message": success_message,
@@ -83,6 +98,17 @@ def update_profile(request):
return form
def update_global_settings(request):
user = request.user
if not user.is_superuser:
raise PermissionDenied()
form = GlobalSettingsForm(request.POST, instance=GlobalSettings.get())
if form.is_valid():
form.save()
return form
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):

View File

@@ -1,5 +1,3 @@
version: '3'
services:
linkding:
container_name: "${LD_CONTAINER_NAME:-linkding}"
@@ -10,4 +8,4 @@ services:
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
env_file:
- .env
restart: unless-stopped
restart: unless-stopped

View File

@@ -69,6 +69,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
FROM python:3.11.8-alpine3.19 AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap libssl3
# create www-data user and group

View File

@@ -71,6 +71,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
FROM python:3.11.8-slim-bookworm as linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
WORKDIR /etc/linkding
# copy prod dependencies

View File

@@ -49,6 +49,8 @@ Example response:
"website_title": "Website title",
"website_description": "Website description",
"web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
"favicon_url": "http://127.0.0.1:8000/static/https_example_com.png",
"preview_image_url": "http://127.0.0.1:8000/static/0ac5c53db923727765216a3a58e70522.jpg",
"is_archived": false,
"unread": false,
"shared": false,

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.31.0",
"version": "1.32.0",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -6,3 +6,4 @@ libsass
playwright
pytest
pytest-django
pytest-xdist

View File

@@ -12,7 +12,7 @@ click==8.1.7
# via black
coverage==7.4.1
# via -r requirements.dev.in
django==5.0.3
django==5.0.8
# via
# django-appconf
# django-debug-toolbar
@@ -22,6 +22,8 @@ django-compressor==4.4
# via -r requirements.dev.in
django-debug-toolbar==4.2.0
# via -r requirements.dev.in
execnet==2.1.1
# via pytest-xdist
greenlet==3.0.3
# via playwright
iniconfig==2.0.0
@@ -48,8 +50,11 @@ pytest==8.0.0
# via
# -r requirements.dev.in
# pytest-django
# pytest-xdist
pytest-django==4.7.0
# via -r requirements.dev.in
pytest-xdist==3.6.1
# via -r requirements.dev.in
rcssmin==1.1.1
# via django-compressor
rjsmin==1.2.1

View File

@@ -12,7 +12,7 @@ bleach==6.1.0
# via -r requirements.in
bleach-allowlist==1.0.3
# via -r requirements.in
certifi==2023.11.17
certifi==2024.7.4
# via requests
cffi==1.16.0
# via cryptography
@@ -27,7 +27,7 @@ cryptography==42.0.5
# josepy
# mozilla-django-oidc
# pyopenssl
django==5.0.3
django==5.0.8
# via
# -r requirements.in
# django-registration
@@ -39,7 +39,7 @@ django-sass-processor==1.4
# via -r requirements.in
django-widget-tweaks==1.5.0
# via -r requirements.in
djangorestframework==3.14.0
djangorestframework==3.15.2
# via -r requirements.in
huey==2.5.0
# via -r requirements.in
@@ -59,8 +59,6 @@ pyopenssl==24.1.0
# via josepy
python-dateutil==2.8.2
# via -r requirements.in
pytz==2023.3.post1
# via djangorestframework
requests==2.32.0
# via
# -r requirements.in
@@ -76,7 +74,7 @@ sqlparse==0.5.0
# via django
supervisor==4.2.5
# via -r requirements.in
urllib3==2.1.0
urllib3==2.2.2
# via
# requests
# waybackpy

View File

@@ -73,7 +73,6 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"bookmarks.context_processors.toasts",
"bookmarks.context_processors.public_shares",
"bookmarks.context_processors.app_version",
],
},
@@ -114,7 +113,7 @@ LOGOUT_REDIRECT_URL = "/" + LD_CONTEXT_PATH + "login"
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True

View File

@@ -1 +1 @@
1.31.0
1.32.0