Compare commits

...

116 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
Sascha Ißbrücker
492de5618c Bump version 2025-12-13 10:33:32 +01:00
Sascha Ißbrücker
c349ad7670 Use sandbox CSP for viewing assets (#1245) 2025-12-13 10:32:06 +01:00
Simon
1c17e16655 Bump supervisor to 4.3.0 to fix warning (#1216)
Co-authored-by: simonhammes <simonhammes@users.noreply.github.com>
2025-12-13 10:07:32 +01:00
Devinside
9b70bc3b55 Add Komrade project to community resources (#1236) 2025-12-13 09:54:00 +01:00
vbsampath
beba4f8b93 Add Javascript API to community resources (#1195)
* Added Javascript client and library for Linkding REST API

* Cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-12-13 09:53:16 +01:00
Sascha Ißbrücker
bb7af56dc1 Bump docs dependencies 2025-12-13 09:37:51 +01:00
dependabot[bot]
e89fecbd10 Bump astro from 5.13.2 to 5.14.4 in /docs (#1201)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.13.2 to 5.14.4.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.14.4/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.14.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 07:08:12 +02:00
Sascha Ißbrücker
70734ed273 Fix tag cloud highlighting first char when tags are not grouped (#1209)
* Fix tag cloud highlighting first char when tags are not grouped

* update test
2025-10-18 07:05:15 +02:00
m3e
dcb15f1942 Fix devcontainer (#1208)
* Update Python version to 3.13 in devcontainer

* Update `postCreateCommand` to install and use uv

* Update DevContainers paragraph in README with uv commands

* Update commands

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-10-18 06:24:31 +02:00
Sascha Ißbrücker
3b6cdbdd84 Update CHANGELOG.md 2025-10-11 13:37:40 +02:00
Sascha Ißbrücker
344420ec4a Bump version 2025-10-11 11:14:37 +02:00
Sascha Ißbrücker
eb99ece360 Attempt to fix botched normalized URL migration from 1.43.0 (#1205) 2025-10-11 11:12:27 +02:00
Sascha Ißbrücker
95529eccd4 Check for dupes by exact URL if normalized URL is missing (#1204) 2025-10-11 10:45:23 +02:00
Sascha Ißbrücker
a6b36750da Fix missing tags causing errors in import with Postgres (#1203)
* Handle missing tags in importer

* Make all tests run with Postgres again
2025-10-11 10:32:31 +02:00
Sascha Ißbrücker
8b98a335d4 Fix normalized URL not being generated in bookmark import (#1202) 2025-10-11 09:57:14 +02:00
Sascha Ißbrücker
6ac8ce6a7b Publish search guide 2025-10-05 20:16:07 +02:00
Sascha Ißbrücker
a9f135552a Update CHANGELOG.md 2025-10-05 15:43:13 +02:00
Sascha Ißbrücker
f110eb35fe Bump version 2025-10-05 13:20:54 +02:00
Sascha Ißbrücker
051bd39256 Add new search engine that supports logical expressions (and, or, not) (#1198)
* parser implementation

* add support for quoted strings

* add support for tags

* ignore empty tags

* implicit and

* prepare query conversion by disabling tests

* convert query logic

* fix nested combined tag searches

* simplify query logic

* Add special keyword support to parser

* Add special keyword support to query builder

* Handle invalid queries in query builder

* Notify user about invalid queries

* Add helper to strip tags from search query

* Make tag cloud show all tags from search query

* Use new method for extracting tags

* Add query for getting tags from search query

* Get selected tags through specific context

* Properly remove selected tags from complex queries

* cleanup

* Clarify bundle search terms

* Add documentation draft

* Improve adding tags to search query

* Add option to switch back to the old search
2025-10-05 12:51:08 +02:00
Sascha Ißbrücker
229d3b511f Fix error button icon color 2025-10-04 02:56:21 +02:00
Sascha Ißbrücker
b9d6d91a91 Fix bundle preview pagination resetting to first page (#1194) 2025-10-03 10:12:50 +02:00
Dunlor
a7a4dd5fff Fix queued tasks link when context path is used (#1187)
* Fix queued tasks link when LD_CONTEXT_PATH is set

* cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-10-03 10:11:42 +02:00
dependabot[bot]
ecb34d2aea Bump tar-fs from 3.0.9 to 3.1.1 in /docs (#1190)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 3.0.9 to 3.1.1.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v3.0.9...v3.1.1)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 3.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-03 10:07:54 +02:00
dependabot[bot]
5495565fbd Bump devalue from 5.1.1 to 5.3.2 in /docs (#1192)
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.1.1 to 5.3.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.1.1...v5.3.2)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-03 10:07:38 +02:00
Dunlor
0c18b83a8e Fix pagination links to use relative URLs (#1186) 2025-10-03 09:54:50 +02:00
Sascha Ißbrücker
128e1afbce Fix ublock setup 2025-09-28 10:38:56 +02:00
Sascha Ißbrücker
d33719dc7c Bump version 2025-09-28 09:22:00 +02:00
dependabot[bot]
357c2d1399 Bump vite from 6.3.5 to 6.3.6 in /docs (#1184)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 19:36:31 +02:00
Sascha Ißbrücker
9cda5a54d3 Add href parsing test 2025-08-27 08:45:20 +02:00
Sascha Ißbrücker
67d5b17450 Fix filter background in dark theme 2025-08-27 08:31:49 +02:00
Sascha Ißbrücker
3ec6c0a7f8 Hide tag menu for unauthenticated users (#1176) 2025-08-26 19:06:04 +02:00
Sascha Ißbrücker
86c2bdd138 Update test build 2025-08-26 12:14:30 +02:00
Sascha Ißbrücker
82e5b7d9d5 Add basic tag management (#1175) 2025-08-26 12:01:36 +02:00
Sascha Ißbrücker
d873342105 Replace Svelte components with Lit elements (#1174) 2025-08-24 12:28:15 +02:00
Sascha Ißbrücker
d519cb74eb Bump versions (#1173)
* Bump versions

* Bump NPM versions, update to Svelte 5

* try improve flaky test

* bump single-file-cli, remove ublock origin workaround

* bump base images

* replace libssl3
2025-08-24 12:10:17 +02:00
Sascha Ißbrücker
ff0e6f0ff6 Add test environment 2025-08-24 09:31:17 +02:00
Sascha Ißbrücker
77c45c63f3 Add authelia OIDC test setup 2025-08-23 13:50:17 +02:00
Sascha Ißbrücker
e45e63bfb1 Fix psycopg install 2025-08-23 10:50:10 +02:00
Sascha Ißbrücker
004319adae Install uv via installer 2025-08-23 07:58:26 +02:00
Sascha Ißbrücker
d8358f1b12 Add preview build 2025-08-23 07:41:34 +02:00
Sascha Ißbrücker
b90ae1b202 Switch to uv (#1172) 2025-08-23 07:37:25 +02:00
Sascha Ißbrücker
6c874afff2 Add option to mark bookmarks as shared by default (#1170)
* Add option to mark bookmarks as shared by default

* add migration
2025-08-22 20:05:56 +02:00
Sascha Ißbrücker
723b843c13 Normalize URLs when checking for duplicates (#1169)
* Normalize URLs when checking for duplicates

* Improve migration script
2025-08-22 19:37:28 +02:00
Per Mortensen
96176ba50e Fix bookmark asset admin search error (#1162) 2025-08-22 10:03:20 +02:00
dependabot[bot]
f6fb46e8ad Bump astro from 5.12.8 to 5.13.2 in /docs (#1166)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.12.8 to 5.13.2.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.13.2/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.13.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 09:57:47 +02:00
Sascha Ißbrücker
3804640574 Use modal dialog for confirming actions (#1168)
* Use modal dialog for confirming actions

* cleanup unused state
2025-08-22 09:57:31 +02:00
FireFingers21
8f61fbd04a Add alfred-linkding-bookmarks to community.md (#1160) 2025-08-16 21:45:53 +02:00
Per Mortensen
22bc713ed8 Document API bundle filter (#1161) 2025-08-16 21:40:03 +02:00
Sascha Ißbrücker
04248a7fba Bump version 2025-08-16 07:31:30 +02:00
Sascha Ißbrücker
0ff36a94fe Add alternative bookmarklet that uses browser metadata (#1159) 2025-08-16 07:29:53 +02:00
Sascha Ißbrücker
f83eb25569 Submit bookmark form with Ctrl/Cmd + Enter (#1158) 2025-08-16 06:20:07 +02:00
thR CIrcU5
c746afcf76 Bulk create HTML snapshots (#1132)
* Add option to create HTML snapshot for bulk edit

* Add the prerequisite for displaying the "Create HTML Snapshot" bulk action option

* Add test case

This test case covers the scenario where the bulk actions panel displays the corresponding options when the HTML snapshot feature is enabled.

* Use the existing `tasks.create_html_snapshots()` instead of the for loop

* Fix the exposure of `settings.LD_ENABLE_SNAPSHOTS` within `BookmarkListContext`

* add service tests

* cleanup context

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-08-12 23:06:23 +02:00
Sascha Ißbrücker
aaa0f6e119 Run formatter 2025-08-11 08:05:50 +02:00
Sascha Ißbrücker
cd215a9237 Create bundle from current search query (#1154) 2025-08-10 22:45:28 +02:00
Sascha Ißbrücker
1e56b0e6f3 Ignore tags that exceed length limit during import (#1153) 2025-08-10 15:05:10 +02:00
Sascha Ißbrücker
5cc8c9c010 Allow filtering feeds by bundle (#1152) 2025-08-10 12:59:55 +02:00
Pedro Lima
846808d870 Ignore tags with just whitespace (#1125) 2025-08-10 10:20:03 +02:00
Sascha Ißbrücker
6d9a694756 Wrap long titles in bookmark details modal (#1150) 2025-08-10 10:05:46 +02:00
Per Mortensen
de38e56b3f Add linkding-media-archiver to community.md (#1144)
Adds a new project link to the community page
2025-08-10 09:11:42 +02:00
dependabot[bot]
c6fb695af2 Bump astro from 5.7.13 to 5.12.8 in /docs (#1147)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.7.13 to 5.12.8.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@5.12.8/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 5.12.8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-10 09:09:09 +02:00
Per Mortensen
93faf70b37 Use filename when downloading asset through UI (#1146) 2025-08-10 08:38:18 +02:00
hkclark
5330252db9 Add Pocket migration to to community page (#1112)
* Add Pocket migration to to community page

* Fix order

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-07-23 03:17:45 +02:00
Ben Oakes
ef00d289f5 Add CloudBreak on Managed Hosting (#1079)
* Add CloudBreak on Managed Hosting

* Use new path
2025-07-23 03:15:26 +02:00
Sascha Ißbrücker
4e8318d0ae Improve bookmark form accessibility (#1116)
* Bump Django

* Render error messages in English

* Remove unused USE_L10N option

* Associate errors and help texts with form fields

* Make checkbox inputs clickable

* Change cancel button text

* Fix tests
2025-07-03 08:44:41 +02:00
Sascha Ißbrücker
a8623d11ef Update order when deleting bundle (#1114) 2025-07-01 07:09:02 +02:00
Sascha Ißbrücker
8cd992ca30 Show bookmark bundles in admin (#1110) 2025-06-25 19:37:34 +02:00
Sascha Ißbrücker
68c104ba54 Fix custom CSS not being used in reader mode (#1102) 2025-06-20 06:22:08 +02:00
hkclark
7a4236d179 Automatically compress uploads with gzip (#1087)
* Gzip .html upload, tests for .html & .gz uploads

* Gzip all file types that aren't already gzips

* Show filename of what user uploaded before compression

* Remove line I thought we need but we don't

* cleanup and fix tests

---------

Co-authored-by: kclark <kclark@autoverify.net>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-06-20 06:15:25 +02:00
Sascha Ißbrücker
e87304501f Add date and time to HTML export filename (#1101) 2025-06-20 06:01:15 +02:00
Sascha Ißbrücker
809e9e02f3 Update CHANGELOG.md 2025-06-20 00:38:18 +02:00
362 changed files with 16758 additions and 9351 deletions

View File

@@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"image": "mcr.microsoft.com/devcontainers/python:3.13",
"features": {
"ghcr.io/devcontainers/features/node:1": {}
},
@@ -14,7 +14,7 @@
"forwardPorts": [8000],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
"postCreateCommand": "pip install uv && uv sync --group dev && npm install && mkdir -p data && uv run manage.py migrate",
// Configure tool-specific properties.
"customizations": {

View File

@@ -10,10 +10,11 @@
!/package.json
!/package-lock.json
!/postcss.config.js
!/requirements.dev.txt
!/requirements.txt
!/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=

81
.github/workflows/build-test.yaml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: build-test
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build latest
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
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
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
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
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
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
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
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

@@ -15,7 +15,9 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Node
uses: actions/setup-node@v4
with:
@@ -25,10 +27,10 @@ jobs:
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt
uv sync
mkdir data
- name: Run tests
run: python manage.py test bookmarks.tests
run: uv run pytest -n auto
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
@@ -37,7 +39,9 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Node
uses: actions/setup-node@v4
with:
@@ -47,12 +51,18 @@ jobs:
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt
playwright install chromium
uv sync
uv run playwright install chromium
mkdir data
- name: Run build
run: |
npm run build
python manage.py collectstatic
uv run manage.py collectstatic
- name: Run tests
run: python 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

5
.gitignore vendored
View File

@@ -60,6 +60,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
test-results/
# Translations
*.mo
@@ -196,3 +197,7 @@ typings/
/chromium-profile
# direnv
/.direnv
# Test setups
/scripts/unsecure-test-setups/authelia-oidc/authelia/db.sqlite3
/scripts/unsecure-test-setups/authelia-oidc/traefik/certs

View File

@@ -1,5 +1,164 @@
# 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
* Fix normalized URL not being generated in bookmark import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1202
* Fix missing tags causing errors in import with Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1203
* Check for dupes by exact URL if normalized URL is missing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1204
* Attempt to fix botched normalized URL migration from 1.43.0 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1205
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.0...v1.44.1
---
## v1.44.0 (05/10/2025)
### What's Changed
* Add new search engine that supports logical expressions (and, or, not) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1198
* Fix pagination links to use relative URLs by @dunlor in https://github.com/sissbruecker/linkding/pull/1186
* Fix queued tasks link when context path is used by @dunlor in https://github.com/sissbruecker/linkding/pull/1187
* Fix bundle preview pagination resetting to first page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1194
### New Contributors
* @dunlor made their first contribution in https://github.com/sissbruecker/linkding/pull/1186
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.43.0...v1.44.0
---
## v1.43.0 (28/09/2025)
### What's Changed
* Add basic tag management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1175
* Normalize URLs when checking for duplicates by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1169
* Add option to mark bookmarks as shared by default by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1170
* Use modal dialog for confirming actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1168
* Fix error when filtering bookmark assets in the admin UI by @proog in https://github.com/sissbruecker/linkding/pull/1162
* Document API bundle filter by @proog in https://github.com/sissbruecker/linkding/pull/1161
* Add alfred-linkding-bookmarks to community.md by @FireFingers21 in https://github.com/sissbruecker/linkding/pull/1160
* Switch to uv by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1172
* Replace Svelte components with Lit elements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1174
* Bump versions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1173
* Bump astro from 5.12.8 to 5.13.2 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1166
* Bump vite from 6.3.5 to 6.3.6 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1184
### New Contributors
* @FireFingers21 made their first contribution in https://github.com/sissbruecker/linkding/pull/1160
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.42.0...v1.43.0
---
## v1.42.0 (16/08/2025)
### What's Changed
* Bulk create HTML snapshots by @Tql-ws1 in https://github.com/sissbruecker/linkding/pull/1132
* Create bundle from current search query by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1154
* Add alternative bookmarklet that uses browser metadata by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1159
* Add date and time to HTML export filename by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1101
* Automatically compress uploads with gzip by @hkclark in https://github.com/sissbruecker/linkding/pull/1087
* Show bookmark bundles in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1110
* Allow filtering feeds by bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1152
* Submit bookmark form with Ctrl/Cmd + Enter by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1158
* Improve bookmark form accessibility by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1116
* Fix custom CSS not being used in reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1102
* Use filename when downloading asset through UI by @proog in https://github.com/sissbruecker/linkding/pull/1146
* Update order when deleting bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1114
* Wrap long titles in bookmark details modal by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1150
* Ignore tags with just whitespace by @pvl in https://github.com/sissbruecker/linkding/pull/1125
* Ignore tags that exceed length limit during import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1153
* Add CloudBreak on Managed Hosting by @benjaminoakes in https://github.com/sissbruecker/linkding/pull/1079
* Add Pocket migration to to community page by @hkclark in https://github.com/sissbruecker/linkding/pull/1112
* Add linkding-media-archiver to community.md by @proog in https://github.com/sissbruecker/linkding/pull/1144
* Bump astro from 5.7.13 to 5.12.8 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1147
### New Contributors
* @hkclark made their first contribution in https://github.com/sissbruecker/linkding/pull/1087
* @benjaminoakes made their first contribution in https://github.com/sissbruecker/linkding/pull/1079
* @proog made their first contribution in https://github.com/sissbruecker/linkding/pull/1146
* @pvl made their first contribution in https://github.com/sissbruecker/linkding/pull/1125
* @Tql-ws1 made their first contribution in https://github.com/sissbruecker/linkding/pull/1132
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.41.0...v1.42.0
---
## v1.41.0 (19/06/2025)
### What's Changed
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
### New Contributors
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
---
## v1.40.0 (17/05/2025)
### What's Changed

View File

@@ -1,15 +1,38 @@
.PHONY: serve
init:
uv sync
[ -d data ] || mkdir data data/assets data/favicons data/previews
uv run manage.py migrate
npm install
serve:
python manage.py runserver
uv run manage.py runserver
tasks:
python manage.py run_huey
uv run manage.py run_huey
test:
pytest -n auto
uv run pytest -n auto
lint:
uv run ruff check bookmarks
format:
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

View File

@@ -61,43 +61,31 @@ Small improvements, bugfixes and documentation improvements are always welcome.
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3.12
- Python 3.13
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Node.js
### Setup
Create a virtual environment for the application (https://docs.python.org/3/tutorial/venv.html):
Initialize the development environment with:
```
python3 -m venv ~/environments/linkding
```
Activate the environment for your shell:
```
source ~/environments/linkding/bin/activate[.csh|.fish]
```
Within the active environment install the application dependencies from the application folder:
```
pip3 install -r requirements.txt -r requirements.dev.txt
```
Install frontend dependencies:
```
npm install
```
Initialize database:
```
mkdir -p data
python3 manage.py migrate
make init
```
This sets up a virtual environment using uv, installs NPM dependencies and runs migrations to create the initial database.
Create a user for the frontend:
```
python3 manage.py createsuperuser --username=joe --email=joe@example.com
uv run manage.py createsuperuser --username=joe --email=joe@example.com
```
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
Run the frontend build for bundling frontend components with:
```
npm run dev
make frontend
```
Start the Django development server with:
Then start the Django development server with:
```
python3 manage.py runserver
make serve
```
The frontend is now available under http://localhost:8000
@@ -108,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
```
@@ -123,14 +119,14 @@ Once checked out, only the following commands are required to get started:
Create a user for the frontend:
```
python3 manage.py createsuperuser --username=joe --email=joe@example.com
uv run manage.py createsuperuser --username=joe --email=joe@example.com
```
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
```
npm run dev
make frontend
```
Start the Django development server with:
```
python3 manage.py runserver
make serve
```
The frontend is now available under http://localhost:8000

View File

@@ -1,3 +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
@@ -6,12 +9,19 @@ 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 Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
from bookmarks.models import (
ApiToken,
Bookmark,
BookmarkAsset,
BookmarkBundle,
FeedToken,
Tag,
Toast,
UserProfile,
)
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -36,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]
@@ -75,6 +87,7 @@ class LinkdingAdminSite(AdminSite):
def get_app_list(self, request, app_label=None):
app_list = super().get_app_list(request, app_label)
context_path = os.getenv("LD_CONTEXT_PATH", "")
app_list += [
{
"name": "Huey",
@@ -83,7 +96,7 @@ class LinkdingAdminSite(AdminSite):
{
"name": "Queued tasks",
"object_name": "background_tasks",
"admin_url": "/admin/tasks/",
"admin_url": f"/{context_path}admin/tasks/",
"view_only": True,
}
],
@@ -206,7 +219,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("custom_display_name", "date_created", "status")
search_fields = (
"custom_display_name",
"display_name",
"file",
)
list_filter = ("status",)
@@ -256,6 +269,21 @@ class AdminTag(admin.ModelAdmin):
)
class AdminBookmarkBundle(admin.ModelAdmin):
list_display = (
"name",
"owner",
"order",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"date_created",
)
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
list_filter = ("owner__username",)
class AdminUserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
@@ -270,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):
@@ -285,11 +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, 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
@@ -107,7 +107,7 @@ class BookmarkViewSet(
def check(self, request: HttpRequest):
url = request.GET.get("url")
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
bookmark = Bookmark.query_existing(request.user, url).first()
existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None
)
@@ -151,7 +151,7 @@ class BookmarkViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
bookmark = Bookmark.query_existing(request.user, url).first()
if not bookmark:
bookmark = Bookmark(url=url)
@@ -197,18 +197,15 @@ class BookmarkAssetViewSet(
file_stream = (
gzip.GzipFile(file_path, mode="rb")
if asset.gzip
else open(file_path, "rb")
)
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
else open(file_path, "rb") # noqa: SIM115
)
response = StreamingHttpResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
response["Content-Disposition"] = (
f'attachment; filename="{asset.download_name}"'
)
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}",
@@ -290,6 +287,9 @@ class BookmarkBundleViewSet(
def get_serializer_context(self):
return {"user": self.request.user}
def perform_destroy(self, instance):
bundles.delete_bundle(instance)
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
# Instead create separate routers for each view set and manually register them in urls.py

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,12 +6,12 @@ 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
from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version
@@ -55,17 +55,9 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
]
def create(self, validated_data):
# Set owner to the authenticated user
validated_data["owner"] = self.context["user"]
# Set order to the next available position if not provided
if "order" not in validated_data:
max_order = BookmarkBundle.objects.filter(
owner=self.context["user"]
).aggregate(Max("order", default=-1))["order__max"]
validated_data["order"] = max_order + 1
return super().create(validated_data)
bundle = BookmarkBundle(**validated_data)
bundle.order = validated_data.get("order", None)
return bundles.create_bundle(bundle, self.context["user"])
class BookmarkSerializer(serializers.ModelSerializer):
@@ -94,8 +86,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
"favicon_url",
"preview_image_url",
"tag_names",
"date_added",
"date_modified",
"website_title",
"website_description",
]
@@ -110,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

@@ -8,6 +8,7 @@ from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
from bookmarks.views import access
@dataclass
@@ -30,10 +31,16 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
bundle = None
bundle_id = request.GET.get("bundle")
if bundle_id:
bundle = access.bundle_read(request, bundle_id)
search = BookmarkSearch(
q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""),
bundle=bundle,
)
query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set)
@@ -43,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,22 +1,45 @@
from django import forms
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from bookmarks.models import Bookmark, build_tag_string
from bookmarks.validators import BookmarkURLValidator
from bookmarks.type_defs import HttpRequest
from bookmarks.models import (
Bookmark,
BookmarkBundle,
BookmarkSearch,
GlobalSettings,
Tag,
UserProfile,
build_tag_string,
parse_tag_string,
sanitize_tag_name,
)
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.type_defs import HttpRequest
from bookmarks.validators import BookmarkURLValidator
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
@@ -44,11 +67,14 @@ class BookmarkForm(forms.ModelForm):
"tag_string": request.GET.get("tags"),
"auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread,
"shared": request.user_profile.default_mark_shared,
}
if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None
super().__init__(data, instance=instance, initial=initial)
super().__init__(
data, instance=instance, initial=initial, error_class=FormErrorList
)
@property
def is_auto_close(self):
@@ -79,7 +105,7 @@ class BookmarkForm(forms.ModelForm):
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
Bookmark.query_existing(self.instance.owner, url)
.exclude(pk=self.instance.pk)
.exists()
)
@@ -93,3 +119,247 @@ def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings
return tag_string.replace(" ", ",")
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=FormErrorList)
self.user = user
def clean_name(self):
name = self.cleaned_data.get("name", "").strip()
name = sanitize_tag_name(name)
queryset = Tag.objects.filter(name__iexact=name, owner=self.user)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise forms.ValidationError(f'Tag "{name}" already exists.')
return name
def save(self, commit=True):
tag = super().save(commit=False)
if not self.instance.pk:
tag.owner = self.user
tag.date_added = timezone.now()
else:
tag.date_modified = timezone.now()
if commit:
tag.save()
return tag
class TagMergeForm(forms.Form):
target_tag = forms.CharField(widget=TagAutocomplete)
merge_tags = forms.CharField(widget=TagAutocomplete)
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=FormErrorList)
self.user = user
def clean_target_tag(self):
target_tag_name = self.cleaned_data.get("target_tag", "")
target_tag_names = parse_tag_string(target_tag_name, " ")
if len(target_tag_names) != 1:
raise forms.ValidationError(
"Please enter only one tag name for the target tag."
)
target_tag_name = target_tag_names[0]
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.'
) from None
return target_tag
def clean_merge_tags(self):
merge_tags_string = self.cleaned_data.get("merge_tags", "")
merge_tag_names = parse_tag_string(merge_tags_string, " ")
if not merge_tag_names:
raise forms.ValidationError("Please enter at least one tag to merge.")
merge_tags = []
for tag_name in merge_tag_names:
try:
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.'
) from None
target_tag = self.cleaned_data.get("target_tag")
if target_tag and target_tag in merge_tags:
raise forms.ValidationError(
"The target tag cannot be selected for merging."
)
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,79 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class ConfirmButtonBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.reset();
this.element.removeEventListener("click", this.onClick);
}
onClick(event) {
event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.element.getAttribute("ld-confirm-icon");
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
}
const question = this.element.getAttribute("ld-confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn"))
.join(" ");
const cancelButton = document.createElement(this.element.nodeName);
cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.type;
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
container.append(cancelButton, confirmButton);
this.container = container;
this.element.before(container);
this.element.classList.add("d-none");
}
reset() {
setTimeout(() => {
Behavior.interacting = false;
if (this.container) {
this.container.remove();
this.container = null;
}
this.element.classList.remove("d-none");
});
}
}
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="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,55 +0,0 @@
import { Behavior, registerBehavior } from "./index";
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();
}
}
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-auto-submit", AutoSubmitBehavior);
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,121 +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() {}
}
Behavior.interacting = false;
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,91 +0,0 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
this.element.remove();
this.clearInert();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

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

View File

@@ -1,36 +0,0 @@
import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
class TagAutocomplete extends Behavior {
constructor(element) {
super(element);
const input = element.querySelector("input");
if (!input) {
console.warn("TagAutocomplete: input element not found");
return;
}
const container = document.createElement("div");
new TagAutoCompleteComponent({
target: container,
props: {
id: input.id,
name: input.name,
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
variant: input.getAttribute("variant"),
},
});
this.input = input;
this.autocomplete = container.firstElementChild;
input.replaceWith(this.autocomplete);
}
destroy() {
this.autocomplete.replaceWith(this.input);
}
}
registerBehavior("ld-tag-autocomplete", TagAutocomplete);

View File

@@ -1,262 +0,0 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let mode = '';
export let search;
export let linkTarget = '_blank';
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tag => ({
type: 'tag',
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name
}))
}
// Recent search suggestions
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionSearch = {
...search,
q: value
}
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...recentSearches,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, linkTarget)
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.recentSearches.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.recentSearches as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View File

@@ -1,169 +0,0 @@
<script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let placeholder;
export let variant = 'default';
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
async function handleInput(e) {
input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
{tag.name}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
box-sizing: border-box;
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
}
.form-autocomplete-input input {
width: 100%;
height: 100%;
border: none;
margin: 0;
}
.form-autocomplete.small .form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete.small .form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.form-autocomplete.small .menu .menu-item {
font-size: var(--font-size-sm);
}
</style>

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

@@ -0,0 +1,78 @@
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.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", this.onClose);
});
this.addEventListener("keydown", this.onKeyDown);
this.setupScrollLock();
this.focusTrap = new FocusTrapController(
this.querySelector(".modal-container"),
);
}
disconnectedCallback() {
this.removeScrollLock();
this.focusTrap.destroy();
}
setupScrollLock() {
document.body.classList.add("scroll-lock");
}
removeScrollLock() {
document.body.classList.remove("scroll-lock");
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.classList.add("closing");
this.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
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

@@ -0,0 +1,320 @@
import { html } from "lit";
import { api } from "../api.js";
import { TurboLitElement } from "../utils/element.js";
import {
clampText,
debounce,
getCurrentWord,
getCurrentWordBounds,
} 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 TurboLitElement {
static properties = {
inputName: { type: String, attribute: "input-name" },
inputPlaceholder: { type: String, attribute: "input-placeholder" },
inputValue: { type: String, attribute: "input-value" },
mode: { type: String },
user: { type: String },
shared: { type: String },
unread: { type: String },
target: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.inputName = "";
this.inputPlaceholder = "";
this.inputValue = "";
this.mode = "";
this.target = "_blank";
this.isFocus = false;
this.isOpen = false;
this.suggestions = {
recentSearches: [],
bookmarks: [],
tags: [],
total: [],
};
this.selectedIndex = undefined;
this.input = null;
this.menu = null;
this.searchHistory = new SearchHistory();
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
}
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() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
handleInput(e) {
this.inputValue = e.target.value;
this.debouncedLoadSuggestions();
}
handleKeyDown(e) {
// Enter
if (
this.isOpen &&
this.selectedIndex !== undefined &&
(e.keyCode === 13 || e.keyCode === 9)
) {
const suggestion = this.suggestions.total[this.selectedIndex];
if (suggestion) this.completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
this.close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
this.updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!this.isOpen) {
this.loadSuggestions();
} else {
this.updateSelection(1);
}
e.preventDefault();
}
}
open() {
this.isOpen = true;
this.positionController.enable();
}
close() {
this.isOpen = false;
this.updateSuggestions();
this.selectedIndex = undefined;
this.positionController.disable();
}
hasSuggestions() {
return this.suggestions.total.length > 0;
}
async loadSuggestions() {
let suggestionIndex = 0;
function nextIndex() {
return suggestionIndex++;
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = [];
const currentWord = getCurrentWord(this.input);
if (currentWord && currentWord.length > 1 && currentWord[0] === "#") {
const searchTag = currentWord.substring(1, currentWord.length);
tagSuggestions = (tags || [])
.filter(
(tag) =>
tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,
)
.slice(0, 5)
.map((tag) => ({
type: "tag",
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name,
}));
}
// Recent search suggestions
const recentSearches = this.searchHistory
.getRecentSearches(this.inputValue, 5)
.map((value) => ({
type: "search",
index: nextIndex(),
label: value,
value,
}));
// Bookmark suggestions
let bookmarks = [];
if (this.inputValue && this.inputValue.length >= 3) {
const path = this.mode ? `/${this.mode}` : "";
const suggestionSearch = {
user: this.user,
shared: this.shared,
unread: this.unread,
q: this.inputValue,
};
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
limit: 5,
offset: 0,
path,
});
bookmarks = fetchedBookmarks.map((bookmark) => {
const fullLabel = bookmark.title || bookmark.url;
const label = clampText(fullLabel, 60);
return {
type: "bookmark",
index: nextIndex(),
label,
bookmark,
};
});
}
this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);
if (this.hasSuggestions()) {
this.open();
} else {
this.close();
}
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || [];
bookmarks = bookmarks || [];
tagSuggestions = tagSuggestions || [];
this.suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [...tagSuggestions, ...recentSearches, ...bookmarks],
};
}
completeSuggestion(suggestion) {
if (suggestion.type === "search") {
this.inputValue = suggestion.value;
this.close();
}
if (suggestion.type === "bookmark") {
window.open(suggestion.bookmark.url, this.target);
this.close();
}
if (suggestion.type === "tag") {
const bounds = getCurrentWordBounds(this.input);
const inputValue = this.input.value;
this.input.value =
inputValue.substring(0, bounds.start) +
`#${suggestion.tagName} ` +
inputValue.substring(bounds.end);
this.close();
}
}
updateSelection(dir) {
const length = this.suggestions.total.length;
if (length === 0) return;
if (this.selectedIndex === undefined) {
this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);
return;
}
let newIndex = this.selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
this.selectedIndex = newIndex;
}
renderSuggestions(suggestions, title) {
if (suggestions.length === 0) return "";
return html`
<li class="menu-item group-item">${title}</li>
${suggestions.map(
(suggestion) => html`
<li
class="menu-item ${this.selectedIndex === suggestion.index
? "selected"
: ""}"
>
<a
href="#"
@mousedown=${(e) => {
e.preventDefault();
this.completeSuggestion(suggestion);
}}
>
${suggestion.label}
</a>
</li>
`,
)}
`;
}
render() {
return html`
<div class="form-autocomplete">
<div
class="form-autocomplete-input form-input ${this.isFocus
? "is-focused"
: ""}"
>
<input
type="search"
class="form-input"
name="${this.inputName}"
placeholder="${this.inputPlaceholder}"
autocomplete="off"
.value="${this.inputValue}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
</div>
<ul class="menu ${this.isOpen ? "open" : ""}">
${this.renderSuggestions(this.suggestions.tags, "Tags")}
${this.renderSuggestions(
this.suggestions.recentSearches,
"Recent Searches",
)}
${this.renderSuggestions(this.suggestions.bookmarks, "Bookmarks")}
</ul>
</div>
`;
}
}
customElements.define("ld-search-autocomplete", SearchAutocomplete);

View File

@@ -0,0 +1,206 @@
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 TurboLitElement {
static properties = {
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 },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.inputId = "";
this.inputName = "";
this.inputValue = "";
this.inputPlaceholder = "";
this.inputAriaDescribedBy = "";
this.variant = "default";
this.isFocus = false;
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
this.input = null;
this.suggestionList = null;
}
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() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
async handleInput(e) {
this.input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(this.input);
this.suggestions = word
? tags.filter(
(tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,
)
: [];
if (word && this.suggestions.length > 0) {
this.open();
} else {
this.close();
}
}
handleKeyDown(e) {
if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = this.suggestions[this.selectedIndex];
this.complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
this.close();
e.preventDefault();
}
if (e.keyCode === 38) {
this.updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
this.updateSelection(1);
e.preventDefault();
}
}
open() {
this.isOpen = true;
this.selectedIndex = 0;
this.positionController.enable();
}
close() {
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
this.positionController.disable();
}
complete(suggestion) {
const bounds = getCurrentWordBounds(this.input);
const value = this.input.value;
this.input.value =
value.substring(0, bounds.start) +
suggestion.name +
" " +
value.substring(bounds.end);
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
this.close();
}
updateSelection(dir) {
const length = this.suggestions.length;
let newIndex = this.selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
this.selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (this.suggestionList) {
const selectedListItem =
this.suggestionList.querySelector("li.selected");
if (selectedListItem) {
selectedListItem.scrollIntoView({ block: "center" });
}
}
}, 0);
}
render() {
return html`
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
<!-- autocomplete input container -->
<div
class="form-autocomplete-input form-input ${this.isFocus
? "is-focused"
: ""}"
>
<!-- autocomplete real input box -->
<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.inputAriaDescribedBy || nothing}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
</div>
<!-- autocomplete suggestion list -->
<ul
class="menu ${this.isOpen && this.suggestions.length > 0
? "open"
: ""}"
>
<!-- menu list items -->
${this.suggestions.map(
(tag, i) => html`
<li
class="menu-item ${this.selectedIndex === i ? "selected" : ""}"
>
<a
href="#"
@mousedown=${(e) => {
e.preventDefault();
this.complete(tag);
}}
>
${tag.name}
</a>
</li>
`,
)}
</ul>
</div>
`;
}
}
customElements.define("ld-tag-autocomplete", TagAutocomplete);

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,17 +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 { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
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

@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
return;
}
// Ignore if there is a modal dialog, which should handle its own focus
const modal = document.querySelector("[aria-modal='true']");
if (modal) {
return;
}
// Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target);

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"),
]

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