Compare commits

...

46 Commits

Author SHA1 Message Date
Alexander Lehmann
dfbba20275 Make init command initialize data dir (#1292) 2026-01-25 18:27:11 +01:00
Sascha Ißbrücker
f67c4605fd Fix URL not updating on tag search 2026-01-25 11:44:48 +01:00
Sascha Ißbrücker
1f0a2201ba Preserve page and scroll position when editing tags (#1291) 2026-01-25 11:07:30 +01:00
Sascha Ißbrücker
d52caefe2c Add dev tool for quickly switching profile settings 2026-01-11 22:40:06 +01:00
Sascha Ißbrücker
c998dd35b7 Update docs 2026-01-07 21:04:04 +01:00
Sascha Ißbrücker
397eb6d316 Update CHANGELOG.md 2026-01-06 21:35:04 +01:00
Sascha Ißbrücker
fbb9e10421 Bump version 2026-01-06 20:22:28 +01:00
Sascha Ißbrücker
b937f26b44 Docker build improvements 2026-01-06 20:21:34 +01:00
Sascha Ißbrücker
414c7abbe5 Fix empty container spacing 2026-01-06 20:20:22 +01:00
Sascha Ißbrücker
7333b283cf Download PDF instead of creating HTML snapshot if URL points at PDF (#1271)
* basic pdf snapshots

* cleanup website_loader tests

* cleanup asset tests

* cleanup asset service tests

* use PDF download as display name

* update new snapshot name

* update docs

* update e2e test

* update test
2026-01-06 10:29:31 +01:00
Justin Mason
4f26c3483b Allow setting date_added and date_modified for new bookmarks through REST API (#1063)
* Make date_added and date_modified optionally writable fields for the POST /api/bookmarks/ API

* Update as per PR feedback to avoid double-save; add test coverage

* Remove blank line

* improve tests

---------

Co-authored-by: Justin.Mason <Justin.Mason@messagegears.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2026-01-05 19:12:14 +01:00
Sascha Ißbrücker
184e4baa84 Add option to run supervisor as main process (#1270)
* Add option to run supervisor as main process

* use new option in test script
2026-01-05 18:41:50 +01:00
Emanuele Beffa
1b90db70c0 Disable bulk execute button when no bookmarks selected (#1241)
* feat: disable execute button when no bookmarks selected in bulk edit

* format

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2026-01-05 17:08:22 +01:00
Max
cbc8618805 Turn scheme-less URLs into HTTPS instead of HTTP links (#1225)
* Turn scheme-less URLs into HTTPS instead of HTTP links

Signed-off-by: Max Kunzelmann <maxdev@posteo.de>

* fix bug, add tests

* use single linker instance

* simplify logic

* lint

---------

Signed-off-by: Max Kunzelmann <maxdev@posteo.de>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2026-01-05 16:45:33 +01:00
Sascha Ißbrücker
afbf85b249 Add option to disable login form (#1269) 2026-01-05 12:37:49 +01:00
Sascha Ißbrücker
9ab91e018b Organize community projects 2026-01-05 10:47:04 +01:00
Luís Mendes
f7229a06fc Add linkdinger to community projects (#1266) 2026-01-05 09:35:42 +01:00
Sascha Ißbrücker
5f5ea73aec Cleanup 2026-01-05 09:33:27 +01:00
Aidan Coyle
fdb5b4e82d Remove absolute URIs from settings page (#1261)
* Remove absolute URIs from admin page

The rest of the links on this page are absolute paths without a
specified hostname, but these in particlar use build_absolute_uri. I
am running linkding behind two different load balancers which makes
these links bubble up the "internal" hostname instead of the hostname
I actually got to the page from.

* Add LD_USE_X_FORWARDED_HOST

See: https://docs.djangoproject.com/en/6.0/ref/settings/#std-setting-USE_X_FORWARDED_HOST
2026-01-05 09:25:54 +01:00
Sascha Ißbrücker
50180c9684 Remove registration switch (#1268) 2026-01-05 05:52:43 +01:00
Sascha Ißbrücker
65f3759444 Align form usages in templates (#1267) 2026-01-05 05:33:59 +01:00
Sascha Ißbrücker
7dfb8126c4 Remove python-dateutil dependency (#1265) 2026-01-04 13:16:33 +01:00
Sascha Ißbrücker
5da450ce96 Add note about Singlefile extension compatibility (#1264) 2026-01-04 12:19:33 +01:00
Sascha Ißbrücker
3b26190df5 Format and lint with ruff (#1263) 2026-01-04 12:13:48 +01:00
Sascha Ißbrücker
4d82fefa4e Extract inline icons to SVG iconset (#1262) 2026-01-04 09:39:42 +01:00
Sascha Ißbrücker
06048ee26f Allow viewing video assets (#1259) 2026-01-03 16:33:49 +01:00
Sascha Ißbrücker
4f5009b30f Move bulk edit checkboxes into bookmark list container (#1257) 2026-01-03 09:27:30 +01:00
Sascha Ißbrücker
ee169e82cd Include templates in live reload 2026-01-03 08:27:23 +01:00
Sascha Ißbrücker
cce191440d Small UI tweaks 2026-01-02 18:36:57 +01:00
Sascha Ißbrücker
ec0c7ee253 Live reload for dev mode 2026-01-02 18:26:14 +01:00
Sascha Ißbrücker
4291bda9d4 Fix JS errors 2026-01-02 08:09:20 +01:00
Sascha Ißbrücker
f7c371bce1 Bump dependencies (#1255)
* Bump dependencies

* fix test
2026-01-01 21:30:51 +01:00
Sascha Ißbrücker
b6c4634403 Add make command 2026-01-01 21:30:02 +01:00
Sascha Ißbrücker
b4a5b34815 Use single bookmark page template 2026-01-01 19:18:23 +01:00
Sascha Ißbrücker
ffc1a69085 Template improvements 2026-01-01 13:40:57 +01:00
Sascha Ißbrücker
38d450a916 Run tests in CI in parallel (#1254)
* Run tests in CI in parallel

* make tests automatically open/close playwright

* fix parallel tests and screenshots

* fix capturing screenshots for non-failing tests

* cleanup

* cleanup

* format

* log js errors

* provide screenshots as artifacts

* remove old scripts
2026-01-01 01:46:31 +01:00
Sascha Ißbrücker
df595f2219 Fix web component initialization timing 2026-01-01 01:08:54 +01:00
Sascha Ißbrücker
b82d07c588 Move tag management forms into dialogs (#1253)
* Move tag management forms into dialogs

* add e2e tests
2025-12-31 21:38:46 +01:00
Sascha Ißbrücker
fc15363349 Fix missing file 2025-12-31 18:22:29 +01:00
Sascha Ißbrücker
b97b0493e0 Cleanup modals 2025-12-31 18:03:37 +01:00
Sascha Ißbrücker
9013a8dfc2 Add e2e make command 2025-12-31 15:45:47 +01:00
Sascha Ißbrücker
4fed5de7b3 Convert behaviors to web components 2025-12-31 15:31:51 +01:00
Sascha Ißbrücker
ee1cf6596b Allow sandboxes scripts when viewing assets (#1252) 2025-12-30 11:34:04 +01:00
Sascha Ißbrücker
12dd1d8bc6 Refactor dropdowns to use fixed positioning 2025-12-21 10:22:39 +01:00
Sascha Ißbrücker
74ddf45632 Fix bookmark details focus restoration 2025-12-21 10:00:55 +01:00
Sascha Ißbrücker
83092ccb48 API token management (#1248) 2025-12-14 17:51:53 +01:00
330 changed files with 7781 additions and 5805 deletions

View File

@@ -12,7 +12,8 @@
!/postcss.config.js
!/pyproject.toml
!/rollup.config.mjs
!/supervisord.conf
!/supervisord-tasks.conf
!/supervisord-all.conf
!/uv.lock
!/uwsgi.ini
!/version.txt

View File

@@ -24,6 +24,8 @@ LD_AUTH_PROXY_USERNAME_HEADER=
# The URL that linkding should redirect to after a logout, when using an auth proxy
# See docs/Options.md for more details
LD_AUTH_PROXY_LOGOUT_URL=
# Disables the login form, useful to enforce OIDC authentication
LD_DISABLE_LOGIN_FORM=False
# List of trusted origins from which to accept POST requests
# See docs/Options.md for more details
LD_CSRF_TRUSTED_ORIGINS=

View File

@@ -38,6 +38,8 @@ jobs:
ghcr.io/sissbruecker/linkding:test
target: linkding
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
- name: Build latest-alpine
uses: docker/build-push-action@v6
@@ -49,6 +51,8 @@ jobs:
ghcr.io/sissbruecker/linkding:test-alpine
target: linkding
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
- name: Build latest-plus
uses: docker/build-push-action@v6
@@ -60,6 +64,8 @@ jobs:
ghcr.io/sissbruecker/linkding:test-plus
target: linkding-plus
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
@@ -71,3 +77,5 @@ jobs:
ghcr.io/sissbruecker/linkding:test-plus-alpine
target: linkding-plus
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max

View File

@@ -45,6 +45,8 @@ jobs:
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
target: linkding
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
- name: Build latest-alpine
uses: docker/build-push-action@v6
@@ -59,6 +61,8 @@ jobs:
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
target: linkding
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
- name: Build latest-plus
uses: docker/build-push-action@v6
@@ -73,6 +77,8 @@ jobs:
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
target: linkding-plus
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
@@ -86,4 +92,6 @@ jobs:
ghcr.io/sissbruecker/linkding:latest-plus-alpine
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
target: linkding-plus
push: true
push: true
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max

View File

@@ -30,7 +30,7 @@ jobs:
uv sync
mkdir data
- name: Run tests
run: uv run manage.py test bookmarks.tests
run: uv run pytest -n auto
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
@@ -59,4 +59,10 @@ jobs:
npm run build
uv run manage.py collectstatic
- name: Run tests
run: uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
run: uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-screenshots
path: test-results/screenshots

1
.gitignore vendored
View File

@@ -60,6 +60,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
test-results/
# Translations
*.mo

View File

@@ -1,5 +1,58 @@
# Changelog
## v1.45.0 (06/01/2026)
### What's Changed
* API token management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1248
* Add option to disable login form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1269
* Turn scheme-less URLs into HTTPS instead of HTTP links by @Maaxxs in https://github.com/sissbruecker/linkding/pull/1225
* Disable bulk execute button when no bookmarks selected by @emanuelebeffa in https://github.com/sissbruecker/linkding/pull/1241
* Add option to run supervisor as main process by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1270
* Allow setting date_added and date_modified for new bookmarks through REST API by @jmason in https://github.com/sissbruecker/linkding/pull/1063
* Download PDF instead of creating HTML snapshot if URL points at PDF by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1271
* Allow sandboxed scripts when viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1252
* Allow viewing video assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1259
* Remove absolute URIs from settings page by @packrat386 in https://github.com/sissbruecker/linkding/pull/1261
* Move tag management forms into dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1253
* Move bulk edit checkboxes into bookmark list container by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1257
* Remove registration switch by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1268
* Add linkdinger to community projects by @lmmendes in https://github.com/sissbruecker/linkding/pull/1266
### New Contributors
* @packrat386 made their first contribution in https://github.com/sissbruecker/linkding/pull/1261
* @lmmendes made their first contribution in https://github.com/sissbruecker/linkding/pull/1266
* @Maaxxs made their first contribution in https://github.com/sissbruecker/linkding/pull/1225
* @emanuelebeffa made their first contribution in https://github.com/sissbruecker/linkding/pull/1241
* @jmason made their first contribution in https://github.com/sissbruecker/linkding/pull/1063
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.2...v1.45.0
---
## v1.44.2 (13/12/2025)
### What's Changed
> [!WARNING]
> *This resolves a [security vulnerability](https://github.com/sissbruecker/linkding/security/advisories/GHSA-3pf9-5cjv-2w7q) in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
* Use sandbox CSP for viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1245
* Fix devcontainer by @m3eno in https://github.com/sissbruecker/linkding/pull/1208
* Fix tag cloud highlighting first char when tags are not grouped by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1209
* Bump supervisor to 4.3.0 to fix warning by @simonhammes in https://github.com/sissbruecker/linkding/pull/1216
* Added Javascript client and library for Linkding REST API by @vbsampath in https://github.com/sissbruecker/linkding/pull/1195
* Add Komrade project to community resources by @dev-inside in https://github.com/sissbruecker/linkding/pull/1236
### New Contributors
* @m3eno made their first contribution in https://github.com/sissbruecker/linkding/pull/1208
* @vbsampath made their first contribution in https://github.com/sissbruecker/linkding/pull/1195
* @dev-inside made their first contribution in https://github.com/sissbruecker/linkding/pull/1236
* @simonhammes made their first contribution in https://github.com/sissbruecker/linkding/pull/1216
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.1...v1.44.2
---
## v1.44.1 (11/10/2025)
### What's Changed

View File

@@ -2,6 +2,7 @@
init:
uv sync
[ -d data ] || mkdir data data/assets data/favicons data/previews
uv run manage.py migrate
npm install
@@ -14,10 +15,24 @@ tasks:
test:
uv run pytest -n auto
lint:
uv run ruff check bookmarks
format:
uv run black bookmarks
uv run ruff format bookmarks
uv run djlint bookmarks/templates --reformat --quiet --warn
npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write
prepare-e2e:
uv run playwright install chromium
rm -rf static
npm run build
uv run manage.py collectstatic --no-input
e2e:
make prepare-e2e
uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
frontend:
npm run dev
npm run dev

View File

@@ -96,9 +96,17 @@ Run all tests with pytest:
make test
```
### Linting
Run linting with ruff:
```
make lint
```
### Formatting
Format Python code with black, and JavaScript code with prettier:
Format Python code with ruff, Django templates with djlint, and JavaScript code with prettier:
```
make format
```

View File

@@ -1,4 +1,6 @@
import os
from django import forms
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin
@@ -7,19 +9,18 @@ from django.core.paginator import Paginator
from django.db.models import Count, QuerySet
from django.shortcuts import render
from django.urls import path
from django.utils.translation import ngettext, gettext
from django.utils.translation import gettext, ngettext
from huey.contrib.djhuey import HUEY as huey
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import (
ApiToken,
Bookmark,
BookmarkAsset,
BookmarkBundle,
Tag,
UserProfile,
Toast,
FeedToken,
Tag,
Toast,
UserProfile,
)
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -45,12 +46,14 @@ class TaskPaginator(Paginator):
# Copied from Huey's SqliteStorage with some modifications to allow pagination
def enqueued_items(self, limit, offset):
to_bytes = lambda b: bytes(b) if not isinstance(b, bytes) else b
def to_bytes(b):
return bytes(b) if not isinstance(b, bytes) else b
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
params = (huey.storage.name, limit, offset)
serialized_tasks = [
to_bytes(i) for i, in huey.storage.sql(sql, params, results=True)
to_bytes(i) for (i,) in huey.storage.sql(sql, params, results=True)
]
return [huey.deserialize_task(task) for task in serialized_tasks]
@@ -295,7 +298,7 @@ class AdminCustomUser(UserAdmin):
def get_inline_instances(self, request, obj=None):
if not obj:
return list()
return super(AdminCustomUser, self).get_inline_instances(request, obj)
return super().get_inline_instances(request, obj)
class AdminToast(admin.ModelAdmin):
@@ -310,12 +313,26 @@ class AdminFeedToken(admin.ModelAdmin):
list_filter = ("user__username",)
class ApiTokenAdminForm(forms.ModelForm):
class Meta:
model = ApiToken
fields = ("name", "user")
class AdminApiToken(admin.ModelAdmin):
form = ApiTokenAdminForm
list_display = ("name", "user", "created")
search_fields = ["name", "user__username"]
list_filter = ("user__username",)
ordering = ("-created",)
linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(ApiToken, AdminApiToken)
linkding_admin_site.register(Toast, AdminToast)
linkding_admin_site.register(FeedToken, AdminFeedToken)

View File

@@ -2,12 +2,16 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication, get_authorization_header
from bookmarks.models import ApiToken
class LinkdingTokenAuthentication(TokenAuthentication):
"""
Extends DRF TokenAuthentication to add support for multiple keywords
Extends DRF TokenAuthentication to add support for multiple keywords and
multiple tokens per user.
"""
model = ApiToken
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
def authenticate(self, request):
@@ -29,6 +33,6 @@ class LinkdingTokenAuthentication(TokenAuthentication):
msg = _(
"Invalid token header. Token string should not contain invalid characters."
)
raise exceptions.AuthenticationFailed(msg)
raise exceptions.AuthenticationFailed(msg) from None
return self.authenticate_credentials(token)

View File

@@ -4,29 +4,29 @@ import os
from django.conf import settings
from django.http import Http404, StreamingHttpResponse
from rest_framework import viewsets, mixins, status
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import SimpleRouter, DefaultRouter
from rest_framework.routers import DefaultRouter, SimpleRouter
from bookmarks import queries
from bookmarks.api.serializers import (
BookmarkSerializer,
BookmarkAssetSerializer,
BookmarkBundleSerializer,
BookmarkSerializer,
TagSerializer,
UserProfileSerializer,
BookmarkBundleSerializer,
)
from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
BookmarkSearch,
Tag,
User,
BookmarkBundle,
)
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.services import assets, auto_tagging, bookmarks, bundles, website_loader
from bookmarks.type_defs import HttpRequest
from bookmarks.views import access
@@ -197,7 +197,7 @@ class BookmarkAssetViewSet(
file_stream = (
gzip.GzipFile(file_path, mode="rb")
if asset.gzip
else open(file_path, "rb")
else open(file_path, "rb") # noqa: SIM115
)
response = StreamingHttpResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = (
@@ -205,7 +205,7 @@ class BookmarkAssetViewSet(
)
return response
except FileNotFoundError:
raise Http404("Asset file does not exist")
raise Http404("Asset file does not exist") from None
except Exception as e:
logger.error(
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",

View File

@@ -1,4 +1,4 @@
from django.db.models import Max, prefetch_related_objects
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
@@ -6,10 +6,10 @@ from rest_framework.serializers import ListSerializer
from bookmarks.models import (
Bookmark,
BookmarkAsset,
Tag,
build_tag_string,
UserProfile,
BookmarkBundle,
Tag,
UserProfile,
build_tag_string,
)
from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag
@@ -56,7 +56,7 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
def create(self, validated_data):
bundle = BookmarkBundle(**validated_data)
bundle.order = validated_data["order"] if "order" in validated_data else None
bundle.order = validated_data.get("order", None)
return bundles.create_bundle(bundle, self.context["user"])
@@ -86,8 +86,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
"favicon_url",
"preview_image_url",
"tag_names",
"date_added",
"date_modified",
"website_title",
"website_description",
]
@@ -102,6 +100,9 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = EmtpyField()
website_description = EmtpyField()
# these are optional
date_added = serializers.DateTimeField(required=False)
date_modified = serializers.DateTimeField(required=False)
def get_favicon_url(self, obj: Bookmark):
if not obj.favicon_file:

View File

@@ -6,4 +6,5 @@ class BookmarksConfig(AppConfig):
def ready(self):
# Register signal handlers
import bookmarks.signals
# noinspection PyUnusedImports
import bookmarks.signals # noqa: F401

View File

@@ -50,10 +50,7 @@ class BaseBookmarksFeed(Feed):
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)
data = context.query_set[: int(limit)] if limit else list(context.query_set)
prefetch_related_objects(data, "tags")
return data

View File

@@ -1,10 +1,15 @@
from django import forms
from django.forms.utils import ErrorList
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from bookmarks.models import (
Bookmark,
BookmarkBundle,
BookmarkSearch,
GlobalSettings,
Tag,
UserProfile,
build_tag_string,
parse_tag_string,
sanitize_tag_name,
@@ -12,23 +17,29 @@ from bookmarks.models import (
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.type_defs import HttpRequest
from bookmarks.validators import BookmarkURLValidator
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
from bookmarks.widgets import (
FormCheckbox,
FormErrorList,
FormInput,
FormNumberInput,
FormSelect,
FormTextarea,
TagAutocomplete,
)
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
url = forms.CharField(validators=[BookmarkURLValidator()], widget=FormInput)
tag_string = forms.CharField(required=False, widget=TagAutocomplete)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
title = forms.CharField(max_length=512, required=False, widget=FormInput)
description = forms.CharField(required=False, widget=FormTextarea)
notes = forms.CharField(required=False, widget=FormTextarea)
unread = forms.BooleanField(required=False, widget=FormCheckbox)
shared = forms.BooleanField(required=False, widget=FormCheckbox)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
auto_close = forms.CharField(required=False, widget=forms.HiddenInput)
class Meta:
model = Bookmark
@@ -62,7 +73,7 @@ class BookmarkForm(forms.ModelForm):
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None
super().__init__(
data, instance=instance, initial=initial, error_class=CustomErrorList
data, instance=instance, initial=initial, error_class=FormErrorList
)
@property
@@ -111,12 +122,14 @@ def convert_tag_string(tag_string: str):
class TagForm(forms.ModelForm):
name = forms.CharField(widget=FormInput)
class Meta:
model = Tag
fields = ["name"]
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
super().__init__(*args, **kwargs, error_class=FormErrorList)
self.user = user
def clean_name(self):
@@ -146,11 +159,11 @@ class TagForm(forms.ModelForm):
class TagMergeForm(forms.Form):
target_tag = forms.CharField()
merge_tags = forms.CharField()
target_tag = forms.CharField(widget=TagAutocomplete)
merge_tags = forms.CharField(widget=TagAutocomplete)
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
super().__init__(*args, **kwargs, error_class=FormErrorList)
self.user = user
def clean_target_tag(self):
@@ -167,7 +180,9 @@ class TagMergeForm(forms.Form):
try:
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
except Tag.DoesNotExist:
raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.')
raise forms.ValidationError(
f'Tag "{target_tag_name}" does not exist.'
) from None
return target_tag
@@ -184,7 +199,9 @@ class TagMergeForm(forms.Form):
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
merge_tags.append(tag)
except Tag.DoesNotExist:
raise forms.ValidationError(f'Tag "{tag_name}" does not exist.')
raise forms.ValidationError(
f'Tag "{tag_name}" does not exist.'
) from None
target_tag = self.cleaned_data.get("target_tag")
if target_tag and target_tag in merge_tags:
@@ -193,3 +210,156 @@ class TagMergeForm(forms.Form):
)
return merge_tags
class BookmarkBundleForm(forms.ModelForm):
name = forms.CharField(max_length=256, widget=FormInput)
search = forms.CharField(max_length=256, required=False, widget=FormInput)
any_tags = forms.CharField(required=False, widget=TagAutocomplete)
all_tags = forms.CharField(required=False, widget=TagAutocomplete)
excluded_tags = forms.CharField(required=False, widget=TagAutocomplete)
class Meta:
model = BookmarkBundle
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=FormErrorList)
class BookmarkSearchForm(forms.Form):
SORT_CHOICES = [
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
]
FILTER_SHARED_CHOICES = [
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
]
FILTER_UNREAD_CHOICES = [
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
]
q = forms.CharField()
user = forms.ChoiceField(required=False, widget=FormSelect)
bundle = forms.CharField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES, widget=FormSelect)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
modified_since = forms.CharField(required=False)
added_since = forms.CharField(required=False)
def __init__(
self,
search: BookmarkSearch,
editable_fields: list[str] = None,
users: list[User] = None,
):
super().__init__()
editable_fields = editable_fields or []
self.editable_fields = editable_fields
# set choices for user field if users are provided
if users:
user_choices = [(user.username, user.username) for user in users]
user_choices.insert(0, ("", "Everyone"))
self.fields["user"].choices = user_choices
for param in search.params:
# set initial values for modified params
value = search.__dict__.get(param)
if isinstance(value, models.Model):
self.fields[param].initial = value.id
else:
self.fields[param].initial = value
# Mark non-editable modified fields as hidden. That way, templates
# rendering a form can just loop over hidden_fields to ensure that
# all necessary search options are kept when submitting the form.
if search.is_modified(param) and param not in editable_fields:
self.fields[param].widget = forms.HiddenInput()
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = [
"theme",
"bookmark_date_display",
"bookmark_description_display",
"bookmark_description_max_lines",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
"tag_grouping",
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"enable_preview_images",
"enable_automatic_html_snapshots",
"display_url",
"display_view_bookmark_action",
"display_edit_bookmark_action",
"display_archive_bookmark_action",
"display_remove_bookmark_action",
"permanent_notes",
"default_mark_unread",
"default_mark_shared",
"custom_css",
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
"hide_bundles",
"legacy_search",
]
widgets = {
"theme": FormSelect,
"bookmark_date_display": FormSelect,
"bookmark_description_display": FormSelect,
"bookmark_description_max_lines": FormNumberInput,
"bookmark_link_target": FormSelect,
"web_archive_integration": FormSelect,
"tag_search": FormSelect,
"tag_grouping": FormSelect,
"auto_tagging_rules": FormTextarea,
"custom_css": FormTextarea,
"items_per_page": FormNumberInput,
"display_url": FormCheckbox,
"permanent_notes": FormCheckbox,
"display_view_bookmark_action": FormCheckbox,
"display_edit_bookmark_action": FormCheckbox,
"display_archive_bookmark_action": FormCheckbox,
"display_remove_bookmark_action": FormCheckbox,
"sticky_pagination": FormCheckbox,
"collapse_side_panel": FormCheckbox,
"hide_bundles": FormCheckbox,
"legacy_search": FormCheckbox,
"enable_favicons": FormCheckbox,
"enable_preview_images": FormCheckbox,
"enable_sharing": FormCheckbox,
"enable_public_sharing": FormCheckbox,
"enable_automatic_html_snapshots": FormCheckbox,
"default_mark_unread": FormCheckbox,
"default_mark_shared": FormCheckbox,
}
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
widgets = {
"landing_page": FormSelect,
"guest_profile_user": FormSelect,
"enable_link_prefetch": FormCheckbox,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

View File

@@ -1,37 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class BookmarkItem extends Behavior {
constructor(element) {
super(element);
// Toggle notes
this.onToggleNotes = this.onToggleNotes.bind(this);
this.notesToggle = element.querySelector(".toggle-notes");
if (this.notesToggle) {
this.notesToggle.addEventListener("click", this.onToggleNotes);
}
// Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span");
requestAnimationFrame(() => {
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
titleAnchor.dataset.tooltip = titleSpan.textContent;
}
});
}
destroy() {
if (this.notesToggle) {
this.notesToggle.removeEventListener("click", this.onToggleNotes);
}
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();
this.element.classList.toggle("show-notes");
}
}
registerBehavior("ld-bookmark-item", BookmarkItem);

View File

@@ -1,128 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class BulkEdit extends Behavior {
constructor(element) {
super(element);
this.active = element.classList.contains("active");
this.init = this.init.bind(this);
this.onToggleActive = this.onToggleActive.bind(this);
this.onToggleAll = this.onToggleAll.bind(this);
this.onToggleBookmark = this.onToggleBookmark.bind(this);
this.onActionSelected = this.onActionSelected.bind(this);
this.init();
// Reset when bookmarks are updated
document.addEventListener("bookmark-list-updated", this.init);
}
destroy() {
this.removeListeners();
document.removeEventListener("bookmark-list-updated", this.init);
}
init() {
// Update elements
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
this.actionSelect = this.element.querySelector(
"select[name='bulk_action']",
);
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
this.selectAcross = this.element.querySelector("label.select-across");
this.allCheckbox = this.element.querySelector(
".bulk-edit-checkbox.all input",
);
this.bookmarkCheckboxes = Array.from(
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
// Add listeners, ensure there are no dupes by possibly removing existing listeners
this.removeListeners();
this.addListeners();
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
addListeners() {
this.activeToggle.addEventListener("click", this.onToggleActive);
this.actionSelect.addEventListener("change", this.onActionSelected);
this.allCheckbox.addEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", this.onToggleBookmark);
});
}
removeListeners() {
this.activeToggle.removeEventListener("click", this.onToggleActive);
this.actionSelect.removeEventListener("change", this.onActionSelected);
this.allCheckbox.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
}
onToggleActive() {
this.active = !this.active;
if (this.active) {
this.element.classList.add("active", "activating");
setTimeout(() => {
this.element.classList.remove("activating");
}, 500);
} else {
this.element.classList.remove("active");
}
}
onToggleBookmark() {
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked;
});
this.allCheckbox.checked = allChecked;
this.updateSelectAcross(allChecked);
}
onToggleAll() {
const allChecked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = allChecked;
});
this.updateSelectAcross(allChecked);
}
onActionSelected() {
const action = this.actionSelect.value;
if (action === "bulk_tag" || action === "bulk_untag") {
this.tagAutoComplete.classList.remove("d-none");
} else {
this.tagAutoComplete.classList.add("d-none");
}
}
updateSelectAcross(allChecked) {
if (allChecked) {
this.selectAcross.classList.remove("d-none");
} else {
this.selectAcross.classList.add("d-none");
this.selectAcross.querySelector("input").checked = false;
}
}
reset() {
this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
this.updateSelectAcross(false);
}
}
registerBehavior("ld-bulk-edit", BulkEdit);

View File

@@ -1,42 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class ClearButtonBehavior extends Behavior {
constructor(element) {
super(element);
this.field = document.getElementById(element.dataset.for);
if (!this.field) {
console.error(`Field with ID ${element.dataset.for} not found`);
return;
}
this.update = this.update.bind(this);
this.clear = this.clear.bind(this);
this.element.addEventListener("click", this.clear);
this.field.addEventListener("input", this.update);
this.field.addEventListener("value-changed", this.update);
this.update();
}
destroy() {
if (!this.field) {
return;
}
this.element.removeEventListener("click", this.clear);
this.field.removeEventListener("input", this.update);
this.field.removeEventListener("value-changed", this.update);
}
update() {
this.element.style.display = this.field.value ? "inline-flex" : "none";
}
clear() {
this.field.value = "";
this.field.focus();
this.update();
}
}
registerBehavior("ld-clear-button", ClearButtonBehavior);

View File

@@ -1,173 +0,0 @@
import { Behavior, registerBehavior } from "./index";
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
let confirmId = 0;
function nextConfirmId() {
return `confirm-${confirmId++}`;
}
class ConfirmButtonBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.element.addEventListener("click", this.onClick);
}
destroy() {
if (this.opened) {
this.close();
}
this.element.removeEventListener("click", this.onClick);
}
onClick(event) {
event.preventDefault();
if (this.opened) {
this.close();
} else {
this.open();
}
}
open() {
const dropdown = document.createElement("div");
dropdown.className = "dropdown confirm-dropdown active";
const confirmId = nextConfirmId();
const questionId = `${confirmId}-question`;
const menu = document.createElement("div");
menu.className = "menu with-arrow";
menu.role = "alertdialog";
menu.setAttribute("aria-modal", "true");
menu.setAttribute("aria-labelledby", questionId);
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
const question = document.createElement("span");
question.id = questionId;
question.textContent =
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
question.style.fontWeight = "bold";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.type = "button";
cancelButton.className = "btn";
cancelButton.tabIndex = 0;
cancelButton.addEventListener("click", () => this.close());
const confirmButton = document.createElement("button");
confirmButton.textContent = "Confirm";
confirmButton.type = "submit";
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.className = "btn btn-error";
confirmButton.addEventListener("click", () => this.confirm());
const arrow = document.createElement("div");
arrow.className = "menu-arrow";
menu.append(question, cancelButton, confirmButton, arrow);
dropdown.append(menu);
document.body.append(dropdown);
this.positionController = new AnchorPositionController(this.element, menu);
this.focusTrap = new FocusTrapController(menu);
this.dropdown = dropdown;
this.opened = true;
}
onMenuKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.close();
}
}
confirm() {
this.element.closest("form").requestSubmit(this.element);
this.close();
}
close() {
if (!this.opened) return;
this.positionController.destroy();
this.focusTrap.destroy();
this.dropdown.remove();
this.element.focus({ focusVisible: isKeyboardActive() });
this.opened = false;
}
}
class AnchorPositionController {
constructor(anchor, overlay) {
this.anchor = anchor;
this.overlay = overlay;
this.handleScroll = this.handleScroll.bind(this);
window.addEventListener("scroll", this.handleScroll, { capture: true });
this.updatePosition();
}
handleScroll() {
if (this.debounce) {
return;
}
this.debounce = true;
requestAnimationFrame(() => {
this.updatePosition();
this.debounce = false;
});
}
updatePosition() {
const anchorRect = this.anchor.getBoundingClientRect();
const overlayRect = this.overlay.getBoundingClientRect();
const bufferX = 10;
const bufferY = 30;
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
const initialLeft = left;
const overflowLeft = left < bufferX;
const overflowRight =
left + overlayRect.width > window.innerWidth - bufferX;
if (overflowLeft) {
left = bufferX;
} else if (overflowRight) {
left = window.innerWidth - overlayRect.width - bufferX;
}
const delta = initialLeft - left;
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
let top = anchorRect.bottom;
const overflowBottom =
top + overlayRect.height > window.innerHeight - bufferY;
if (overflowBottom) {
top = anchorRect.top - overlayRect.height;
this.overlay.classList.remove("top-aligned");
this.overlay.classList.add("bottom-aligned");
} else {
this.overlay.classList.remove("bottom-aligned");
this.overlay.classList.add("top-aligned");
}
this.overlay.style.left = `${left}px`;
this.overlay.style.top = `${top}px`;
}
destroy() {
window.removeEventListener("scroll", this.handleScroll, { capture: true });
}
}
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -1,24 +0,0 @@
import { registerBehavior } from "./index";
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends ModalBehavior {
doClose() {
super.doClose();
// Navigate to close URL
const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
// Try restore focus to view details to view details link of respective bookmark
const bookmarkId = this.element.dataset.bookmarkId;
setAfterPageLoadFocusTarget(
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
);
}
}
registerBehavior("ld-details-modal", DetailsModalBehavior);

View File

@@ -1,97 +0,0 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="btn btn-noborder close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector(".side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector(".side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -1,109 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class FormSubmit extends Behavior {
constructor(element) {
super(element);
this.onKeyDown = this.onKeyDown.bind(this);
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Check for Ctrl/Cmd + Enter combination
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
this.element.requestSubmit();
}
}
}
class AutoSubmitBehavior extends Behavior {
constructor(element) {
super(element);
this.submit = this.submit.bind(this);
element.addEventListener("change", this.submit);
}
destroy() {
this.element.removeEventListener("change", this.submit);
}
submit() {
this.element.closest("form").requestSubmit();
}
}
// Resets form controls to their initial values before Turbo caches the DOM.
// Useful for filter forms where navigating back would otherwise still show
// values from after the form submission, which means the filters would be out
// of sync with the URL.
class FormResetBehavior extends Behavior {
constructor(element) {
super(element);
this.controls = this.element.querySelectorAll("input, select");
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.__initialValue = control.checked;
} else {
control.__initialValue = control.value;
}
});
}
destroy() {
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.checked = control.__initialValue;
} else {
control.value = control.__initialValue;
}
delete control.__initialValue;
});
}
}
class UploadButton extends Behavior {
constructor(element) {
super(element);
this.fileInput = element.nextElementSibling;
this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this);
element.addEventListener("click", this.onClick);
this.fileInput.addEventListener("change", this.onChange);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
this.fileInput.removeEventListener("change", this.onChange);
}
onClick(event) {
event.preventDefault();
this.fileInput.click();
}
onChange() {
// Check if the file input has a file selected
if (!this.fileInput.files.length) {
return;
}
const form = this.fileInput.closest("form");
form.requestSubmit(this.element);
// remove selected file so it doesn't get submitted again
this.fileInput.value = "";
}
}
registerBehavior("ld-form-submit", FormSubmit);
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-form-reset", FormResetBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

@@ -1,80 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class GlobalShortcuts extends Behavior {
constructor(element) {
super(element);
this.onKeyDown = this.onKeyDown.bind(this);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
// Handle shortcuts for navigating bookmarks with arrow keys
const isArrowUp = event.key === "ArrowUp";
const isArrowDown = event.key === "ArrowDown";
if (isArrowUp || isArrowDown) {
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
);
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector("[ld-bookmark-item]");
}
// Focus first link
if (nextItem) {
nextItem.querySelector("a").focus();
}
}
// Handle shortcut for toggling all notes
if (event.key === "e") {
const list = document.querySelector(".bookmark-list");
if (list) {
list.classList.toggle("show-notes");
}
}
// Handle shortcut for focusing search input
if (event.key === "s") {
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
}
// Handle shortcut for adding new bookmark
if (event.key === "n") {
window.location.assign("/bookmarks/new");
}
}
}
registerBehavior("ld-global-shortcuts", GlobalShortcuts);

View File

@@ -1,119 +0,0 @@
const behaviorRegistry = {};
const debug = false;
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && !node.isConnected) {
destroyBehaviors(node);
}
});
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.isConnected) {
applyBehaviors(node);
}
});
});
});
// Update behaviors on Turbo events
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
// - turbo:render: after page navigation, including back/forward, and failed form submissions
// - turbo:before-cache: before page navigation, reset DOM before caching
document.addEventListener(
"turbo:load",
() => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
applyBehaviors(document.body);
},
{ once: true },
);
document.addEventListener("turbo:render", () => {
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
applyBehaviors(document.body);
});
document.addEventListener("turbo:before-cache", () => {
destroyBehaviors(document.body);
});
export class Behavior {
constructor(element) {
this.element = element;
}
destroy() {}
}
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
}
export function applyBehaviors(container, behaviorNames = null) {
if (!behaviorNames) {
behaviorNames = Object.keys(behaviorRegistry);
}
behaviorNames.forEach((behaviorName) => {
const behavior = behaviorRegistry[behaviorName];
const elements = Array.from(
container.querySelectorAll(`[${behaviorName}]`),
);
// Include the container element if it has the behavior
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
elements.push(container);
}
elements.forEach((element) => {
element.__behaviors = element.__behaviors || [];
const hasBehavior = element.__behaviors.some(
(b) => b instanceof behavior,
);
if (hasBehavior) {
return;
}
const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance);
if (debug) {
console.log(
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
);
}
});
});
}
export function destroyBehaviors(element) {
const behaviorNames = Object.keys(behaviorRegistry);
behaviorNames.forEach((behaviorName) => {
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
elements.push(element);
elements.forEach((element) => {
if (!element.__behaviors) {
return;
}
element.__behaviors.forEach((behavior) => {
behavior.destroy();
if (debug) {
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
}
});
delete element.__behaviors;
});
});
}

View File

@@ -1,35 +0,0 @@
import { Behavior, registerBehavior } from "./index";
import "../components/SearchAutocomplete.js";
class SearchAutocomplete extends Behavior {
constructor(element) {
super(element);
const input = element.querySelector("input");
if (!input) {
console.warn("SearchAutocomplete: input element not found");
return;
}
const autocomplete = document.createElement("ld-search-autocomplete");
autocomplete.name = "q";
autocomplete.placeholder = input.getAttribute("placeholder") || "";
autocomplete.value = input.value;
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
autocomplete.mode = input.dataset.mode || "";
autocomplete.search = {
user: input.dataset.user,
shared: input.dataset.shared,
unread: input.dataset.unread,
};
this.input = input;
this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete);
}
destroy() {
this.autocomplete.replaceWith(this.input);
}
}
registerBehavior("ld-search-autocomplete", SearchAutocomplete);

View File

@@ -1,31 +0,0 @@
import { Behavior, registerBehavior } from "./index";
import "../components/TagAutocomplete.js";
class TagAutocomplete extends Behavior {
constructor(element) {
super(element);
const input = element.querySelector("input");
if (!input) {
console.warn("TagAutocomplete: input element not found");
return;
}
const autocomplete = document.createElement("ld-tag-autocomplete");
autocomplete.id = input.id;
autocomplete.name = input.name;
autocomplete.value = input.value;
autocomplete.placeholder = input.getAttribute("placeholder") || "";
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
autocomplete.variant = input.getAttribute("variant") || "default";
this.input = input;
this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete);
}
destroy() {
this.autocomplete.replaceWith(this.input);
}
}
registerBehavior("ld-tag-autocomplete", TagAutocomplete);

View File

@@ -0,0 +1,153 @@
import { HeadlessElement } from "../utils/element.js";
class BookmarkPage extends HeadlessElement {
init() {
this.update = this.update.bind(this);
this.onToggleNotes = this.onToggleNotes.bind(this);
this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this);
this.onBulkActionChange = this.onBulkActionChange.bind(this);
this.onToggleAll = this.onToggleAll.bind(this);
this.onToggleBookmark = this.onToggleBookmark.bind(this);
this.oldItems = [];
this.update();
document.addEventListener("bookmark-list-updated", this.update);
}
disconnectedCallback() {
document.removeEventListener("bookmark-list-updated", this.update);
}
update() {
const items = this.querySelectorAll("ul.bookmark-list > li");
this.updateTooltips(items);
this.updateNotesToggles(items, this.oldItems);
this.updateBulkEdit(items, this.oldItems);
this.oldItems = items;
}
updateTooltips(items) {
// Add tooltip to title if it is truncated
items.forEach((item) => {
const titleAnchor = item.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span");
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
titleAnchor.dataset.tooltip = titleSpan.textContent;
} else {
delete titleAnchor.dataset.tooltip;
}
});
}
updateNotesToggles(items, oldItems) {
oldItems.forEach((oldItem) => {
const oldToggle = oldItem.querySelector(".toggle-notes");
if (oldToggle) {
oldToggle.removeEventListener("click", this.onToggleNotes);
}
});
items.forEach((item) => {
const notesToggle = item.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes);
}
});
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();
event.target.closest("li").classList.toggle("show-notes");
}
updateBulkEdit() {
if (this.hasAttribute("no-bulk-edit")) {
return;
}
// Remove existing listeners
this.activeToggle?.removeEventListener("click", this.onToggleBulkEdit);
this.actionSelect?.removeEventListener("change", this.onBulkActionChange);
this.allCheckbox?.removeEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes?.forEach((checkbox) => {
checkbox.removeEventListener("change", this.onToggleBookmark);
});
// Re-query elements
this.activeToggle = this.querySelector(".bulk-edit-active-toggle");
this.actionSelect = this.querySelector("select[name='bulk_action']");
this.allCheckbox = this.querySelector(".bulk-edit-checkbox.all input");
this.bookmarkCheckboxes = Array.from(
this.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
);
this.selectAcross = this.querySelector("label.select-across");
this.executeButton = this.querySelector("button[name='bulk_execute']");
// Add listeners
this.activeToggle.addEventListener("click", this.onToggleBulkEdit);
this.actionSelect.addEventListener("change", this.onBulkActionChange);
this.allCheckbox.addEventListener("change", this.onToggleAll);
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", this.onToggleBookmark);
});
// Reset checkbox states
this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
this.updateSelectAcross(false);
this.updateExecuteButton();
// Update total number of bookmarks
const totalHolder = this.querySelector("[data-bookmarks-total]");
const total = totalHolder?.dataset.bookmarksTotal || 0;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
onToggleBulkEdit() {
this.classList.toggle("active");
}
onBulkActionChange() {
this.dataset.bulkAction = this.actionSelect.value;
}
onToggleAll() {
const allChecked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = allChecked;
});
this.updateSelectAcross(allChecked);
this.updateExecuteButton();
}
onToggleBookmark() {
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked;
});
this.allCheckbox.checked = allChecked;
this.updateSelectAcross(allChecked);
this.updateExecuteButton();
}
updateSelectAcross(allChecked) {
if (allChecked) {
this.selectAcross.classList.remove("d-none");
} else {
this.selectAcross.classList.add("d-none");
this.selectAcross.querySelector("input").checked = false;
}
}
updateExecuteButton() {
const anyChecked = this.bookmarkCheckboxes.some((checkbox) => {
return checkbox.checked;
});
this.executeButton.disabled = !anyChecked;
}
}
customElements.define("ld-bookmark-page", BookmarkPage);

View File

@@ -0,0 +1,30 @@
import { HeadlessElement } from "../utils/element";
class ClearButton extends HeadlessElement {
init() {
this.field = document.getElementById(this.dataset.for);
if (!this.field) {
console.error(`Field with ID ${this.dataset.for} not found`);
return;
}
this.update = this.update.bind(this);
this.clear = this.clear.bind(this);
this.addEventListener("click", this.clear);
this.field.addEventListener("input", this.update);
this.field.addEventListener("value-changed", this.update);
this.update();
}
update() {
this.style.display = this.field.value ? "inline" : "none";
}
clear() {
this.field.value = "";
this.field.focus();
this.update();
}
}
customElements.define("ld-clear-button", ClearButton);

View File

@@ -0,0 +1,103 @@
import { html, LitElement } from "lit";
import { FocusTrapController, isKeyboardActive } from "../utils/focus.js";
import { PositionController } from "../utils/position-controller.js";
let confirmId = 0;
function nextConfirmId() {
return `confirm-${confirmId++}`;
}
function removeAll() {
document
.querySelectorAll("ld-confirm-dropdown")
.forEach((dropdown) => dropdown.close());
}
// Create a confirm dropdown whenever a button with the data-confirm attribute is clicked
document.addEventListener("click", (event) => {
// Check if the clicked element is a button with data-confirm
const button = event.target.closest("button[data-confirm]");
if (!button) return;
// Remove any existing confirm dropdowns
removeAll();
// Show confirmation dropdown
event.preventDefault();
const dropdown = document.createElement("ld-confirm-dropdown");
dropdown.button = button;
document.body.appendChild(dropdown);
});
// Remove all confirm dropdowns when:
// - Turbo caches the page
// - The escape key is pressed
document.addEventListener("turbo:before-cache", removeAll);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
removeAll();
}
});
class ConfirmDropdown extends LitElement {
constructor() {
super();
this.confirmId = nextConfirmId();
}
createRenderRoot() {
return this;
}
firstUpdated(props) {
super.firstUpdated(props);
this.classList.add("dropdown", "confirm-dropdown", "active");
const menu = this.querySelector(".menu");
this.positionController = new PositionController({
anchor: this.button,
overlay: menu,
arrow: this.querySelector(".menu-arrow"),
offset: 12,
});
this.positionController.enable();
this.focusTrap = new FocusTrapController(menu);
}
render() {
const questionText = this.button.dataset.confirmQuestion || "Are you sure?";
return html`
<div
class="menu with-arrow"
role="alertdialog"
aria-modal="true"
aria-labelledby=${this.confirmId}
>
<span id=${this.confirmId} style="font-weight: bold;">
${questionText}
</span>
<button type="button" class="btn" @click=${this.close}>Cancel</button>
<button type="submit" class="btn btn-error" @click=${this.confirm}>
Confirm
</button>
<div class="menu-arrow"></div>
</div>
`;
}
confirm() {
this.button.closest("form").requestSubmit(this.button);
this.close();
}
close() {
this.positionController.disable();
this.focusTrap.destroy();
this.remove();
this.button.focus({ focusVisible: isKeyboardActive() });
}
}
customElements.define("ld-confirm-dropdown", ConfirmDropdown);

View File

@@ -0,0 +1,16 @@
import { setAfterPageLoadFocusTarget } from "../utils/focus.js";
import { Modal } from "./modal.js";
class DetailsModal extends Modal {
doClose() {
super.doClose();
// Try restore focus to view details to view details link of respective bookmark
const bookmarkId = this.dataset.bookmarkId;
setAfterPageLoadFocusTarget(
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
);
}
}
customElements.define("ld-details-modal", DetailsModal);

View File

@@ -0,0 +1,257 @@
import { LitElement, html, css } from "lit";
class DevTool extends LitElement {
static properties = {
profile: { type: Object, state: true },
formAction: { type: String, attribute: "data-form-action" },
csrfToken: { type: String, attribute: "data-csrf-token" },
isOpen: { type: Boolean, state: true },
};
static styles = css`
:host {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 10000;
}
.button {
background: var(--btn-primary-bg-color);
color: var(--btn-primary-text-color);
border: none;
padding: var(--unit-2);
border-radius: var(--border-radius);
box-shadow: var(--btn-box-shadow);
cursor: pointer;
height: auto;
line-height: 0;
}
.overlay {
display: none;
position: absolute;
bottom: 100%;
right: 0;
background: var(--body-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--unit-2);
margin-bottom: var(--unit-2);
min-width: 220px;
box-shadow: var(--box-shadow-lg);
font-size: var(--font-size-sm);
}
:host([open]) .overlay {
display: block;
}
h3 {
margin: 0 0 var(--unit-2) 0;
}
label {
display: flex;
align-items: center;
gap: var(--unit-1);
cursor: pointer;
}
label:has(select) {
margin-bottom: var(--unit-1);
}
label:has(select) span {
min-width: 100px;
}
hr {
margin: var(--unit-2) 0;
border: none;
border-top: 1px solid var(--border-color);
}
`;
static fields = [
{
type: "select",
key: "theme",
label: "Theme",
options: [
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
],
},
{
type: "select",
key: "bookmark_date_display",
label: "Date",
options: [
{ value: "relative", label: "Relative" },
{ value: "absolute", label: "Absolute" },
{ value: "hidden", label: "Hidden" },
],
},
{
type: "select",
key: "bookmark_description_display",
label: "Description",
options: [
{ value: "inline", label: "Inline" },
{ value: "separate", label: "Separate" },
],
},
{ type: "checkbox", key: "enable_favicons", label: "Favicons" },
{ type: "checkbox", key: "enable_preview_images", label: "Preview images" },
{ type: "checkbox", key: "display_url", label: "Display URL" },
{ type: "checkbox", key: "permanent_notes", label: "Permanent notes" },
{ type: "checkbox", key: "collapse_side_panel", label: "Collapse sidebar" },
{ type: "checkbox", key: "sticky_pagination", label: "Sticky pagination" },
{ type: "checkbox", key: "hide_bundles", label: "Hide bundles" },
];
constructor() {
super();
this.isOpen = false;
this.profile = {};
this._onOutsideClick = this._onOutsideClick.bind(this);
}
connectedCallback() {
super.connectedCallback();
const profileData = document.getElementById("json_profile");
this.profile = JSON.parse(profileData.textContent || "{}");
document.addEventListener("click", this._onOutsideClick);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("click", this._onOutsideClick);
}
_onOutsideClick(e) {
if (!this.contains(e.target) && this.isOpen) {
this.isOpen = false;
this.removeAttribute("open");
}
}
_toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.setAttribute("open", "");
} else {
this.removeAttribute("open");
}
}
_handleChange(key, value) {
this.profile = { ...this.profile, [key]: value };
if (key === "theme") {
const themeLinks = document.head.querySelectorAll('link[href*="theme"]');
themeLinks.forEach((link) => link.remove());
}
this._submitForm();
}
_renderField(field) {
switch (field.type) {
case "checkbox":
return html`
<label>
<input
type="checkbox"
.checked=${this.profile[field.key] || false}
@change=${(e) => this._handleChange(field.key, e.target.checked)}
/>
${field.label}
</label>
`;
case "select":
return html`
<label>
<span>${field.label}:</span>
<select
@change=${(e) => this._handleChange(field.key, e.target.value)}
>
${field.options.map(
(opt) => html`
<option
value=${opt.value}
?selected=${this.profile[field.key] === opt.value}
>
${opt.label}
</option>
`,
)}
</select>
</label>
`;
case "divider":
return html`<hr />`;
default:
return null;
}
}
async _submitForm() {
const formData = new FormData();
formData.append("csrfmiddlewaretoken", this.csrfToken);
// Profile fields
for (const [key, value] of Object.entries(this.profile)) {
if (typeof value === "boolean" && value) {
formData.append(key, "on");
} else if (typeof value !== "boolean") {
formData.append(key, value);
}
}
// Submit button name that settings.update expects
formData.append("update_profile", "1");
await fetch(this.formAction, {
method: "POST",
body: formData,
});
const url = new URL(window.location);
url.searchParams.set("ts", Date.now().toString());
window.history.replaceState({}, "", url);
Turbo.visit(url.toString());
}
render() {
return html`
<button class="button" @click=${() => this._toggle()}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065"
/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>
</button>
<div class="overlay">
<h3>Dev Tools</h3>
${DevTool.fields.map((field) => this._renderField(field))}
</div>
`;
}
}
customElements.define("ld-dev-tool", DevTool);

View File

@@ -1,43 +1,42 @@
import { Behavior, registerBehavior } from "./index";
import { HeadlessElement } from "../utils/element.js";
class DropdownBehavior extends Behavior {
constructor(element) {
super(element);
class Dropdown extends HeadlessElement {
constructor() {
super();
this.opened = false;
this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
}
init() {
// Prevent opening the dropdown automatically on focus, so that it only
// opens on click then JS is enabled
this.element.style.setProperty("--dropdown-focus-display", "none");
this.element.addEventListener("keydown", this.onEscape);
this.element.addEventListener("focusout", this.onFocusOut);
// opens on click when JS is enabled
this.style.setProperty("--dropdown-focus-display", "none");
this.addEventListener("keydown", this.onEscape);
this.addEventListener("focusout", this.onFocusOut);
this.toggle = element.querySelector(".dropdown-toggle");
this.toggle = this.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick);
}
destroy() {
disconnectedCallback() {
this.close();
this.toggle.removeEventListener("click", this.onClick);
this.element.removeEventListener("keydown", this.onEscape);
this.element.removeEventListener("focusout", this.onFocusOut);
}
open() {
this.opened = true;
this.element.classList.add("active");
this.classList.add("active");
this.toggle.setAttribute("aria-expanded", "true");
document.addEventListener("click", this.onOutsideClick);
}
close() {
this.opened = false;
this.element.classList.remove("active");
this.toggle.setAttribute("aria-expanded", "false");
this.classList.remove("active");
this.toggle?.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onOutsideClick);
}
@@ -50,7 +49,7 @@ class DropdownBehavior extends Behavior {
}
onOutsideClick(event) {
if (!this.element.contains(event.target)) {
if (!this.contains(event.target)) {
this.close();
}
}
@@ -64,10 +63,10 @@ class DropdownBehavior extends Behavior {
}
onFocusOut(event) {
if (!this.element.contains(event.relatedTarget)) {
if (!this.contains(event.relatedTarget)) {
this.close();
}
}
}
registerBehavior("ld-dropdown", DropdownBehavior);
customElements.define("ld-dropdown", Dropdown);

View File

@@ -0,0 +1,110 @@
import { html, render } from "lit";
import { Modal } from "./modal.js";
import { HeadlessElement } from "../utils/element.js";
import { isKeyboardActive } from "../utils/focus.js";
class FilterDrawerTrigger extends HeadlessElement {
init() {
this.onClick = this.onClick.bind(this);
this.addEventListener("click", this.onClick.bind(this));
}
onClick() {
const modal = document.createElement("ld-filter-drawer");
document.body.querySelector(".modals").appendChild(modal);
}
}
customElements.define("ld-filter-drawer-trigger", FilterDrawerTrigger);
class FilterDrawer extends Modal {
connectedCallback() {
this.classList.add("modal", "drawer");
// Render modal structure
render(
html`
<div class="modal-overlay" data-close-modal></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button
class="btn btn-noborder close"
aria-label="Close dialog"
data-close-modal
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body"></div>
</div>
`,
this,
);
// Teleport filter content
this.teleport();
// Force close on turbo cache to restore content
this.doClose = this.doClose.bind(this);
document.addEventListener("turbo:before-cache", this.doClose);
// Force reflow to make transform transition work
this.getBoundingClientRect();
// Add active class to start slide-in animation
requestAnimationFrame(() => this.classList.add("active"));
// Call super.init() after rendering to ensure elements are available
super.init();
}
disconnectedCallback() {
super.disconnectedCallback();
this.teleportBack();
document.removeEventListener("turbo:before-cache", this.doClose);
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.querySelector(".modal-body");
const sidePanel = document.querySelector(".side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector(".side-panel");
const content = this.querySelector(".modal-body");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("ld-filter-drawer-trigger") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
customElements.define("ld-filter-drawer", FilterDrawer);

View File

@@ -0,0 +1,71 @@
import { HeadlessElement } from "../utils/element.js";
class Form extends HeadlessElement {
constructor() {
super();
this.onKeyDown = this.onKeyDown.bind(this);
this.onChange = this.onChange.bind(this);
}
init() {
this.addEventListener("keydown", this.onKeyDown);
this.addEventListener("change", this.onChange);
if (this.hasAttribute("data-form-reset")) {
// Resets form controls to their initial values before Turbo caches the DOM.
// Useful for filter forms where navigating back would otherwise still show
// values from after the form submission, which means the filters would be out
// of sync with the URL.
this.initFormReset();
}
}
disconnectedCallback() {
if (this.hasAttribute("data-form-reset")) {
this.resetForm();
}
}
onChange(event) {
if (event.target.hasAttribute("data-submit-on-change")) {
this.querySelector("form")?.requestSubmit();
}
}
onKeyDown(event) {
// Check for Ctrl/Cmd + Enter combination
if (
this.hasAttribute("data-submit-on-ctrl-enter") &&
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
event.stopPropagation();
this.querySelector("form")?.requestSubmit();
}
}
initFormReset() {
this.controls = this.querySelectorAll("input, select");
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.__initialValue = control.checked;
} else {
control.__initialValue = control.value;
}
});
}
resetForm() {
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.checked = control.__initialValue;
} else {
control.value = control.__initialValue;
}
delete control.__initialValue;
});
}
}
customElements.define("ld-form", Form);

View File

@@ -1,39 +1,27 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
import { FocusTrapController } from "../utils/focus.js";
import { HeadlessElement } from "../utils/element.js";
export class Modal extends HeadlessElement {
init() {
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", this.onClose);
});
this.addEventListener("keydown", this.onKeyDown);
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.removeScrollLock();
this.focusTrap.destroy();
}
init() {
this.setupScrollLock();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
this.querySelector(".modal-container"),
);
}
disconnectedCallback() {
this.removeScrollLock();
this.focusTrap.destroy();
}
setupScrollLock() {
document.body.classList.add("scroll-lock");
}
@@ -61,8 +49,8 @@ export class ModalBehavior extends Behavior {
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
this.classList.add("closing");
this.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
@@ -74,8 +62,17 @@ export class ModalBehavior extends Behavior {
}
doClose() {
this.element.remove();
this.removeScrollLock();
this.element.dispatchEvent(new CustomEvent("modal:close"));
this.remove();
this.dispatchEvent(new CustomEvent("modal:close"));
// Navigate to close URL
const closeUrl = this.dataset.closeUrl;
const frame = this.dataset.turboFrame;
const action = this.dataset.turboAction || "replace";
if (closeUrl) {
Turbo.visit(closeUrl, { action, frame: frame });
}
}
}
customElements.define("ld-modal", Modal);

View File

@@ -1,22 +1,26 @@
import { LitElement, html } from "lit";
import { SearchHistory } from "./SearchHistory.js";
import { html } from "lit";
import { api } from "../api.js";
import { cache } from "../cache.js";
import { TurboLitElement } from "../utils/element.js";
import {
clampText,
debounce,
getCurrentWord,
getCurrentWordBounds,
} from "../util.js";
} from "../utils/input.js";
import { PositionController } from "../utils/position-controller.js";
import { SearchHistory } from "../utils/search-history.js";
import { cache } from "../utils/tag-cache.js";
export class SearchAutocomplete extends LitElement {
export class SearchAutocomplete extends TurboLitElement {
static properties = {
name: { type: String },
placeholder: { type: String },
value: { type: String },
inputName: { type: String, attribute: "input-name" },
inputPlaceholder: { type: String, attribute: "input-placeholder" },
inputValue: { type: String, attribute: "input-value" },
mode: { type: String },
search: { type: Object },
linkTarget: { type: String },
user: { type: String },
shared: { type: String },
unread: { type: String },
target: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
@@ -25,12 +29,11 @@ export class SearchAutocomplete extends LitElement {
constructor() {
super();
this.name = "";
this.placeholder = "";
this.value = "";
this.inputName = "";
this.inputPlaceholder = "";
this.inputValue = "";
this.mode = "";
this.search = {};
this.linkTarget = "_blank";
this.target = "_blank";
this.isFocus = false;
this.isOpen = false;
this.suggestions = {
@@ -41,20 +44,29 @@ export class SearchAutocomplete extends LitElement {
};
this.selectedIndex = undefined;
this.input = null;
this.menu = null;
this.searchHistory = new SearchHistory();
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.style.setProperty("--menu-max-height", "400px");
this.input = this.querySelector("input");
this.menu = this.querySelector(".menu");
// Track current search query after loading the page
this.searchHistory.pushCurrent();
this.updateSuggestions();
this.positionController = new PositionController({
anchor: this.input,
overlay: this.menu,
autoWidth: true,
placement: "bottom-start",
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.close();
}
handleFocus() {
@@ -67,7 +79,7 @@ export class SearchAutocomplete extends LitElement {
}
handleInput(e) {
this.value = e.target.value;
this.inputValue = e.target.value;
this.debouncedLoadSuggestions();
}
@@ -105,12 +117,14 @@ export class SearchAutocomplete extends LitElement {
open() {
this.isOpen = true;
this.positionController.enable();
}
close() {
this.isOpen = false;
this.updateSuggestions();
this.selectedIndex = undefined;
this.positionController.disable();
}
hasSuggestions() {
@@ -146,7 +160,7 @@ export class SearchAutocomplete extends LitElement {
// Recent search suggestions
const recentSearches = this.searchHistory
.getRecentSearches(this.value, 5)
.getRecentSearches(this.inputValue, 5)
.map((value) => ({
type: "search",
index: nextIndex(),
@@ -157,11 +171,13 @@ export class SearchAutocomplete extends LitElement {
// Bookmark suggestions
let bookmarks = [];
if (this.value && this.value.length >= 3) {
if (this.inputValue && this.inputValue.length >= 3) {
const path = this.mode ? `/${this.mode}` : "";
const suggestionSearch = {
...this.search,
q: this.value,
user: this.user,
shared: this.shared,
unread: this.unread,
q: this.inputValue,
};
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
limit: 5,
@@ -203,11 +219,11 @@ export class SearchAutocomplete extends LitElement {
completeSuggestion(suggestion) {
if (suggestion.type === "search") {
this.value = suggestion.value;
this.inputValue = suggestion.value;
this.close();
}
if (suggestion.type === "bookmark") {
window.open(suggestion.bookmark.url, this.linkTarget);
window.open(suggestion.bookmark.url, this.target);
this.close();
}
if (suggestion.type === "tag") {
@@ -277,10 +293,10 @@ export class SearchAutocomplete extends LitElement {
<input
type="search"
class="form-input"
name="${this.name}"
placeholder="${this.placeholder}"
name="${this.inputName}"
placeholder="${this.inputPlaceholder}"
autocomplete="off"
.value="${this.value}"
.value="${this.inputValue}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}

View File

@@ -1,14 +1,17 @@
import { LitElement, html } from "lit";
import { cache } from "../cache.js";
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
import { html, nothing } from "lit";
import { TurboLitElement } from "../utils/element.js";
import { getCurrentWord, getCurrentWordBounds } from "../utils/input.js";
import { PositionController } from "../utils/position-controller.js";
import { cache } from "../utils/tag-cache.js";
export class TagAutocomplete extends LitElement {
export class TagAutocomplete extends TurboLitElement {
static properties = {
id: { type: String },
name: { type: String },
value: { type: String },
placeholder: { type: String },
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
inputId: { type: String, attribute: "input-id" },
inputName: { type: String, attribute: "input-name" },
inputValue: { type: String, attribute: "input-value" },
inputClass: { type: String, attribute: "input-class" },
inputPlaceholder: { type: String, attribute: "input-placeholder" },
inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" },
variant: { type: String },
isFocus: { state: true },
isOpen: { state: true },
@@ -18,11 +21,11 @@ export class TagAutocomplete extends LitElement {
constructor() {
super();
this.id = "";
this.name = "";
this.value = "";
this.placeholder = "";
this.ariaDescribedBy = "";
this.inputId = "";
this.inputName = "";
this.inputValue = "";
this.inputPlaceholder = "";
this.inputAriaDescribedBy = "";
this.variant = "default";
this.isFocus = false;
this.isOpen = false;
@@ -32,13 +35,20 @@ export class TagAutocomplete extends LitElement {
this.suggestionList = null;
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.input = this.querySelector("input");
this.suggestionList = this.querySelector(".menu");
this.positionController = new PositionController({
anchor: this.input,
overlay: this.suggestionList,
autoWidth: true,
placement: "bottom-start",
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.close();
}
handleFocus() {
@@ -92,12 +102,14 @@ export class TagAutocomplete extends LitElement {
open() {
this.isOpen = true;
this.selectedIndex = 0;
this.positionController.enable();
}
close() {
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
this.positionController.disable();
}
complete(suggestion) {
@@ -145,15 +157,15 @@ export class TagAutocomplete extends LitElement {
>
<!-- autocomplete real input box -->
<input
id="${this.id}"
name="${this.name}"
.value="${this.value || ""}"
placeholder="${this.placeholder || " "}"
class="form-input"
id="${this.inputId || nothing}"
name="${this.inputName || nothing}"
.value="${this.inputValue || ""}"
placeholder="${this.inputPlaceholder || " "}"
class="form-input ${this.inputClass || ""}"
type="text"
autocomplete="off"
autocapitalize="off"
aria-describedby="${this.ariaDescribedBy}"
aria-describedby="${this.inputAriaDescribedBy || nothing}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}

View File

@@ -0,0 +1,31 @@
import { HeadlessElement } from "../utils/element.js";
class UploadButton extends HeadlessElement {
init() {
this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this);
this.button = this.querySelector('button[type="submit"]');
this.button.addEventListener("click", this.onClick);
this.fileInput = this.querySelector('input[type="file"]');
this.fileInput.addEventListener("change", this.onChange);
}
onClick(event) {
event.preventDefault();
this.fileInput.click();
}
onChange() {
// Check if the file input has a file selected
if (!this.fileInput.files.length) {
return;
}
this.closest("form").requestSubmit(this.button);
// remove selected file so it doesn't get submitted again
this.fileInput.value = "";
}
}
customElements.define("ld-upload-button", UploadButton);

View File

@@ -1,15 +1,14 @@
import "@hotwired/turbo";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/clear-button";
import "./behaviors/confirm-button";
import "./behaviors/details-modal";
import "./behaviors/dropdown";
import "./behaviors/filter-drawer";
import "./behaviors/form";
import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete";
export { api } from "./api";
export { cache } from "./cache";
import "./components/bookmark-page.js";
import "./components/clear-button.js";
import "./components/confirm-dropdown.js";
import "./components/details-modal.js";
import "./components/dev-tool.js";
import "./components/dropdown.js";
import "./components/filter-drawer.js";
import "./components/form.js";
import "./components/modal.js";
import "./components/search-autocomplete.js";
import "./components/tag-autocomplete.js";
import "./components/upload-button.js";
import "./shortcuts.js";

View File

@@ -0,0 +1,62 @@
document.addEventListener("keydown", (event) => {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
// Handle shortcuts for navigating bookmarks with arrow keys
const isArrowUp = event.key === "ArrowUp";
const isArrowDown = event.key === "ArrowDown";
if (isArrowUp || isArrowDown) {
event.preventDefault();
// Detect current bookmark list item
const items = [...document.querySelectorAll("ul.bookmark-list > li")];
const path = event.composedPath();
const currentItem = path.find((item) => items.includes(item));
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = items[0];
}
// Focus first link
if (nextItem) {
nextItem.querySelector("a").focus();
}
}
// Handle shortcut for toggling all notes
if (event.key === "e") {
const list = document.querySelector(".bookmark-list");
if (list) {
list.classList.toggle("show-notes");
}
}
// Handle shortcut for focusing search input
if (event.key === "s") {
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
}
// Handle shortcut for adding new bookmark
if (event.key === "n") {
window.location.assign("/bookmarks/new");
}
});

View File

@@ -0,0 +1,82 @@
import { LitElement } from "lit";
/**
* Base class for custom elements that wrap existing server-rendered DOM.
*
* Handles timing issues where connectedCallback fires before child elements
* are parsed during initial page load. With Turbo navigation, children are
* always available, but on fresh page loads they may not be.
*
* Subclasses should override init() instead of connectedCallback().
*/
export class HeadlessElement extends HTMLElement {
connectedCallback() {
if (this.__initialized) {
return;
}
this.__initialized = true;
if (document.readyState === "loading") {
document.addEventListener("turbo:load", () => this.init(), {
once: true,
});
} else {
this.init();
}
}
init() {
// Override in subclass
}
}
let isTopFrameVisit = false;
document.addEventListener("turbo:visit", (event) => {
const url = event.detail.url;
isTopFrameVisit =
document.querySelector(`turbo-frame[src="${url}"][target="_top"]`) !== null;
});
document.addEventListener("turbo:render", () => {
isTopFrameVisit = false;
});
document.addEventListener("turbo:before-morph-element", (event) => {
const parent = event.target?.parentElement;
if (parent instanceof TurboLitElement) {
// Prevent Turbo from morphing Lit elements contents, which would remove
// elements rendered on the client side.
event.preventDefault();
}
});
export class TurboLitElement extends LitElement {
constructor() {
super();
this.__prepareForCache = this.__prepareForCache.bind(this);
}
createRenderRoot() {
return this; // Render to light DOM
}
connectedCallback() {
document.addEventListener("turbo:before-cache", this.__prepareForCache);
super.connectedCallback();
}
disconnectedCallback() {
document.removeEventListener("turbo:before-cache", this.__prepareForCache);
super.disconnectedCallback();
}
__prepareForCache() {
// Remove rendered contents before caching, otherwise restoring the DOM from
// cache will result in duplicated contents. Turbo also fires before-cache
// when rendering a frame that does target the top frame, in which case we
// want to keep the contents.
if (!isTopFrameVisit) {
this.innerHTML = "";
}
}
}

View File

@@ -9,13 +9,6 @@ export function debounce(callback, delay = 250) {
};
}
export function preventDefault(fn) {
return function (event) {
event.preventDefault();
fn.call(this, event);
};
}
export function clampText(text, maxChars = 30) {
if (!text || text.length <= 30) return text;

View File

@@ -0,0 +1,71 @@
import {
arrow,
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
export class PositionController {
constructor(options) {
this.anchor = options.anchor;
this.overlay = options.overlay;
this.arrow = options.arrow;
this.placement = options.placement || "bottom";
this.offset = options.offset;
this.autoWidth = options.autoWidth || false;
this.autoUpdateCleanup = null;
}
enable() {
if (!this.autoUpdateCleanup) {
this.autoUpdateCleanup = autoUpdate(this.anchor, this.overlay, () =>
this.updatePosition(),
);
}
}
disable() {
if (this.autoUpdateCleanup) {
this.autoUpdateCleanup();
this.autoUpdateCleanup = null;
}
}
updatePosition() {
const middleware = [flip(), shift()];
if (this.arrow) {
middleware.push(arrow({ element: this.arrow }));
}
if (this.offset) {
middleware.push(offset(this.offset));
}
computePosition(this.anchor, this.overlay, {
placement: this.placement,
strategy: "fixed",
middleware,
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.overlay.style, {
left: `${x}px`,
top: `${y}px`,
});
this.overlay.classList.remove("top-aligned", "bottom-aligned");
this.overlay.classList.add(`${placement}-aligned`);
if (this.arrow) {
const { x, y } = middlewareData.arrow;
Object.assign(this.arrow.style, {
left: x != null ? `${x}px` : "",
top: y != null ? `${y}px` : "",
});
}
});
if (this.autoWidth) {
const width = this.anchor.offsetWidth;
this.overlay.style.width = `${width}px`;
}
}
}

View File

@@ -1,6 +1,6 @@
import { api } from "./api.js";
import { api } from "../api.js";
class Cache {
class TagCache {
constructor(api) {
this.api = api;
@@ -32,4 +32,4 @@ class Cache {
}
}
export const cache = new Cache(api);
export const cache = new TagCache(api);

View File

@@ -1,5 +1,5 @@
import sqlite3
import os
import sqlite3
from django.core.management.base import BaseCommand
@@ -14,7 +14,7 @@ class Command(BaseCommand):
destination = options["destination"]
def progress(status, remaining, total):
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
self.stdout.write(f"Copied {total - remaining} of {total} pages...")
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
backup_db = sqlite3.connect(destination)

View File

@@ -1,8 +1,8 @@
import os
import logging
import os
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,5 +1,5 @@
import sqlite3
import os
import sqlite3
import tempfile
import zipfile
@@ -65,7 +65,7 @@ class Command(BaseCommand):
def backup_database(self, backup_db_file):
def progress(status, remaining, total):
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
self.stdout.write(f"Copied {total - remaining} of {total} pages...")
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
backup_db = sqlite3.connect(backup_db_file)

View File

@@ -4,7 +4,6 @@ import os
from django.core.management.base import BaseCommand
from django.core.management.utils import get_random_secret_key
logger = logging.getLogger(__name__)
@@ -15,10 +14,10 @@ class Command(BaseCommand):
secret_key_file = os.path.join("data", "secretkey.txt")
if os.path.exists(secret_key_file):
logger.info(f"Secret key file already exists")
logger.info("Secret key file already exists")
return
secret_key = get_random_secret_key()
with open(secret_key_file, "w") as f:
f.write(secret_key)
logger.info(f"Generated secret key file")
logger.info("Generated secret key file")

View File

@@ -1,7 +1,7 @@
import importlib
import json
import os
import sqlite3
import importlib
from django.core.management.base import BaseCommand

View File

@@ -1,7 +1,7 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.models import GlobalSettings, UserProfile
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
@@ -22,7 +22,7 @@ class LinkdingMiddleware:
# add global settings to request
try:
global_settings = GlobalSettings.get()
except:
except Exception:
global_settings = default_global_settings
request.global_settings = global_settings

View File

@@ -1,12 +1,11 @@
# Generated by Django 2.2.2 on 2019-06-28 23:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@@ -1,12 +1,11 @@
# Generated by Django 2.2.2 on 2019-06-29 23:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("bookmarks", "0001_initial"),

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0002_auto_20190629_2303"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0003_auto_20200913_0656"),
]

View File

@@ -1,11 +1,11 @@
# Generated by Django 2.2.13 on 2021-01-03 12:12
import bookmarks.validators
from django.db import migrations, models
import bookmarks.validators
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0004_auto_20200926_1028"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0005_auto_20210103_1212"),
]

View File

@@ -1,8 +1,8 @@
# Generated by Django 2.2.18 on 2021-03-26 22:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0007_userprofile"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0008_userprofile_bookmark_date_display"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0010_userprofile_bookmark_link_target"),
]

View File

@@ -1,12 +1,11 @@
# Generated by Django 3.2.6 on 2022-01-08 19:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("bookmarks", "0011_userprofile_web_archive_integration"),

View File

@@ -1,7 +1,7 @@
# Generated by Django 3.2.6 on 2022-01-08 19:27
from django.db import migrations
from django.contrib.auth import get_user_model
from django.db import migrations
from bookmarks.models import Toast

View File

@@ -1,12 +1,11 @@
# Generated by Django 3.2.13 on 2022-07-23 20:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("bookmarks", "0014_alter_bookmark_unread"),

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0015_feedtoken"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0016_bookmark_shared"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0017_userprofile_enable_sharing"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0018_bookmark_favicon_file"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0019_userprofile_enable_favicons"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0020_userprofile_tag_search"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0021_userprofile_display_url"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0022_bookmark_notes"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0023_userprofile_permanent_notes"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0024_userprofile_enable_public_sharing"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0025_userprofile_search_preferences"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0026_userprofile_custom_css"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
]

View File

@@ -1,7 +1,7 @@
# Generated by Django 5.0.2 on 2024-03-29 21:25
from django.db import migrations
from django.contrib.auth import get_user_model
from django.db import migrations
from bookmarks.models import Toast
@@ -9,7 +9,6 @@ User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="bookmark_list_actions_hint",
@@ -24,7 +23,6 @@ def reverse(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
]

View File

@@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0029_bookmark_list_actions_toast"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0030_bookmarkasset"),
]

View File

@@ -1,7 +1,7 @@
# Generated by Django 5.0.2 on 2024-04-01 12:17
from django.db import migrations
from django.contrib.auth import get_user_model
from django.db import migrations
from bookmarks.models import Toast
@@ -9,7 +9,6 @@ User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="html_snapshots_hint",
@@ -24,7 +23,6 @@ def reverse(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0032_html_snapshots_hint_toast"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0033_userprofile_default_mark_unread"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0035_userprofile_tag_grouping"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0036_userprofile_auto_tagging_rules"),
]

View File

@@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0037_globalsettings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0038_globalsettings_guest_profile_user"),
]

View File

@@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
]

View File

@@ -10,9 +10,9 @@ from bookmarks.models import Bookmark
def forwards(apps, schema_editor):
Bookmark.objects.filter(
Q(title__isnull=True) | Q(title__exact=""),
).extra(
where=["website_title IS NOT NULL"]
).update(title=RawSQL("website_title", ()))
).extra(where=["website_title IS NOT NULL"]).update(
title=RawSQL("website_title", ())
)
Bookmark.objects.filter(
Q(description__isnull=True) | Q(description__exact=""),
@@ -26,7 +26,6 @@ def reverse(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0040_userprofile_items_per_page_and_more"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0041_merge_metadata"),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0042_userprofile_custom_css_hash"),
]

View File

@@ -25,7 +25,6 @@ def reverse(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0043_userprofile_collapse_side_panel"),
]

View File

@@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0044_bookmark_latest_snapshot"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
]

View File

@@ -1,6 +1,7 @@
# Generated by Django 5.2.3 on 2025-08-22 08:28
from django.db import migrations, transaction
from bookmarks.utils import normalize_url
@@ -25,7 +26,6 @@ def reverse_populate_url_normalized(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0046_add_url_normalized_field"),
]

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