Compare commits

...

84 Commits

Author SHA1 Message Date
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
Sascha Ißbrücker
2bb33ff96d Bump version 2025-06-19 22:23:34 +02:00
Sascha Ißbrücker
549554cc17 Add REST API for bookmark bundles (#1100)
* Add bundles API

* Add docs
2025-06-19 22:19:29 +02:00
Peter
20e31397cc Add LinkBuddy to community section (#1088)
* Updates community resources to add LinkBuddy, an open-source React Native android and iOS app

* Fix ordering

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-06-19 17:40:29 +02:00
Sascha Ißbrücker
94ae5fb41c Fix assets not using correct icon (#1098) 2025-06-19 17:37:16 +02:00
dependabot[bot]
2a550e2315 Bump urllib3 from 2.2.3 to 2.5.0 (#1096)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.3 to 2.5.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.2.3...2.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 17:21:32 +02:00
dependabot[bot]
a79e8bcd59 Bump requests from 2.32.3 to 2.32.4 (#1090)
Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.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-06-19 17:21:05 +02:00
dependabot[bot]
1710d44df7 Bump django from 5.1.9 to 5.1.10 (#1086)
Bumps [django](https://github.com/django/django) from 5.1.9 to 5.1.10.
- [Commits](https://github.com/django/django/compare/5.1.9...5.1.10)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.1.10
  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-06-19 17:20:54 +02:00
dependabot[bot]
9967b3e27b Bump tar-fs in /docs (#1084)
Bumps  and [tar-fs](https://github.com/mafintosh/tar-fs). These dependencies needed to be updated together.

Updates `tar-fs` from 2.1.2 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

Updates `tar-fs` from 3.0.8 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 17:20:40 +02:00
Sascha Ißbrücker
1672dc0152 Add bundles for organizing bookmarks (#1097)
* add bundle model and query logic

* cleanup tests

* add basic form

* add success message

* Add form tests

* Add bundle list view

* fix edit view

* Add remove button

* Add basic preview logic

* Make pagination use absolute URLs

* Hide bookmark edits when rendering preview

* Render bookmark list in preview

* Reorder bundles

* Show bundles in bookmark view

* Make bookmark search respect selected bundle

* UI tweaks

* Fix bookmark scope

* Improve bundle preview

* Skip preview if form is submitted

* Show correct preview after invalid form submission

* Add option to hide bundles

* Merge new migrations

* Add tests for bundle menu

* Improve check for preview being removed
2025-06-19 16:47:29 +02:00
Sascha Ißbrücker
8be72a5d1f Fix side panel not being hidden on smaller viewports (#1089) 2025-06-10 09:24:37 +02:00
Sascha Ißbrücker
bb796c9bdb Add date filters for REST API (#1080)
* Add modified_since query parameter

* Add added_since parameter

* update date_modified when assets change
2025-05-30 10:24:19 +02:00
Sascha Ißbrücker
578680c3c1 Fix docs build 2025-05-17 13:37:00 +02:00
Sascha Ißbrücker
8debb5c5aa Add install instructions for GHCR 2025-05-17 13:18:40 +02:00
Sascha Ißbrücker
be752f8146 Update CHANGELOG.md 2025-05-17 12:56:10 +02:00
188 changed files with 13158 additions and 4986 deletions

View File

@@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/python // README at: https://github.com/devcontainers/templates/tree/main/src/python
{ {
"name": "Python 3", "name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:3.12", "image": "mcr.microsoft.com/devcontainers/python:3.13",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": {} "ghcr.io/devcontainers/features/node:1": {}
}, },
@@ -14,7 +14,7 @@
"forwardPorts": [8000], "forwardPorts": [8000],
// Use 'postCreateCommand' to run commands after the container is created. // 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. // Configure tool-specific properties.
"customizations": { "customizations": {

View File

@@ -10,10 +10,10 @@
!/package.json !/package.json
!/package-lock.json !/package-lock.json
!/postcss.config.js !/postcss.config.js
!/requirements.dev.txt !/pyproject.toml
!/requirements.txt
!/rollup.config.mjs !/rollup.config.mjs
!/supervisord.conf !/supervisord.conf
!/uv.lock
!/uwsgi.ini !/uwsgi.ini
!/version.txt !/version.txt

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

@@ -0,0 +1,73 @@
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
- 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
- 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
- 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

View File

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

4
.gitignore vendored
View File

@@ -196,3 +196,7 @@ typings/
/chromium-profile /chromium-profile
# direnv # direnv
/.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,178 @@
# Changelog # Changelog
## 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
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
### New Contributors
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
---
## v1.39.1 (06/03/2025)
> [!WARNING]
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
---
## v1.39.0 (06/03/2025)
### What's Changed
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
### New Contributors
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
---
## v1.38.1 (22/02/2025) ## v1.38.1 (22/02/2025)
### What's Changed ### What's Changed

View File

@@ -1,15 +1,23 @@
.PHONY: serve .PHONY: serve
init:
uv sync
uv run manage.py migrate
npm install
serve: serve:
python manage.py runserver uv run manage.py runserver
tasks: tasks:
python manage.py run_huey uv run manage.py run_huey
test: test:
pytest -n auto uv run pytest -n auto
format: format:
black bookmarks uv run black bookmarks
npx prettier bookmarks/frontend --write npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write npx prettier bookmarks/styles --write
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 🙂. 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 ### Prerequisites
- Python 3.12 - Python 3.13
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Node.js - Node.js
### Setup ### 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 make init
```
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
``` ```
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: 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 The frontend is now available under http://localhost:8000
@@ -123,14 +111,14 @@ Once checked out, only the following commands are required to get started:
Create a user for the frontend: 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: 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: Start the Django development server with:
``` ```
python3 manage.py runserver make serve
``` ```
The frontend is now available under http://localhost:8000 The frontend is now available under http://localhost:8000

View File

@@ -1,3 +1,4 @@
import os
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
@@ -11,7 +12,15 @@ from huey.contrib.djhuey import HUEY as huey
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken from bookmarks.models import (
Bookmark,
BookmarkAsset,
BookmarkBundle,
Tag,
UserProfile,
Toast,
FeedToken,
)
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -75,6 +84,7 @@ class LinkdingAdminSite(AdminSite):
def get_app_list(self, request, app_label=None): def get_app_list(self, request, app_label=None):
app_list = super().get_app_list(request, app_label) app_list = super().get_app_list(request, app_label)
context_path = os.getenv("LD_CONTEXT_PATH", "")
app_list += [ app_list += [
{ {
"name": "Huey", "name": "Huey",
@@ -83,7 +93,7 @@ class LinkdingAdminSite(AdminSite):
{ {
"name": "Queued tasks", "name": "Queued tasks",
"object_name": "background_tasks", "object_name": "background_tasks",
"admin_url": "/admin/tasks/", "admin_url": f"/{context_path}admin/tasks/",
"view_only": True, "view_only": True,
} }
], ],
@@ -206,7 +216,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("custom_display_name", "date_created", "status") list_display = ("custom_display_name", "date_created", "status")
search_fields = ( search_fields = (
"custom_display_name", "display_name",
"file", "file",
) )
list_filter = ("status",) list_filter = ("status",)
@@ -256,6 +266,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): class AdminUserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
can_delete = False can_delete = False
@@ -289,6 +314,7 @@ linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset) linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(Toast, AdminToast)

View File

@@ -16,9 +16,17 @@ from bookmarks.api.serializers import (
BookmarkAssetSerializer, BookmarkAssetSerializer,
TagSerializer, TagSerializer,
UserProfileSerializer, UserProfileSerializer,
BookmarkBundleSerializer,
) )
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User from bookmarks.models import (
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader Bookmark,
BookmarkAsset,
BookmarkSearch,
Tag,
User,
BookmarkBundle,
)
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
from bookmarks.type_defs import HttpRequest from bookmarks.type_defs import HttpRequest
from bookmarks.views import access from bookmarks.views import access
@@ -50,7 +58,7 @@ class BookmarkViewSet(
def get_queryset(self): def get_queryset(self):
# Provide filtered queryset for list actions # Provide filtered queryset for list actions
user = self.request.user user = self.request.user
search = BookmarkSearch.from_request(self.request.GET) search = BookmarkSearch.from_request(self.request, self.request.GET)
if self.action == "list": if self.action == "list":
return queries.query_bookmarks(user, user.profile, search) return queries.query_bookmarks(user, user.profile, search)
elif self.action == "archived": elif self.action == "archived":
@@ -99,7 +107,7 @@ class BookmarkViewSet(
def check(self, request: HttpRequest): def check(self, request: HttpRequest):
url = request.GET.get("url") url = request.GET.get("url")
ignore_cache = request.GET.get("ignore_cache", False) in ["true"] 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 = ( existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None self.get_serializer(bookmark).data if bookmark else None
) )
@@ -143,7 +151,7 @@ class BookmarkViewSet(
status=status.HTTP_400_BAD_REQUEST, 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: if not bookmark:
bookmark = Bookmark(url=url) bookmark = Bookmark(url=url)
@@ -191,13 +199,10 @@ class BookmarkAssetViewSet(
if asset.gzip if asset.gzip
else open(file_path, "rb") else open(file_path, "rb")
) )
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
)
response = StreamingHttpResponse(file_stream, content_type=content_type) response = 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 return response
except FileNotFoundError: except FileNotFoundError:
raise Http404("Asset file does not exist") raise Http404("Asset file does not exist")
@@ -264,6 +269,28 @@ class UserViewSet(viewsets.GenericViewSet):
return Response(UserProfileSerializer(request.user.profile).data) return Response(UserProfileSerializer(request.user.profile).data)
class BookmarkBundleViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
request: HttpRequest
serializer_class = BookmarkBundleSerializer
def get_queryset(self):
user = self.request.user
return BookmarkBundle.objects.filter(owner=user).order_by("order")
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>/ # 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 # Instead create separate routers for each view set and manually register them in urls.py
# The default router is only used to allow reversing a URL for the API root # The default router is only used to allow reversing a URL for the API root
@@ -278,5 +305,8 @@ tag_router.register("", TagViewSet, basename="tag")
user_router = SimpleRouter() user_router = SimpleRouter()
user_router.register("", UserViewSet, basename="user") user_router.register("", UserViewSet, basename="user")
bundle_router = SimpleRouter()
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
bookmark_asset_router = SimpleRouter() bookmark_asset_router = SimpleRouter()
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset") bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")

View File

@@ -1,10 +1,17 @@
from django.db.models import prefetch_related_objects from django.db.models import Max, prefetch_related_objects
from django.templatetags.static import static from django.templatetags.static import static
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ListSerializer from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile from bookmarks.models import (
from bookmarks.services import bookmarks Bookmark,
BookmarkAsset,
Tag,
build_tag_string,
UserProfile,
BookmarkBundle,
)
from bookmarks.services import bookmarks, bundles
from bookmarks.services.tags import get_or_create_tag from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version from bookmarks.utils import app_version
@@ -27,6 +34,32 @@ class EmtpyField(serializers.ReadOnlyField):
return None return None
class BookmarkBundleSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkBundle
fields = [
"id",
"name",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"order",
"date_created",
"date_modified",
]
read_only_fields = [
"id",
"date_created",
"date_modified",
]
def create(self, validated_data):
bundle = BookmarkBundle(**validated_data)
bundle.order = validated_data["order"] if "order" in validated_data else None
return bundles.create_bundle(bundle, self.context["user"])
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Bookmark model = Bookmark

View File

@@ -8,6 +8,7 @@ from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
from bookmarks.views import access
@dataclass @dataclass
@@ -30,10 +31,16 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed): class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str | None): def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else 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( search = BookmarkSearch(
q=request.GET.get("q", ""), q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""), unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""), shared=request.GET.get("shared", ""),
bundle=bundle,
) )
query_set = self.get_query_set(feed_token, search) query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set) return FeedContext(request, feed_token, query_set)

View File

@@ -1,9 +1,21 @@
from django import forms from django import forms
from django.forms.utils import ErrorList
from django.utils import timezone
from bookmarks.models import Bookmark, build_tag_string from bookmarks.models import (
from bookmarks.validators import BookmarkURLValidator Bookmark,
from bookmarks.type_defs import HttpRequest Tag,
build_tag_string,
parse_tag_string,
sanitize_tag_name,
)
from bookmarks.services.bookmarks import create_bookmark, update_bookmark from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.type_defs import HttpRequest
from bookmarks.validators import BookmarkURLValidator
class CustomErrorList(ErrorList):
template_name = "shared/error_list.html"
class BookmarkForm(forms.ModelForm): class BookmarkForm(forms.ModelForm):
@@ -44,11 +56,14 @@ class BookmarkForm(forms.ModelForm):
"tag_string": request.GET.get("tags"), "tag_string": request.GET.get("tags"),
"auto_close": "auto_close" in request.GET, "auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread, "unread": request.user_profile.default_mark_unread,
"shared": request.user_profile.default_mark_shared,
} }
if instance is not None and request.method == "GET": if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")} initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None 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=CustomErrorList
)
@property @property
def is_auto_close(self): def is_auto_close(self):
@@ -79,7 +94,7 @@ class BookmarkForm(forms.ModelForm):
url = self.cleaned_data["url"] url = self.cleaned_data["url"]
if self.instance.pk: if self.instance.pk:
is_duplicate = ( is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url) Bookmark.query_existing(self.instance.owner, url)
.exclude(pk=self.instance.pk) .exclude(pk=self.instance.pk)
.exists() .exists()
) )
@@ -93,3 +108,88 @@ def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings # strings
return tag_string.replace(" ", ",") return tag_string.replace(" ", ",")
class TagForm(forms.ModelForm):
class Meta:
model = Tag
fields = ["name"]
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
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()
merge_tags = forms.CharField()
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=CustomErrorList)
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.')
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.')
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

View File

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

View File

@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
<div class="modal-container" role="dialog" aria-modal="true"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>Filters</h2> <h2>Filters</h2>
<button class="close" aria-label="Close dialog"> <button class="btn btn-noborder close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>

View File

@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
return; 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 // Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) { for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target); const element = document.querySelector(target);

View File

@@ -1,5 +1,27 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
class FormSubmit extends Behavior {
constructor(element) {
super(element);
this.onKeyDown = this.onKeyDown.bind(this);
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Check for Ctrl/Cmd + Enter combination
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
this.element.requestSubmit();
}
}
}
class AutoSubmitBehavior extends Behavior { class AutoSubmitBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
@@ -17,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
} }
} }
// Resets form controls to their initial values before Turbo caches the DOM.
// Useful for filter forms where navigating back would otherwise still show
// values from after the form submission, which means the filters would be out
// of sync with the URL.
class FormResetBehavior extends Behavior {
constructor(element) {
super(element);
this.controls = this.element.querySelectorAll("input, select");
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.__initialValue = control.checked;
} else {
control.__initialValue = control.value;
}
});
}
destroy() {
this.controls.forEach((control) => {
if (control.type === "checkbox" || control.type === "radio") {
control.checked = control.__initialValue;
} else {
control.value = control.__initialValue;
}
delete control.__initialValue;
});
}
}
class UploadButton extends Behavior { class UploadButton extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
@@ -51,5 +103,7 @@ class UploadButton extends Behavior {
} }
} }
registerBehavior("ld-form-submit", FormSubmit);
registerBehavior("ld-auto-submit", AutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-form-reset", FormResetBehavior);
registerBehavior("ld-upload-button", UploadButton); registerBehavior("ld-upload-button", UploadButton);

View File

@@ -54,8 +54,6 @@ export class Behavior {
destroy() {} destroy() {}
} }
Behavior.interacting = false;
export function registerBehavior(name, behavior) { export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior; behaviorRegistry[name] = behavior;
} }

View File

@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
this.closeButton.removeEventListener("click", this.onClose); this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown); document.removeEventListener("keydown", this.onKeyDown);
this.clearInert(); this.removeScrollLock();
this.focusTrap.destroy(); this.focusTrap.destroy();
} }
init() { init() {
this.setupInert(); this.setupScrollLock();
this.focusTrap = new FocusTrapController( this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"), this.element.querySelector(".modal-container"),
); );
} }
setupInert() { setupScrollLock() {
// 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"); document.body.classList.add("scroll-lock");
} }
clearInert() { removeScrollLock() {
// 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"); document.body.classList.remove("scroll-lock");
} }
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
doClose() { doClose() {
this.element.remove(); this.element.remove();
this.clearInert(); this.removeScrollLock();
this.element.dispatchEvent(new CustomEvent("modal:close")); this.element.dispatchEvent(new CustomEvent("modal:close"));
} }
} }

View File

@@ -1,5 +1,5 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte"; import "../components/SearchAutocomplete.js";
class SearchAutocomplete extends Behavior { class SearchAutocomplete extends Behavior {
constructor(element) { constructor(element) {
@@ -10,26 +10,20 @@ class SearchAutocomplete extends Behavior {
return; return;
} }
const container = document.createElement("div"); const autocomplete = document.createElement("ld-search-autocomplete");
autocomplete.name = "q";
new SearchAutoCompleteComponent({ autocomplete.placeholder = input.getAttribute("placeholder") || "";
target: container, autocomplete.value = input.value;
props: { autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
name: "q", autocomplete.mode = input.dataset.mode || "";
placeholder: input.getAttribute("placeholder") || "", autocomplete.search = {
value: input.value, user: input.dataset.user,
linkTarget: input.dataset.linkTarget, shared: input.dataset.shared,
mode: input.dataset.mode, unread: input.dataset.unread,
search: { };
user: input.dataset.user,
shared: input.dataset.shared,
unread: input.dataset.unread,
},
},
});
this.input = input; this.input = input;
this.autocomplete = container.firstElementChild; this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete); input.replaceWith(this.autocomplete);
} }

View File

@@ -1,5 +1,5 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import "../components/TagAutocomplete.js";
class TagAutocomplete extends Behavior { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
@@ -10,21 +10,16 @@ class TagAutocomplete extends Behavior {
return; return;
} }
const container = document.createElement("div"); const autocomplete = document.createElement("ld-tag-autocomplete");
autocomplete.id = input.id;
new TagAutoCompleteComponent({ autocomplete.name = input.name;
target: container, autocomplete.value = input.value;
props: { autocomplete.placeholder = input.getAttribute("placeholder") || "";
id: input.id, autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
name: input.name, autocomplete.variant = input.getAttribute("variant") || "default";
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
variant: input.getAttribute("variant"),
},
});
this.input = input; this.input = input;
this.autocomplete = container.firstElementChild; this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete); input.replaceWith(this.autocomplete);
} }

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

@@ -0,0 +1,304 @@
import { LitElement, html } from "lit";
import { SearchHistory } from "./SearchHistory.js";
import { api } from "../api.js";
import { cache } from "../cache.js";
import {
clampText,
debounce,
getCurrentWord,
getCurrentWordBounds,
} from "../util.js";
export class SearchAutocomplete extends LitElement {
static properties = {
name: { type: String },
placeholder: { type: String },
value: { type: String },
mode: { type: String },
search: { type: Object },
linkTarget: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.name = "";
this.placeholder = "";
this.value = "";
this.mode = "";
this.search = {};
this.linkTarget = "_blank";
this.isFocus = false;
this.isOpen = false;
this.suggestions = {
recentSearches: [],
bookmarks: [],
tags: [],
total: [],
};
this.selectedIndex = undefined;
this.input = null;
this.searchHistory = new SearchHistory();
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.style.setProperty("--menu-max-height", "400px");
this.input = this.querySelector("input");
// Track current search query after loading the page
this.searchHistory.pushCurrent();
this.updateSuggestions();
}
handleFocus() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
handleInput(e) {
this.value = 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;
}
close() {
this.isOpen = false;
this.updateSuggestions();
this.selectedIndex = undefined;
}
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.value, 5)
.map((value) => ({
type: "search",
index: nextIndex(),
label: value,
value,
}));
// Bookmark suggestions
let bookmarks = [];
if (this.value && this.value.length >= 3) {
const path = this.mode ? `/${this.mode}` : "";
const suggestionSearch = {
...this.search,
q: this.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,
};
});
}
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.value = suggestion.value;
this.close();
}
if (suggestion.type === "bookmark") {
window.open(suggestion.bookmark.url, this.linkTarget);
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.name}"
placeholder="${this.placeholder}"
autocomplete="off"
.value="${this.value}"
@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,194 @@
import { LitElement, html } from "lit";
import { cache } from "../cache.js";
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
export class TagAutocomplete extends LitElement {
static properties = {
id: { type: String },
name: { type: String },
value: { type: String },
placeholder: { type: String },
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
variant: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.id = "";
this.name = "";
this.value = "";
this.placeholder = "";
this.ariaDescribedBy = "";
this.variant = "default";
this.isFocus = false;
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
this.input = null;
this.suggestionList = null;
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.input = this.querySelector("input");
this.suggestionList = this.querySelector(".menu");
}
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;
}
close() {
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
}
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.id}"
name="${this.name}"
.value="${this.value || ""}"
placeholder="${this.placeholder || " "}"
class="form-input"
type="text"
autocomplete="off"
autocapitalize="off"
aria-describedby="${this.ariaDescribedBy}"
@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

@@ -1,168 +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);
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

@@ -11,7 +11,5 @@ import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete"; import "./behaviors/search-autocomplete";
import "./behaviors/tag-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 { api } from "./api";
export { cache } from "./cache"; export { cache } from "./cache";

View File

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

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.1.9 on 2025-06-19 08:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0044_bookmark_latest_snapshot"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="hide_bundles",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="BookmarkBundle",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=256)),
("search", models.CharField(blank=True, max_length=256)),
("any_tags", models.CharField(blank=True, max_length=1024)),
("all_tags", models.CharField(blank=True, max_length=1024)),
("excluded_tags", models.CharField(blank=True, max_length=1024)),
("order", models.IntegerField(default=0)),
("date_created", models.DateTimeField(auto_now_add=True)),
("date_modified", models.DateTimeField(auto_now=True)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-08-22 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="url_normalized",
field=models.CharField(blank=True, db_index=True, max_length=2048),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.3 on 2025-08-22 08:28
from django.db import migrations, transaction
from bookmarks.utils import normalize_url
def populate_url_normalized(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
batch_size = 500
with transaction.atomic():
qs = Bookmark.objects.all()
for start in range(0, qs.count(), batch_size):
batch = list(qs[start : start + batch_size])
for bookmark in batch:
bookmark.url_normalized = normalize_url(bookmark.url)
Bookmark.objects.bulk_update(
batch, ["url_normalized"], batch_size=batch_size
)
def reverse_populate_url_normalized(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
Bookmark.objects.all().update(url_normalized="")
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0046_add_url_normalized_field"),
]
operations = [
migrations.RunPython(
populate_url_normalized,
reverse_populate_url_normalized,
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-08-22 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0047_populate_url_normalized_field"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_mark_shared",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-10-05 09:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0048_userprofile_default_mark_shared"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="legacy_search",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.5 on 2025-10-05 10:01
from django.contrib.auth import get_user_model
from django.db import migrations
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="new_search_toast",
message="This version replaces the search engine with a new implementation that supports logical operators (and, or, not). If you run into any issues with the new search, you can switch back to the old one by enabling legacy search in the settings.",
owner=user,
)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key="new_search_toast").delete()
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0049_userprofile_legacy_search"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.5 on 2025-10-11 08:46
from django.db import migrations
from bookmarks.utils import normalize_url
def fix_url_normalized(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
batch_size = 200
qs = Bookmark.objects.filter(url_normalized="").all()
for start in range(0, qs.count(), batch_size):
batch = list(qs[start : start + batch_size])
for bookmark in batch:
bookmark.url_normalized = normalize_url(bookmark.url)
Bookmark.objects.bulk_update(batch, ["url_normalized"])
def reverse_fix_url_normalized(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0050_new_search_toast"),
]
operations = [
migrations.RunPython(
fix_url_normalized,
reverse_fix_url_normalized,
),
]

View File

@@ -1,19 +1,20 @@
import binascii
import hashlib import hashlib
import logging import logging
import os import os
from typing import List from typing import List
import binascii
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import QueryDict from django.http import QueryDict
from bookmarks.utils import unique from bookmarks.utils import unique, normalize_url
from bookmarks.validators import BookmarkURLValidator from bookmarks.validators import BookmarkURLValidator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
return [] return []
names = tag_string.strip().split(delimiter) names = tag_string.strip().split(delimiter)
# remove empty names, sanitize remaining names # remove empty names, sanitize remaining names
names = [sanitize_tag_name(name) for name in names if name] names = [sanitize_tag_name(name) for name in names if name.strip()]
# remove duplicates # remove duplicates
names = unique(names, str.lower) names = unique(names, str.lower)
names.sort(key=str.lower) names.sort(key=str.lower)
@@ -53,6 +54,7 @@ def build_tag_string(tag_names: List[str], delimiter: str = ","):
class Bookmark(models.Model): class Bookmark(models.Model):
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()]) url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
url_normalized = models.CharField(max_length=2048, blank=True, db_index=True)
title = models.CharField(max_length=512, blank=True) title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
@@ -95,9 +97,23 @@ class Bookmark(models.Model):
names = [tag.name for tag in self.tags.all()] names = [tag.name for tag in self.tags.all()]
return sorted(names) return sorted(names)
def save(self, *args, **kwargs):
self.url_normalized = normalize_url(self.url)
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)" return self.resolved_title + " (" + self.url[:30] + "...)"
@staticmethod
def query_existing(owner: User, url: str) -> models.QuerySet:
# Find existing bookmark by normalized URL, or fall back to exact URL if
# normalized URL was not generated for whatever reason
normalized_url = normalize_url(url)
q = Q(owner=owner) & (
Q(url_normalized=normalized_url) | Q(url_normalized="", url=url)
)
return Bookmark.objects.filter(q)
@receiver(post_delete, sender=Bookmark) @receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs): def bookmark_deleted(sender, instance, **kwargs):
@@ -132,6 +148,14 @@ class BookmarkAsset(models.Model):
status = models.CharField(max_length=64, blank=False, null=False) status = models.CharField(max_length=64, blank=False, null=False)
gzip = models.BooleanField(default=False, null=False) gzip = models.BooleanField(default=False, null=False)
@property
def download_name(self):
return (
f"{self.display_name}.html"
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else self.display_name
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.file: if self.file:
try: try:
@@ -157,6 +181,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error) logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
class BookmarkBundle(models.Model):
name = models.CharField(max_length=256, blank=False)
search = models.CharField(max_length=256, blank=True)
any_tags = models.CharField(max_length=1024, blank=True)
all_tags = models.CharField(max_length=1024, blank=True)
excluded_tags = models.CharField(max_length=1024, blank=True)
order = models.IntegerField(null=False, default=0)
date_created = models.DateTimeField(auto_now_add=True, null=False)
date_modified = models.DateTimeField(auto_now=True, null=False)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.name
class BookmarkBundleForm(forms.ModelForm):
class Meta:
model = BookmarkBundle
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
class BookmarkSearch: class BookmarkSearch:
SORT_ADDED_ASC = "added_asc" SORT_ADDED_ASC = "added_asc"
SORT_ADDED_DESC = "added_desc" SORT_ADDED_DESC = "added_desc"
@@ -171,34 +216,54 @@ class BookmarkSearch:
FILTER_UNREAD_YES = "yes" FILTER_UNREAD_YES = "yes"
FILTER_UNREAD_NO = "no" FILTER_UNREAD_NO = "no"
params = ["q", "user", "sort", "shared", "unread"] params = [
"q",
"user",
"bundle",
"sort",
"shared",
"unread",
"modified_since",
"added_since",
]
preferences = ["sort", "shared", "unread"] preferences = ["sort", "shared", "unread"]
defaults = { defaults = {
"q": "", "q": "",
"user": "", "user": "",
"bundle": None,
"sort": SORT_ADDED_DESC, "sort": SORT_ADDED_DESC,
"shared": FILTER_SHARED_OFF, "shared": FILTER_SHARED_OFF,
"unread": FILTER_UNREAD_OFF, "unread": FILTER_UNREAD_OFF,
"modified_since": None,
"added_since": None,
} }
def __init__( def __init__(
self, self,
q: str = None, q: str = None,
user: str = None, user: str = None,
bundle: BookmarkBundle = None,
sort: str = None, sort: str = None,
shared: str = None, shared: str = None,
unread: str = None, unread: str = None,
modified_since: str = None,
added_since: str = None,
preferences: dict = None, preferences: dict = None,
request: any = None,
): ):
if not preferences: if not preferences:
preferences = {} preferences = {}
self.defaults = {**BookmarkSearch.defaults, **preferences} self.defaults = {**BookmarkSearch.defaults, **preferences}
self.request = request
self.q = q or self.defaults["q"] self.q = q or self.defaults["q"]
self.user = user or self.defaults["user"] self.user = user or self.defaults["user"]
self.bundle = bundle or self.defaults["bundle"]
self.sort = sort or self.defaults["sort"] self.sort = sort or self.defaults["sort"]
self.shared = shared or self.defaults["shared"] self.shared = shared or self.defaults["shared"]
self.unread = unread or self.defaults["unread"] self.unread = unread or self.defaults["unread"]
self.modified_since = modified_since or self.defaults["modified_since"]
self.added_since = added_since or self.defaults["added_since"]
def is_modified(self, param): def is_modified(self, param):
value = self.__dict__[param] value = self.__dict__[param]
@@ -226,7 +291,14 @@ class BookmarkSearch:
@property @property
def query_params(self): def query_params(self):
return {param: self.__dict__[param] for param in self.modified_params} query_params = {}
for param in self.modified_params:
value = self.__dict__[param]
if isinstance(value, models.Model):
query_params[param] = value.id
else:
query_params[param] = value
return query_params
@property @property
def preferences_dict(self): def preferences_dict(self):
@@ -235,14 +307,21 @@ class BookmarkSearch:
} }
@staticmethod @staticmethod
def from_request(query_dict: QueryDict, preferences: dict = None): def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
initial_values = {} initial_values = {}
for param in BookmarkSearch.params: for param in BookmarkSearch.params:
value = query_dict.get(param) value = query_dict.get(param)
if value: if value:
initial_values[param] = value if param == "bundle":
initial_values[param] = BookmarkBundle.objects.filter(
owner=request.user, pk=value
).first()
else:
initial_values[param] = value
return BookmarkSearch(**initial_values, preferences=preferences) return BookmarkSearch(
**initial_values, preferences=preferences, request=request
)
class BookmarkSearchForm(forms.Form): class BookmarkSearchForm(forms.Form):
@@ -265,9 +344,12 @@ class BookmarkSearchForm(forms.Form):
q = forms.CharField() q = forms.CharField()
user = forms.ChoiceField(required=False) user = forms.ChoiceField(required=False)
bundle = forms.CharField(required=False)
sort = forms.ChoiceField(choices=SORT_CHOICES) sort = forms.ChoiceField(choices=SORT_CHOICES)
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect) shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
unread = forms.ChoiceField(choices=FILTER_UNREAD_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__( def __init__(
self, self,
@@ -287,7 +369,11 @@ class BookmarkSearchForm(forms.Form):
for param in search.params: for param in search.params:
# set initial values for modified params # set initial values for modified params
self.fields[param].initial = search.__dict__[param] 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 # Mark non-editable modified fields as hidden. That way, templates
# rendering a form can just loop over hidden_fields to ensure that # rendering a form can just loop over hidden_fields to ensure that
@@ -403,11 +489,14 @@ class UserProfile(models.Model):
search_preferences = models.JSONField(default=dict, null=False) search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False) default_mark_unread = models.BooleanField(default=False, null=False)
default_mark_shared = models.BooleanField(default=False, null=False)
items_per_page = models.IntegerField( items_per_page = models.IntegerField(
null=False, default=30, validators=[MinValueValidator(10)] null=False, default=30, validators=[MinValueValidator(10)]
) )
sticky_pagination = models.BooleanField(default=False, null=False) sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False) collapse_side_panel = models.BooleanField(default=False, null=False)
hide_bundles = models.BooleanField(default=False, null=False)
legacy_search = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.custom_css: if self.custom_css:
@@ -443,11 +532,14 @@ class UserProfileForm(forms.ModelForm):
"display_remove_bookmark_action", "display_remove_bookmark_action",
"permanent_notes", "permanent_notes",
"default_mark_unread", "default_mark_unread",
"default_mark_shared",
"custom_css", "custom_css",
"auto_tagging_rules", "auto_tagging_rules",
"items_per_page", "items_per_page",
"sticky_pagination", "sticky_pagination",
"collapse_side_panel", "collapse_side_panel",
"hide_bundles",
"legacy_search",
] ]

View File

@@ -2,16 +2,38 @@ from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.db.models.functions import Lower from django.db.models.functions import Lower
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile from bookmarks.models import (
Bookmark,
BookmarkBundle,
BookmarkSearch,
Tag,
UserProfile,
parse_tag_string,
)
from bookmarks.services.search_query_parser import (
parse_search_query,
SearchExpression,
TermExpression,
TagExpression,
SpecialKeywordExpression,
AndExpression,
OrExpression,
NotExpression,
SearchQueryParseError,
extract_tag_names_from_query,
)
from bookmarks.utils import unique from bookmarks.utils import unique
def query_bookmarks( def query_bookmarks(
user: User, profile: UserProfile, search: BookmarkSearch user: User,
profile: UserProfile,
search: BookmarkSearch,
) -> QuerySet: ) -> QuerySet:
return _base_bookmarks_query(user, profile, search).filter(is_archived=False) return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
@@ -35,17 +57,92 @@ def query_shared_bookmarks(
return _base_bookmarks_query(user, profile, search).filter(conditions) return _base_bookmarks_query(user, profile, search).filter(conditions)
def _base_bookmarks_query( def _convert_ast_to_q_object(ast_node: SearchExpression, profile: UserProfile) -> Q:
user: Optional[User], profile: UserProfile, search: BookmarkSearch if isinstance(ast_node, TermExpression):
) -> QuerySet: # Search across title, description, notes, URL
query_set = Bookmark.objects conditions = (
Q(title__icontains=ast_node.term)
| Q(description__icontains=ast_node.term)
| Q(notes__icontains=ast_node.term)
| Q(url__icontains=ast_node.term)
)
# Filter for user # In lax mode, also search in tag names
if user: if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_set = query_set.filter(owner=user) conditions = conditions | Exists(
Bookmark.objects.filter(
id=OuterRef("id"), tags__name__iexact=ast_node.term
)
)
return conditions
elif isinstance(ast_node, TagExpression):
# Use Exists() to avoid reusing the same join when combining multiple tag expressions with and
return Q(
Exists(
Bookmark.objects.filter(
id=OuterRef("id"), tags__name__iexact=ast_node.tag
)
)
)
elif isinstance(ast_node, SpecialKeywordExpression):
# Handle special keywords
if ast_node.keyword.lower() == "unread":
return Q(unread=True)
elif ast_node.keyword.lower() == "untagged":
return Q(tags=None)
else:
# Unknown keyword, return empty Q object (matches all)
return Q()
elif isinstance(ast_node, AndExpression):
# Combine left and right with AND
left_q = _convert_ast_to_q_object(ast_node.left, profile)
right_q = _convert_ast_to_q_object(ast_node.right, profile)
return left_q & right_q
elif isinstance(ast_node, OrExpression):
# Combine left and right with OR
left_q = _convert_ast_to_q_object(ast_node.left, profile)
right_q = _convert_ast_to_q_object(ast_node.right, profile)
return left_q | right_q
elif isinstance(ast_node, NotExpression):
# Negate the operand
operand_q = _convert_ast_to_q_object(ast_node.operand, profile)
return ~operand_q
else:
# Fallback for unknown node types
return Q()
def _filter_search_query(
query_set: QuerySet, query_string: str, profile: UserProfile
) -> QuerySet:
"""New search filtering logic using logical expressions."""
try:
ast = parse_search_query(query_string)
if ast:
search_query = _convert_ast_to_q_object(ast, profile)
query_set = query_set.filter(search_query)
except SearchQueryParseError:
# If the query cannot be parsed, return zero results
return query_set.none()
return query_set
def _filter_search_query_legacy(
query_set: QuerySet, query_string: str, profile: UserProfile
) -> QuerySet:
"""Legacy search filtering logic where everything is just combined with AND."""
# Split query into search terms and tags # Split query into search terms and tags
query = parse_query_string(search.q) query = parse_query_string(query_string)
# Filter for search terms and tags # Filter for search terms and tags
for term in query["search_terms"]: for term in query["search_terms"]:
@@ -73,6 +170,83 @@ def _base_bookmarks_query(
if query["unread"]: if query["unread"]:
query_set = query_set.filter(unread=True) query_set = query_set.filter(unread=True)
return query_set
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
# Search terms
search_terms = parse_query_string(bundle.search)["search_terms"]
for term in search_terms:
conditions = (
Q(title__icontains=term)
| Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(url__icontains=term)
)
query_set = query_set.filter(conditions)
# Any tags - at least one tag must match
any_tags = parse_tag_string(bundle.any_tags, " ")
if len(any_tags) > 0:
tag_conditions = Q()
for tag in any_tags:
tag_conditions |= Q(tags__name__iexact=tag)
query_set = query_set.filter(
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)
# All tags - all tags must match
all_tags = parse_tag_string(bundle.all_tags, " ")
for tag in all_tags:
query_set = query_set.filter(tags__name__iexact=tag)
# Excluded tags - no tags must match
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
if len(exclude_tags) > 0:
tag_conditions = Q()
for tag in exclude_tags:
tag_conditions |= Q(tags__name__iexact=tag)
query_set = query_set.exclude(
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)
return query_set
def _base_bookmarks_query(
user: Optional[User],
profile: UserProfile,
search: BookmarkSearch,
) -> QuerySet:
query_set = Bookmark.objects
# Filter for user
if user:
query_set = query_set.filter(owner=user)
# Filter by modified_since if provided
if search.modified_since:
try:
query_set = query_set.filter(date_modified__gt=search.modified_since)
except ValidationError:
# If the date format is invalid, ignore the filter
pass
# Filter by added_since if provided
if search.added_since:
try:
query_set = query_set.filter(date_added__gt=search.added_since)
except ValidationError:
# If the date format is invalid, ignore the filter
pass
# Filter by search query
if profile.legacy_search:
query_set = _filter_search_query_legacy(query_set, search.q, profile)
else:
query_set = _filter_search_query(query_set, search.q, profile)
# Unread filter from bookmark search # Unread filter from bookmark search
if search.unread == BookmarkSearch.FILTER_UNREAD_YES: if search.unread == BookmarkSearch.FILTER_UNREAD_YES:
query_set = query_set.filter(unread=True) query_set = query_set.filter(unread=True)
@@ -85,6 +259,10 @@ def _base_bookmarks_query(
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED: elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
query_set = query_set.filter(shared=False) query_set = query_set.filter(shared=False)
# Filter by bundle
if search.bundle:
query_set = _filter_bundle(query_set, search.bundle)
# Sort # Sort
if ( if (
search.sort == BookmarkSearch.SORT_TITLE_ASC search.sort == BookmarkSearch.SORT_TITLE_ASC
@@ -168,6 +346,45 @@ def get_user_tags(user: User):
return Tag.objects.filter(owner=user).all() return Tag.objects.filter(owner=user).all()
def get_tags_for_query(user: User, profile: UserProfile, query: str) -> QuerySet:
tag_names = extract_tag_names_from_query(query, profile)
if not tag_names:
return Tag.objects.none()
tag_conditions = Q()
for tag_name in tag_names:
tag_conditions |= Q(name__iexact=tag_name)
return Tag.objects.filter(owner=user).filter(tag_conditions).distinct()
def get_shared_tags_for_query(
user: Optional[User], profile: UserProfile, query: str, public_only: bool
) -> QuerySet:
tag_names = extract_tag_names_from_query(query, profile)
if not tag_names:
return Tag.objects.none()
# Build conditions similar to query_shared_bookmarks
conditions = Q(bookmark__shared=True) & Q(
bookmark__owner__profile__enable_sharing=True
)
if public_only:
conditions = conditions & Q(
bookmark__owner__profile__enable_public_sharing=True
)
if user is not None:
conditions = conditions & Q(bookmark__owner=user)
tag_conditions = Q()
for tag_name in tag_names:
tag_conditions |= Q(name__iexact=tag_name)
return Tag.objects.filter(conditions).filter(tag_conditions).distinct()
def parse_query_string(query_string): def parse_query_string(query_string):
# Sanitize query params # Sanitize query params
if not query_string: if not query_string:

View File

@@ -39,9 +39,10 @@ def create_snapshot(asset: BookmarkAsset):
# Store as gzip in asset folder # Store as gzip in asset folder
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz") filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(temp_filepath, "rb") as temp_file, gzip.open( with (
filepath, "wb" open(temp_filepath, "rb") as temp_file,
) as gz_file: gzip.open(filepath, "wb") as gz_file,
):
shutil.copyfileobj(temp_file, gz_file) shutil.copyfileobj(temp_file, gz_file)
# Remove temporary file # Remove temporary file
@@ -53,6 +54,7 @@ def create_snapshot(asset: BookmarkAsset):
asset.save() asset.save()
asset.bookmark.latest_snapshot = asset asset.bookmark.latest_snapshot = asset
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save() asset.bookmark.save()
except Exception as error: except Exception as error:
asset.status = BookmarkAsset.STATUS_FAILURE asset.status = BookmarkAsset.STATUS_FAILURE
@@ -75,6 +77,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
asset.save() asset.save()
asset.bookmark.latest_snapshot = asset asset.bookmark.latest_snapshot = asset
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save() asset.bookmark.save()
return asset return asset
@@ -92,14 +95,33 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
gzip=False, gzip=False,
) )
name, extension = os.path.splitext(upload_file.name) name, extension = os.path.splitext(upload_file.name)
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) # automatically gzip the file if it is not already gzipped
with open(filepath, "wb") as f: if upload_file.content_type != "application/gzip":
for chunk in upload_file.chunks(): filename = _generate_asset_filename(
f.write(chunk) asset, name, extension.lstrip(".") + ".gz"
asset.file = filename )
asset.file_size = upload_file.size filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb", compresslevel=9) as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.gzip = True
asset.file = filename
asset.file_size = os.path.getsize(filepath)
else:
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save() asset.save()
asset.bookmark.date_modified = timezone.now()
asset.bookmark.save()
logger.info( logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}" f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
) )
@@ -128,9 +150,10 @@ def remove_asset(asset: BookmarkAsset):
) )
bookmark.latest_snapshot = latest bookmark.latest_snapshot = latest
bookmark.save()
asset.delete() asset.delete()
bookmark.date_modified = timezone.now()
bookmark.save()
def _generate_asset_filename( def _generate_asset_filename(

View File

@@ -19,8 +19,8 @@ def create_bookmark(
disable_html_snapshot: bool = False, disable_html_snapshot: bool = False,
): ):
# If URL is already bookmarked, then update it # If URL is already bookmarked, then update it
existing_bookmark: Bookmark = Bookmark.objects.filter( existing_bookmark: Bookmark = Bookmark.query_existing(
owner=current_user, url=bookmark.url current_user, bookmark.url
).first() ).first()
if existing_bookmark is not None: if existing_bookmark is not None:
@@ -208,6 +208,15 @@ def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: Us
tasks.load_preview_image(current_user, bookmark) tasks.load_preview_image(current_user, bookmark)
def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
owned_bookmarks = Bookmark.objects.filter(
owner=current_user, id__in=sanitized_bookmark_ids
)
tasks.create_html_snapshots(owned_bookmarks)
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

View File

@@ -0,0 +1,37 @@
from django.db.models import Max
from bookmarks.models import BookmarkBundle, User
def create_bundle(bundle: BookmarkBundle, current_user: User):
bundle.owner = current_user
if bundle.order is None:
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
Max("order", default=-1)
)
bundle.order = max_order_result["order__max"] + 1
bundle.save()
return bundle
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
user_bundles = list(
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
)
if new_order != user_bundles.index(bundle_to_move):
user_bundles.remove(bundle_to_move)
user_bundles.insert(new_order, bundle_to_move)
for bundle_index, bundle in enumerate(user_bundles):
bundle.order = bundle_index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
def delete_bundle(bundle: BookmarkBundle):
bundle.delete()
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
for index, user_bundle in enumerate(user_bundles):
user_bundle.order = index
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])

View File

@@ -8,7 +8,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services.parser import parse, NetscapeBookmark from bookmarks.services.parser import parse, NetscapeBookmark
from bookmarks.utils import parse_timestamp from bookmarks.utils import normalize_url, parse_timestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,8 +45,9 @@ class TagCache:
result = [] result = []
for tag_name in tag_names: for tag_name in tag_names:
tag = self.get(tag_name) tag = self.get(tag_name)
# Tag may not have been created if tag name exceeded maximum length
# Prevent returning duplicates # Prevent returning duplicates
if not (tag in result): if tag and not (tag in result):
result.append(tag) result.append(tag)
return result return result
@@ -96,6 +97,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
for netscape_bookmark in netscape_bookmarks: for netscape_bookmark in netscape_bookmarks:
for tag_name in netscape_bookmark.tag_names: for tag_name in netscape_bookmark.tag_names:
# Skip tag names that exceed the maximum allowed length
if len(tag_name) > 64:
logger.warning(
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
)
continue
tag = tag_cache.get(tag_name) tag = tag_cache.get(tag_name)
if not tag: if not tag:
tag = Tag(name=tag_name, owner=user) tag = Tag(name=tag_name, owner=user)
@@ -174,6 +182,7 @@ def _import_batch(
bookmarks_to_update, bookmarks_to_update,
[ [
"url", "url",
"url_normalized",
"date_added", "date_added",
"date_modified", "date_modified",
"unread", "unread",
@@ -227,6 +236,7 @@ def _copy_bookmark_data(
netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions
): ):
bookmark.url = netscape_bookmark.href bookmark.url = netscape_bookmark.href
bookmark.url_normalized = normalize_url(bookmark.url)
if netscape_bookmark.date_added: if netscape_bookmark.date_added:
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added) bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else: else:

View File

@@ -22,9 +22,10 @@ def create_snapshot(url: str, filepath: str):
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}" command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
subprocess.run(command, check=True, shell=True) subprocess.run(command, check=True, shell=True)
with open(temp_filepath, "rb") as raw_file, gzip.open( with (
filepath, "wb" open(temp_filepath, "rb") as raw_file,
) as gz_file: gzip.open(filepath, "wb") as gz_file,
):
shutil.copyfileobj(raw_file, gz_file) shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath) os.remove(temp_filepath)

View File

@@ -0,0 +1,575 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from bookmarks.models import UserProfile
class TokenType(Enum):
TERM = "TERM"
TAG = "TAG"
SPECIAL_KEYWORD = "SPECIAL_KEYWORD"
AND = "AND"
OR = "OR"
NOT = "NOT"
LPAREN = "LPAREN"
RPAREN = "RPAREN"
EOF = "EOF"
@dataclass
class Token:
type: TokenType
value: str
position: int
class SearchQueryTokenizer:
def __init__(self, query: str):
self.query = query.strip()
self.position = 0
self.current_char = self.query[0] if self.query else None
def advance(self):
"""Move to the next character in the query."""
self.position += 1
if self.position >= len(self.query):
self.current_char = None
else:
self.current_char = self.query[self.position]
def skip_whitespace(self):
"""Skip whitespace characters."""
while self.current_char and self.current_char.isspace():
self.advance()
def read_term(self) -> str:
"""Read a search term (sequence of non-whitespace, non-special characters)."""
term = ""
while (
self.current_char
and not self.current_char.isspace()
and self.current_char not in "()\"'#!"
):
term += self.current_char
self.advance()
return term
def read_quoted_string(self, quote_char: str) -> str:
"""Read a quoted string, handling escaped quotes."""
content = ""
self.advance() # skip opening quote
while self.current_char and self.current_char != quote_char:
if self.current_char == "\\":
# Handle escaped characters
self.advance()
if self.current_char:
if self.current_char == "n":
content += "\n"
elif self.current_char == "t":
content += "\t"
elif self.current_char == "r":
content += "\r"
elif self.current_char == "\\":
content += "\\"
elif self.current_char == quote_char:
content += quote_char
else:
# For any other escaped character, just include it as-is
content += self.current_char
self.advance()
else:
content += self.current_char
self.advance()
if self.current_char == quote_char:
self.advance() # skip closing quote
else:
# Unclosed quote - we could raise an error here, but let's be lenient
# and treat it as if the quote was closed at the end
pass
return content
def read_tag(self) -> str:
"""Read a tag (starts with # and continues until whitespace or special chars)."""
tag = ""
self.advance() # skip the # character
while (
self.current_char
and not self.current_char.isspace()
and self.current_char not in "()\"'"
):
tag += self.current_char
self.advance()
return tag
def read_special_keyword(self) -> str:
"""Read a special keyword (starts with ! and continues until whitespace or special chars)."""
keyword = ""
self.advance() # skip the ! character
while (
self.current_char
and not self.current_char.isspace()
and self.current_char not in "()\"'"
):
keyword += self.current_char
self.advance()
return keyword
def tokenize(self) -> List[Token]:
"""Convert the query string into a list of tokens."""
tokens = []
while self.current_char:
self.skip_whitespace()
if not self.current_char:
break
start_pos = self.position
if self.current_char == "(":
tokens.append(Token(TokenType.LPAREN, "(", start_pos))
self.advance()
elif self.current_char == ")":
tokens.append(Token(TokenType.RPAREN, ")", start_pos))
self.advance()
elif self.current_char in "\"'":
# Read a quoted string - always treated as a term
quote_char = self.current_char
term = self.read_quoted_string(quote_char)
tokens.append(Token(TokenType.TERM, term, start_pos))
elif self.current_char == "#":
# Read a tag
tag = self.read_tag()
# Only add the tag token if it has content
if tag:
tokens.append(Token(TokenType.TAG, tag, start_pos))
elif self.current_char == "!":
# Read a special keyword
keyword = self.read_special_keyword()
# Only add the keyword token if it has content
if keyword:
tokens.append(Token(TokenType.SPECIAL_KEYWORD, keyword, start_pos))
else:
# Read a term and check if it's an operator
term = self.read_term()
term_lower = term.lower()
if term_lower == "and":
tokens.append(Token(TokenType.AND, term, start_pos))
elif term_lower == "or":
tokens.append(Token(TokenType.OR, term, start_pos))
elif term_lower == "not":
tokens.append(Token(TokenType.NOT, term, start_pos))
else:
tokens.append(Token(TokenType.TERM, term, start_pos))
tokens.append(Token(TokenType.EOF, "", len(self.query)))
return tokens
class SearchExpression:
pass
@dataclass
class TermExpression(SearchExpression):
term: str
@dataclass
class TagExpression(SearchExpression):
tag: str
@dataclass
class SpecialKeywordExpression(SearchExpression):
keyword: str
@dataclass
class AndExpression(SearchExpression):
left: SearchExpression
right: SearchExpression
@dataclass
class OrExpression(SearchExpression):
left: SearchExpression
right: SearchExpression
@dataclass
class NotExpression(SearchExpression):
operand: SearchExpression
class SearchQueryParseError(Exception):
def __init__(self, message: str, position: int):
self.message = message
self.position = position
super().__init__(f"{message} at position {position}")
class SearchQueryParser:
def __init__(self, tokens: List[Token]):
self.tokens = tokens
self.position = 0
self.current_token = tokens[0] if tokens else Token(TokenType.EOF, "", 0)
def advance(self):
"""Move to the next token."""
if self.position < len(self.tokens) - 1:
self.position += 1
self.current_token = self.tokens[self.position]
def consume(self, expected_type: TokenType) -> Token:
"""Consume a token of the expected type or raise an error."""
if self.current_token.type == expected_type:
token = self.current_token
self.advance()
return token
else:
raise SearchQueryParseError(
f"Expected {expected_type.value}, got {self.current_token.type.value}",
self.current_token.position,
)
def parse(self) -> Optional[SearchExpression]:
"""Parse the tokens into an AST."""
if not self.tokens or (
len(self.tokens) == 1 and self.tokens[0].type == TokenType.EOF
):
return None
expr = self.parse_or_expression()
if self.current_token.type != TokenType.EOF:
raise SearchQueryParseError(
f"Unexpected token {self.current_token.type.value}",
self.current_token.position,
)
return expr
def parse_or_expression(self) -> SearchExpression:
"""Parse OR expressions (lowest precedence)."""
left = self.parse_and_expression()
while self.current_token.type == TokenType.OR:
self.advance() # consume OR
right = self.parse_and_expression()
left = OrExpression(left, right)
return left
def parse_and_expression(self) -> SearchExpression:
"""Parse AND expressions (medium precedence), including implicit AND."""
left = self.parse_not_expression()
while self.current_token.type == TokenType.AND or self.current_token.type in [
TokenType.TERM,
TokenType.TAG,
TokenType.SPECIAL_KEYWORD,
TokenType.LPAREN,
TokenType.NOT,
]:
if self.current_token.type == TokenType.AND:
self.advance() # consume explicit AND
# else: implicit AND (don't advance token)
right = self.parse_not_expression()
left = AndExpression(left, right)
return left
def parse_not_expression(self) -> SearchExpression:
"""Parse NOT expressions (high precedence)."""
if self.current_token.type == TokenType.NOT:
self.advance() # consume NOT
operand = self.parse_not_expression() # right associative
return NotExpression(operand)
return self.parse_primary_expression()
def parse_primary_expression(self) -> SearchExpression:
"""Parse primary expressions (terms, tags, special keywords, and parenthesized expressions)."""
if self.current_token.type == TokenType.TERM:
term = self.current_token.value
self.advance()
return TermExpression(term)
elif self.current_token.type == TokenType.TAG:
tag = self.current_token.value
self.advance()
return TagExpression(tag)
elif self.current_token.type == TokenType.SPECIAL_KEYWORD:
keyword = self.current_token.value
self.advance()
return SpecialKeywordExpression(keyword)
elif self.current_token.type == TokenType.LPAREN:
self.advance() # consume (
expr = self.parse_or_expression()
self.consume(TokenType.RPAREN) # consume )
return expr
else:
raise SearchQueryParseError(
f"Unexpected token {self.current_token.type.value}",
self.current_token.position,
)
def parse_search_query(query: str) -> Optional[SearchExpression]:
if not query or not query.strip():
return None
tokenizer = SearchQueryTokenizer(query)
tokens = tokenizer.tokenize()
parser = SearchQueryParser(tokens)
return parser.parse()
def _needs_parentheses(expr: SearchExpression, parent_type: type) -> bool:
if isinstance(expr, OrExpression) and parent_type == AndExpression:
return True
# AndExpression or OrExpression needs parentheses when inside NotExpression
if isinstance(expr, (AndExpression, OrExpression)) and parent_type == NotExpression:
return True
return False
def _is_simple_expression(expr: SearchExpression) -> bool:
"""Check if an expression is simple (term, tag, or keyword)."""
return isinstance(expr, (TermExpression, TagExpression, SpecialKeywordExpression))
def _expression_to_string(expr: SearchExpression, parent_type: type = None) -> str:
if isinstance(expr, TermExpression):
# Quote terms if they contain spaces or special characters
if " " in expr.term or any(c in expr.term for c in ["(", ")", '"', "'"]):
# Escape any quotes in the term
escaped = expr.term.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return expr.term
elif isinstance(expr, TagExpression):
return f"#{expr.tag}"
elif isinstance(expr, SpecialKeywordExpression):
return f"!{expr.keyword}"
elif isinstance(expr, NotExpression):
# Don't pass parent type to children
operand_str = _expression_to_string(expr.operand, None)
# Add parentheses if the operand is a binary operation
if isinstance(expr.operand, (AndExpression, OrExpression)):
return f"not ({operand_str})"
return f"not {operand_str}"
elif isinstance(expr, AndExpression):
# Don't pass parent type to children - they'll add their own parens only if needed
left_str = _expression_to_string(expr.left, None)
right_str = _expression_to_string(expr.right, None)
# Add parentheses to children if needed for precedence
if _needs_parentheses(expr.left, AndExpression):
left_str = f"({left_str})"
if _needs_parentheses(expr.right, AndExpression):
right_str = f"({right_str})"
result = f"{left_str} {right_str}"
# Add outer parentheses if needed based on parent context
if parent_type and _needs_parentheses(expr, parent_type):
result = f"({result})"
return result
elif isinstance(expr, OrExpression):
# Don't pass parent type to children
left_str = _expression_to_string(expr.left, None)
right_str = _expression_to_string(expr.right, None)
# OrExpression children don't need parentheses unless they're also OR (handled by recursion)
result = f"{left_str} or {right_str}"
# Add outer parentheses if needed based on parent context
if parent_type and _needs_parentheses(expr, parent_type):
result = f"({result})"
return result
else:
raise ValueError(f"Unknown expression type: {type(expr)}")
def expression_to_string(expr: Optional[SearchExpression]) -> str:
if expr is None:
return ""
return _expression_to_string(expr)
def _strip_tag_from_expression(
expr: Optional[SearchExpression], tag_name: str, enable_lax_search: bool = False
) -> Optional[SearchExpression]:
if expr is None:
return None
if isinstance(expr, TagExpression):
# Remove this tag if it matches
if expr.tag.lower() == tag_name.lower():
return None
return expr
elif isinstance(expr, TermExpression):
# In lax search mode, also remove terms that match the tag name
if enable_lax_search and expr.term.lower() == tag_name.lower():
return None
return expr
elif isinstance(expr, SpecialKeywordExpression):
# Keep special keywords as-is
return expr
elif isinstance(expr, NotExpression):
# Recursively filter the operand
filtered_operand = _strip_tag_from_expression(
expr.operand, tag_name, enable_lax_search
)
if filtered_operand is None:
# If the operand is removed, the whole NOT expression should be removed
return None
return NotExpression(filtered_operand)
elif isinstance(expr, AndExpression):
# Recursively filter both sides
left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)
right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)
# If both sides are removed, remove the AND expression
if left is None and right is None:
return None
# If one side is removed, return the other side
elif left is None:
return right
elif right is None:
return left
else:
return AndExpression(left, right)
elif isinstance(expr, OrExpression):
# Recursively filter both sides
left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)
right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)
# If both sides are removed, remove the OR expression
if left is None and right is None:
return None
# If one side is removed, return the other side
elif left is None:
return right
elif right is None:
return left
else:
return OrExpression(left, right)
else:
# Unknown expression type, return as-is
return expr
def strip_tag_from_query(
query: str, tag_name: str, user_profile: UserProfile | None = None
) -> str:
try:
ast = parse_search_query(query)
except SearchQueryParseError:
return query
if ast is None:
return ""
# Determine if lax search is enabled
enable_lax_search = False
if user_profile is not None:
enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX
# Strip the tag from the AST
filtered_ast = _strip_tag_from_expression(ast, tag_name, enable_lax_search)
# Convert back to a query string
return expression_to_string(filtered_ast)
def _extract_tag_names_from_expression(
expr: Optional[SearchExpression], enable_lax_search: bool = False
) -> List[str]:
if expr is None:
return []
if isinstance(expr, TagExpression):
return [expr.tag]
elif isinstance(expr, TermExpression):
# In lax search mode, terms are also considered tags
if enable_lax_search:
return [expr.term]
return []
elif isinstance(expr, SpecialKeywordExpression):
# Special keywords are not tags
return []
elif isinstance(expr, NotExpression):
# Recursively extract from the operand
return _extract_tag_names_from_expression(expr.operand, enable_lax_search)
elif isinstance(expr, (AndExpression, OrExpression)):
# Recursively extract from both sides and combine
left_tags = _extract_tag_names_from_expression(expr.left, enable_lax_search)
right_tags = _extract_tag_names_from_expression(expr.right, enable_lax_search)
return left_tags + right_tags
else:
# Unknown expression type
return []
def extract_tag_names_from_query(
query: str, user_profile: UserProfile | None = None
) -> List[str]:
try:
ast = parse_search_query(query)
except SearchQueryParseError:
return []
if ast is None:
return []
# Determine if lax search is enabled
enable_lax_search = False
if user_profile is not None:
enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX
# Extract tag names from the AST
tag_names = _extract_tag_names_from_expression(ast, enable_lax_search)
# Deduplicate (case-insensitive) and sort
seen = set()
unique_tags = []
for tag in tag_names:
tag_lower = tag.lower()
if tag_lower not in seen:
seen.add(tag_lower)
unique_tags.append(tag_lower)
return sorted(unique_tags)

View File

@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)

View File

@@ -1,5 +1,13 @@
/* Common styles */ /* Common styles */
.bookmark-details { .bookmark-details {
.title {
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
}
& .weblinks { & .weblinks {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -49,50 +57,9 @@
& .assets { & .assets {
margin-top: var(--unit-2); margin-top: var(--unit-2);
& .asset { & .filesize {
display: flex;
align-items: center;
gap: var(--unit-2);
padding: var(--unit-2) 0;
border-top: var(--unit-o) solid var(--secondary-border-color);
}
& .asset:last-child {
border-bottom: var(--unit-o) solid var(--secondary-border-color);
}
& .asset-icon {
display: flex;
align-items: center;
justify-content: center;
}
& .asset-text {
flex: 1 1 0;
gap: var(--unit-2);
min-width: 0;
display: flex;
}
& .asset-text .truncate {
flex-shrink: 1;
}
& .asset-text .filesize {
color: var(--tertiary-text-color); color: var(--tertiary-text-color);
} }
& .asset-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
} }
& .assets-actions { & .assets-actions {

View File

@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
.bookmark-pagination { .bookmark-pagination {
margin-top: var(--unit-4); margin-top: var(--unit-4);
/* Remove left padding from first pagination link */
& .page-item:first-child a {
padding-left: 0;
}
&.sticky { &.sticky {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -365,7 +359,8 @@ li[ld-bookmark-item] {
top: 0; top: 0;
bottom: 0; bottom: 0;
left: calc( left: calc(
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)) -1 *
calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
); );
width: calc( width: calc(
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset) var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
@@ -379,6 +374,26 @@ li[ld-bookmark-item] {
} }
} }
.bundle-menu {
list-style-type: none;
margin: 0 0 var(--unit-6);
.bundle-menu-item {
margin: 0;
margin-bottom: var(--unit-2);
}
.bundle-menu-item a {
padding: var(--unit-1) var(--unit-2);
border-radius: var(--border-radius);
}
.bundle-menu-item.selected a {
background: var(--primary-color);
color: var(--contrast-text-color);
}
}
.tag-cloud { .tag-cloud {
/* Increase line-height for better separation within / between items */ /* Increase line-height for better separation within / between items */
line-height: 1.1rem; line-height: 1.1rem;

View File

@@ -0,0 +1,29 @@
.bundles-page {
.crud-table {
svg {
cursor: grab;
}
tr.drag-start {
--secondary-border-color: transparent;
}
tr.dragging > * {
opacity: 0;
}
}
}
.bundles-editor-page {
&.grid {
gap: var(--unit-9);
}
.form-footer {
position: sticky;
bottom: 0;
border-top: solid 1px var(--secondary-border-color);
background: var(--body-color);
padding: var(--unit-3) 0;
}
}

View File

@@ -25,28 +25,23 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.section-header { .section-header:not(.no-wrap) {
flex-direction: column; flex-direction: column;
} }
} }
/* Confirm button component */ /* Confirm button component */
span.confirmation { .confirm-dropdown.active {
display: flex; position: fixed;
align-items: baseline; z-index: 500;
gap: var(--unit-1);
color: var(--error-color) !important;
svg { & .menu {
align-self: center; position: fixed;
} display: flex;
flex-direction: column;
.btn.btn-link { box-sizing: border-box;
color: var(--error-color) !important; gap: var(--unit-2);
padding: var(--unit-2);
&:hover {
text-decoration: underline;
}
} }
} }
@@ -60,3 +55,60 @@ span.confirmation {
.turbo-progress-bar { .turbo-progress-bar {
background-color: var(--primary-color); background-color: var(--primary-color);
} }
/* Messages */
.message-list {
margin: var(--unit-4) 0;
.toast {
margin-bottom: var(--unit-2);
}
.toast a.btn-clear:visited {
color: currentColor;
}
}
/* Item list */
.item-list {
& .list-item {
display: flex;
align-items: center;
gap: var(--unit-2);
padding: var(--unit-2) 0;
border-top: var(--unit-o) solid var(--secondary-border-color);
}
& .list-item:last-child {
border-bottom: var(--unit-o) solid var(--secondary-border-color);
}
& .list-item-icon {
display: flex;
align-items: center;
justify-content: center;
}
& .list-item-text {
flex: 1 1 0;
gap: var(--unit-2);
min-width: 0;
display: flex;
}
& .list-item-text .truncate {
flex-shrink: 1;
}
& .list-item-actions {
display: flex;
gap: var(--unit-4);
align-items: center;
& .btn.btn-link {
height: unset;
padding: 0;
border: none;
}
}
}

65
bookmarks/styles/crud.css Normal file
View File

@@ -0,0 +1,65 @@
.crud-page {
.crud-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--unit-6);
h1 {
font-size: var(--font-size-xl);
margin: 0;
}
}
.crud-filters {
background: var(--body-color-contrast);
border-radius: var(--border-radius);
border: solid 1px var(--secondary-border-color);
padding: var(--unit-3);
margin-bottom: var(--unit-4);
form {
display: flex;
flex-wrap: wrap;
gap: var(--unit-4);
& .form-group {
margin: 0;
}
&.form-input,
&.form-select {
width: auto;
}
& .form-group:has(.form-checkbox) {
align-self: flex-end;
}
}
}
.crud-table {
.btn.btn-link {
padding: 0;
height: unset;
}
th,
td {
max-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th.actions,
td.actions {
width: 1%;
max-width: 150px;
*:not(:last-child) {
margin-right: var(--unit-2);
}
}
}
}

View File

@@ -27,15 +27,3 @@ header {
line-height: 1.2; line-height: 1.2;
} }
} }
header .toasts {
margin-bottom: 20px;
.toast {
margin-bottom: 0.4rem;
}
.toast a.btn-clear:visited {
color: currentColor;
}
}

View File

@@ -0,0 +1,6 @@
.tags-editor-page {
main {
max-width: 550px;
margin: 0 auto;
}
}

View File

@@ -22,9 +22,12 @@
@import "responsive.css"; @import "responsive.css";
@import "layout.css"; @import "layout.css";
@import "components.css"; @import "components.css";
@import "crud.css";
@import "bookmark-details.css"; @import "bookmark-details.css";
@import "bookmark-form.css"; @import "bookmark-form.css";
@import "bookmark-page.css"; @import "bookmark-page.css";
@import "markdown.css"; @import "markdown.css";
@import "reader-mode.css"; @import "reader-mode.css";
@import "settings.css"; @import "settings.css";
@import "bundles.css";
@import "tags.css";

View File

@@ -3,13 +3,14 @@
position: relative; position: relative;
& .form-autocomplete-input { & .form-autocomplete-input {
box-sizing: border-box;
align-content: flex-start; align-content: flex-start;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
height: auto;
min-height: var(--unit-8);
padding: var(--unit-h);
background: var(--input-bg-color); background: var(--input-bg-color);
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
&.is-focused { &.is-focused {
outline: var(--focus-outline); outline: var(--focus-outline);
@@ -22,10 +23,11 @@
box-shadow: none; box-shadow: none;
display: inline-block; display: inline-block;
flex: 1 0 auto; flex: 1 0 auto;
height: var(--unit-6);
line-height: var(--unit-4); line-height: var(--unit-4);
margin: var(--unit-h); width: 100%;
width: auto; height: 100%;
margin: 0;
border: none;
&:focus { &:focus {
outline: none; outline: none;
@@ -33,11 +35,30 @@
} }
} }
&.small {
.form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.menu .menu-item {
font-size: var(--font-size-sm);
}
}
& .menu { & .menu {
display: none;
left: 0; left: 0;
position: absolute; position: absolute;
top: 100%; top: 100%;
width: 100%; width: 100%;
max-height: var(--menu-max-height, 200px);
overflow: auto;
& .menu-item.selected > a, & .menu-item.selected > a,
& .menu-item > a:hover { & .menu-item > a:hover {
@@ -54,4 +75,8 @@
font-weight: bold; font-weight: bold;
} }
} }
& .menu.open {
display: block;
}
} }

View File

@@ -113,12 +113,19 @@
&.btn-error { &.btn-error {
--btn-border-color: var(--error-color); --btn-border-color: var(--error-color);
--btn-text-color: var(--error-color); --btn-text-color: var(--error-color);
--btn-icon-color: var(--error-color);
&:hover { &:hover {
--btn-hover-bg-color: var(--error-color-shade); --btn-hover-bg-color: var(--error-color-shade);
} }
} }
/* Button no border */
&.btn-noborder {
border-color: transparent;
box-shadow: none;
}
/* Button Link */ /* Button Link */
&.btn-link { &.btn-link {

View File

@@ -4,7 +4,7 @@
border-radius: var(--border-radius); border-radius: var(--border-radius);
color: var(--secondary-text-color); color: var(--secondary-text-color);
text-align: center; text-align: center;
padding: var(--unit-16) var(--unit-8); padding: var(--unit-8) var(--unit-8);
.empty-icon { .empty-icon {
margin-bottom: var(--layout-spacing-lg); margin-bottom: var(--layout-spacing-lg);

View File

@@ -224,12 +224,13 @@ textarea.form-input {
position: relative; position: relative;
input { input {
clip: rect(0, 0, 0, 0); opacity: 0;
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute; position: absolute;
width: 1px; top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
left: 0;
height: var(--control-icon-size);
width: var(--control-icon-size);
cursor: pointer;
&:focus-visible + .form-icon { &:focus-visible + .form-icon {
outline: var(--focus-outline); outline: var(--focus-outline);
@@ -243,9 +244,9 @@ textarea.form-input {
} }
.form-icon { .form-icon {
pointer-events: none;
border: var(--border-width) solid var(--checkbox-border-color); border: var(--border-width) solid var(--checkbox-border-color);
box-shadow: var(--input-box-shadow); box-shadow: var(--input-box-shadow);
cursor: pointer;
display: inline-block; display: inline-block;
position: absolute; position: absolute;
transition: transition:
@@ -429,13 +430,21 @@ textarea.form-input {
/* Form element: Input groups */ /* Form element: Input groups */
.input-group { .input-group {
display: flex; display: flex;
border-radius: var(--border-radius);
box-shadow: var(--input-box-shadow);
> * {
box-shadow: none !important;
}
.input-group-addon { .input-group-addon {
background: var(--body-color); display: flex;
align-items: center;
background: var(--input-bg-color);
border: var(--border-width) solid var(--input-border-color); border: var(--border-width) solid var(--input-border-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
line-height: var(--line-height); line-height: var(--line-height);
padding: var(--control-padding-y) var(--control-padding-x); padding: 0 var(--control-padding-x);
white-space: nowrap; white-space: nowrap;
&.addon-sm { &.addon-sm {

View File

@@ -87,4 +87,43 @@
border-bottom: solid 1px var(--secondary-border-color); border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-2) 0; margin: var(--unit-2) 0;
} }
&.with-arrow {
overflow: visible;
--arrow-size: 16px;
--arrow-offset: 0px;
.menu-arrow {
display: block;
position: absolute;
inset-inline-start: calc(50% + var(--arrow-offset));
top: 0;
width: var(--arrow-size);
height: var(--arrow-size);
translate: -50% -50%;
rotate: 45deg;
background: inherit;
border: inherit;
clip-path: polygon(0 0, 0 100%, 100% 0);
}
&.top-aligned {
transform: translateY(
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm))
);
}
&.bottom-aligned {
transform: translateY(
calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1)
);
.menu-arrow {
top: auto;
bottom: 0;
rotate: 225deg;
translate: -50% 50%;
}
}
}
} }

View File

@@ -80,17 +80,8 @@
} }
& .close { & .close {
background: none;
border: none;
padding: 0; padding: 0;
line-height: 0; height: auto;
cursor: pointer;
opacity: 0.85;
color: var(--secondary-text-color);
&:hover {
opacity: 1;
}
} }
} }
@@ -106,7 +97,6 @@
& .modal-footer { & .modal-footer {
padding: var(--unit-6); padding: var(--unit-6);
padding-top: 0; padding-top: 0;
text-align: right;
} }
} }

View File

@@ -33,6 +33,11 @@
} }
} }
&:first-child a {
/* Remove left padding from first pagination link */
padding-left: 0;
}
&.active { &.active {
& a { & a {
background: var(--primary-color); background: var(--primary-color);

View File

@@ -5,22 +5,19 @@
width: 100%; width: 100%;
text-align: left; text-align: left;
/* Scrollable tables */ td,
th {
&.table-scroll { border-bottom: var(--border-width) solid var(--secondary-border-color);
display: block; padding: var(--unit-2) var(--unit-2);
overflow-x: auto;
padding-bottom: 0.75rem;
white-space: nowrap;
} }
& td, th {
& th { font-weight: 500;
border-bottom: var(--border-width) solid var(--border-color); border-bottom-color: var(--border-color);
padding: var(--unit-3) var(--unit-2);
} }
& th { th:first-child,
border-bottom-width: var(--border-width-lg); td:first-child {
padding-left: 0;
} }
} }

View File

@@ -242,6 +242,44 @@
margin-top: var(--unit-4) !important; margin-top: var(--unit-4) !important;
} }
.m-6 {
margin: var(--unit-6) !important;
}
.mb-6 {
margin-bottom: var(--unit-6) !important;
}
.ml-6 {
margin-left: var(--unit-6) !important;
}
.mr-6 {
margin-right: var(--unit-6) !important;
}
.mt-6 {
margin-top: var(--unit-6) !important;
}
.mx-6 {
margin-left: var(--unit-6) !important;
margin-right: var(--unit-6) !important;
}
.my-6 {
margin-bottom: var(--unit-6) !important;
margin-top: var(--unit-6) !important;
}
.ml-auto {
margin-left: auto;
}
.mr-auto {
margin-right: auto;
}
.mx-auto { .mx-auto {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@@ -283,6 +321,10 @@
} }
/* Flex */ /* Flex */
.flex-column {
flex-direction: column;
}
.align-baseline { .align-baseline {
align-items: baseline; align-items: baseline;
} }
@@ -294,3 +336,7 @@
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
.gap-2 {
gap: var(--unit-2);
}

View File

@@ -49,20 +49,22 @@
--body-color-contrast: var(--gray-100); --body-color-contrast: var(--gray-100);
/* Fonts */ /* Fonts */
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", --base-font-family:
Roboto; -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, --mono-font-family:
monospace; "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
--fallback-font-family: "Helvetica Neue", sans-serif; --fallback-font-family: "Helvetica Neue", sans-serif;
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC", --cjk-zh-hans-font-family:
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family); var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC", "Microsoft YaHei", var(--fallback-font-family);
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family); --cjk-zh-hant-font-family:
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans", var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, "Microsoft JhengHei", var(--fallback-font-family);
var(--fallback-font-family); --cjk-jp-font-family:
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic", var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
var(--fallback-font-family); "Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
--cjk-ko-font-family:
var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
--body-font-family: var(--base-font-family), var(--fallback-font-family); --body-font-family: var(--base-font-family), var(--fallback-font-family);
/* Unit sizes */ /* Unit sizes */
@@ -145,6 +147,6 @@
/* Shadows */ /* Shadows */
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; --box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), --box-shadow-lg:
0 4px 6px -4px rgb(0 0 0 / 0.1); 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
} }

View File

@@ -30,16 +30,10 @@
</form> </form>
</main> </main>
{# Tag cloud #} {# Filters #}
<div class="side-panel col-1"> <div class="side-panel col-1 hide-md">
<section aria-labelledby="tags-heading"> {% include 'bookmarks/bundle_section.html' %}
<div class="section-header"> {% include 'bookmarks/tag_section.html' %}
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -36,14 +36,14 @@
{% endif %} {% endif %}
{% if bookmark_list.description_display == 'inline' %} {% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate"> <div class="description inline truncate">
{% if bookmark_item.tag_names %} {% if bookmark_item.tags %}
<span class="tags"> <span class="tags">
{% for tag_name in bookmark_item.tag_names %} {% for tag in bookmark_item.tags %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %} {% if bookmark_item.tags and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %} {% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span> <span>{{ bookmark_item.description }}</span>
{% endif %} {% endif %}
@@ -52,10 +52,10 @@
{% if bookmark_item.description %} {% if bookmark_item.description %}
<div class="description separate">{{ bookmark_item.description }}</div> <div class="description separate">{{ bookmark_item.description }}</div>
{% endif %} {% endif %}
{% if bookmark_item.tag_names %} {% if bookmark_item.tags %}
<div class="tags"> <div class="tags">
{% for tag_name in bookmark_item.tag_names %} {% for tag in bookmark_item.tags %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -77,72 +77,76 @@
{% else %} {% else %}
<span>{{ bookmark_item.display_date }}</span> <span>{{ bookmark_item.display_date }}</span>
{% endif %} {% endif %}
<span>|</span> {% if not bookmark_list.is_preview %}
{% endif %} <span>|</span>
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
{% if bookmark_list.show_edit_action %}
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% endif %} {% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span>
{% endif %} {% endif %}
{% if bookmark_item.has_extra_actions %} {% if not bookmark_list.is_preview %}
<div class="extra-actions"> {# View link is visible for both owned and shared bookmarks #}
<span class="hide-sm">|</span> {% if bookmark_list.show_view_action %}
{% if bookmark_item.show_mark_as_read %} <a href="{{ bookmark_item.details_url }}" class="view-action"
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
class="btn btn-link btn-sm btn-icon" {% endif %}
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?"> {% if bookmark_item.is_editable %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> {# Bookmark owner actions #}
<use xlink:href="#ld-icon-unread"></use> {% if bookmark_list.show_edit_action %}
</svg> <a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
Unread {% endif %}
{% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove
</button> </button>
{% endif %} {% endif %}
{% if bookmark_item.show_unshare %} {% else %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}" {# Shared bookmark actions #}
class="btn btn-link btn-sm btn-icon" <span>Shared by
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?"> <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> </span>
<use xlink:href="#ld-icon-share"></use> {% endif %}
</svg> {% if bookmark_item.has_extra_actions %}
Shared <div class="extra-actions">
</button> <span class="hide-sm">|</span>
{% endif %} {% if bookmark_item.show_mark_as_read %}
{% if bookmark_item.show_notes_button %} <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes"> class="btn btn-link btn-sm btn-icon"
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> ld-confirm-button ld-confirm-question="Mark as read?">
<use xlink:href="#ld-icon-note"></use> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
</svg> <use xlink:href="#ld-icon-unread"></use>
Notes </svg>
</button> Unread
{% endif %} </button>
</div> {% endif %}
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
Shared
</button>
{% endif %}
{% if bookmark_item.show_notes_button %}
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
{% endif %}
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
(function () { (function () {
var bookmarkUrl = window.location; const bookmarkUrl = window.location;
var applicationUrl = '{{ application_url }}';
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&auto_close'; applicationUrl += '&auto_close';

View File

@@ -0,0 +1,25 @@
(function () {
const bookmarkUrl = window.location;
const title =
document.querySelector('title')?.textContent ||
document
.querySelector(`meta[property='og:title']`)
?.getAttribute('content') ||
'';
const description =
document
.querySelector(`meta[name='description']`)
?.getAttribute('content') ||
document
.querySelector(`meta[property='og:description']`)
?.getAttribute(`content`) ||
'';
let applicationUrl = '{{ application_url }}';
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
applicationUrl += '&title=' + encodeURIComponent(title);
applicationUrl += '&description=' + encodeURIComponent(description);
applicationUrl += '&auto_close';
window.open(applicationUrl);
})();

View File

@@ -23,6 +23,9 @@
<option value="bulk_unshare">Unshare</option> <option value="bulk_unshare">Unshare</option>
{% endif %} {% endif %}
<option value="bulk_refresh">Refresh from website</option> <option value="bulk_refresh">Refresh from website</option>
{% if bookmark_list.snapshot_feature_enabled %}
<option value="bulk_snapshot">Create HTML snapshot</option>
{% endif %}
</select> </select>
<div class="tag-autocomplete d-none" ld-tag-autocomplete> <div class="tag-autocomplete d-none" ld-tag-autocomplete>
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small"> <input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">

View File

@@ -0,0 +1,36 @@
{% if not request.user_profile.hide_bundles %}
<section aria-labelledby="bundles-heading">
<div class="section-header no-wrap">
<h2 id="bundles-heading">Bundles</h2>
<div ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="M4 6l16 0"/>
<path d="M4 12l16 0"/>
<path d="M4 18l16 0"/>
</svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
</li>
{% if bookmark_list.search.q %}
<li class="menu-item">
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
bundle from search</a>
</li>
{% endif %}
</ul>
</div>
</div>
<ul class="bundle-menu">
{% for bundle in bundles.bundles %}
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -1,12 +1,12 @@
<div> <div>
{% if details.assets %} {% if details.assets %}
<div class="assets"> <div class="item-list assets">
{% for asset in details.assets %} {% for asset in details.assets %}
<div class="asset" data-asset-id="{{ asset.id }}"> <div class="list-item" data-asset-id="{{ asset.id }}">
<div class="asset-icon {{ asset.icon_classes }}"> <div class="list-item-icon {{ asset.icon_classes }}">
{% include 'bookmarks/details/asset_icon.html' %} {% include 'bookmarks/details/asset_icon.html' %}
</div> </div>
<div class="asset-text {{ asset.text_classes }}"> <div class="list-item-text {{ asset.text_classes }}">
<span class="truncate"> <span class="truncate">
{{ asset.display_name }} {{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %} {% if asset.status == 'pending' %}(queued){% endif %}
@@ -16,7 +16,7 @@
<span class="filesize">{{ asset.file_size|filesizeformat }}</span> <span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %} {% endif %}
</div> </div>
<div class="asset-actions"> <div class="list-item-actions">
{% if asset.file %} {% if asset.file %}
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a> <a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
{% endif %} {% endif %}

View File

@@ -84,8 +84,8 @@
<section class="tags col-1"> <section class="tags col-1">
<h3 id="details-modal-tags-title">Tags</h3> <h3 id="details-modal-tags-title">Tags</h3>
<div> <div>
{% for tag_name in details.bookmark.tag_names %} {% for tag in details.tags %}
<a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="{% url 'linkding:bookmarks.index' %}?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>

View File

@@ -3,8 +3,8 @@
<div class="modal-overlay"></div> <div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2> <h2 class="title">{{ details.bookmark.resolved_title }}</h2>
<button class="close" aria-label="Close dialog"> <button class="btn btn-noborder close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -32,7 +32,7 @@
<input type="hidden" name="disable_turbo" value="true"> <input type="hidden" name="disable_turbo" value="true">
<button ld-confirm-button class="btn btn-error btn-wide" <button ld-confirm-button class="btn btn-error btn-wide"
type="submit" name="remove" value="{{ details.bookmark.id }}"> type="submit" name="remove" value="{{ details.bookmark.id }}">
Delete... Delete
</button> </button>
</form> </form>
</div> </div>

View File

@@ -13,7 +13,7 @@
<h1 id="main-heading">Edit bookmark</h1> <h1 id="main-heading">Edit bookmark</h1>
</div> </div>
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
novalidate> novalidate ld-form-submit>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>
</main> </main>

View File

@@ -1,9 +1,17 @@
<div class="empty"> <div class="empty">
<p class="empty-title h5">You have no bookmarks yet</p> {% if not bookmark_list.query_is_valid %}
<p class="empty-subtitle"> <p class="empty-title h5">Invalid search query</p>
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks, <p class="empty-subtitle">
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the The search query you entered is not valid. Common reasons are unclosed parentheses or a logical operator (AND, OR,
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a NOT) without operands. The error message from the parser is: "{{ bookmark_list.query_error_message }}".
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>. </p>
</p> {% else %}
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
</p>
{% endif %}
</div> </div>

View File

@@ -1,5 +1,6 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load static %} {% load static %}
{% load shared %}
<div class="bookmarks-form"> <div class="bookmarks-form">
{% csrf_token %} {% csrf_token %}
@@ -7,7 +8,7 @@
<div class="form-group {% if form.url.errors %}has-error{% endif %}"> <div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label> <label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
<div class="has-icon-right"> <div class="has-icon-right">
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }} {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
<i class="form-icon loading"></i> <i class="form-icon loading"></i>
</div> </div>
{% if form.url.errors %} {% if form.url.errors %}
@@ -22,8 +23,8 @@
</div> </div>
<div class="form-group" ld-tag-autocomplete> <div class="form-group" ld-tag-autocomplete>
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} {{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint"> <div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). Enter any number of tags separated by space and <strong>without</strong> the hash (#).
If a tag does not exist it will be automatically created. If a tag does not exist it will be automatically created.
</div> </div>
@@ -35,7 +36,8 @@
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label> <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="flex"> <div class="flex">
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button> <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button" <button ld-clear-button data-for="{{ form.title.id_for_label }}"
class="ml-2 btn btn-link suffix-button clear-button"
type="button">Clear type="button">Clear
</button> </button>
</div> </div>
@@ -60,31 +62,31 @@
<span class="form-label d-inline-block">Notes</span> <span class="form-label d-inline-block">Notes</span>
</summary> </summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label> <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }} {{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint"> <div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
Additional notes, supports Markdown. Additional notes, supports Markdown.
</div> </div>
</details> </details>
{{ form.notes.errors }} {{ form.notes.errors }}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox"> <div class="form-checkbox">
{{ form.unread }} {{ form.unread|form_field:"help" }}
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Mark as unread</span> <label for="{{ form.unread.id_for_label }}">Mark as unread</label>
</label> </div>
<div class="form-input-hint"> <div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them. Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div> </div>
</div> </div>
{% if request.user_profile.enable_sharing %} {% if request.user_profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox"> <div class="form-checkbox">
{{ form.shared }} {{ form.shared|form_field:"help" }}
<i class="form-icon"></i> <i class="form-icon"></i>
<span>Share</span> <label for="{{ form.shared.id_for_label }}">Share</label>
</label> </div>
<div class="form-input-hint"> <div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
{% if request.user_profile.enable_public_sharing %} {% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users. Share this bookmark with other registered users and anonymous users.
{% else %} {% else %}
@@ -100,7 +102,7 @@
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide"> <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn">Nevermind</a> <a href="{{ return_url }}" class="btn">Cancel</a>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
/** /**
@@ -227,6 +229,7 @@
} }
}); });
} }
refreshButton.addEventListener('click', refreshMetadata); refreshButton.addEventListener('click', refreshMetadata);
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark

View File

@@ -32,16 +32,10 @@
</form> </form>
</main> </main>
{# Tag cloud #} {# Filters #}
<div class="side-panel col-1"> <div class="side-panel col-1 hide-md">
<section aria-labelledby="tags-heading"> {% include 'bookmarks/bundle_section.html' %}
<div class="section-header"> {% include 'bookmarks/tag_section.html' %}
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -18,18 +18,6 @@
<path d="M21 6l0 13"></path> <path d="M21 6l0 13"></path>
</symbol> </symbol>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-read" 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="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
<path d="M3 6v13"></path>
<path d="M12 6v2m0 4v7"></path>
<path d="M21 6v11"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" <symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -41,18 +29,6 @@
<path d="M8.7 13.3l6.6 3.4"></path> <path d="M8.7 13.3l6.6 3.4"></path>
</symbol> </symbol>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unshare" 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="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" <symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -67,7 +43,7 @@
<header class="container"> <header class="container">
{% if has_toasts %} {% if has_toasts %}
<div class="toasts"> <div class="message-list">
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post"> <form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %} {% csrf_token %}
{% for toast in toast_messages %} {% for toast in toast_messages %}

View File

@@ -12,7 +12,7 @@
<div class="section-header"> <div class="section-header">
<h1 id="main-heading">New bookmark</h1> <h1 id="main-heading">New bookmark</h1>
</div> </div>
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate> <form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>
</main> </main>

View File

@@ -3,7 +3,7 @@
<ul class="pagination"> <ul class="pagination">
{% if prev_link %} {% if prev_link %}
<li class="page-item"> <li class="page-item">
<a href="?{{ prev_link }}" tabindex="-1">Previous</a> <a href="{{ prev_link }}" tabindex="-1">Previous</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -14,7 +14,7 @@
{% for page_link in page_links %} {% for page_link in page_links %}
{% if page_link %} {% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}"> <li class="page-item {% if page_link.active %}active{% endif %}">
<a href="?{{ page_link.link }}">{{ page_link.number }}</a> <a href="{{ page_link.link }}">{{ page_link.number }}</a>
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
@@ -25,7 +25,7 @@
{% if next_link %} {% if next_link %}
<li class="page-item"> <li class="page-item">
<a href="?{{ next_link }}" tabindex="-1">Next</a> <a href="{{ next_link }}" tabindex="-1">Next</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -21,6 +21,9 @@
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822"> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %} {% endif %}
{% if request.user_profile.custom_css %}
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %}
</head> </head>
<body> <body>
<template id="content">{{ content|safe }}</template> <template id="content">{{ content|safe }}</template>

View File

@@ -28,7 +28,7 @@
</main> </main>
{# Filters #} {# Filters #}
<div class="side-panel col-1"> <div class="side-panel col-1 hide-md">
<section aria-labelledby="user-heading"> <section aria-labelledby="user-heading">
<div class="section-header"> <div class="section-header">
<h2 id="user-heading">User</h2> <h2 id="user-heading">User</h2>
@@ -38,14 +38,7 @@
<br> <br>
</div> </div>
</section> </section>
<section aria-labelledby="tags-heading"> {% include 'bookmarks/tag_section.html' %}
<div class="section-header">
<h2 id="tags-heading">Tags</h2>
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,7 @@
{% if tag_cloud.has_selected_tags %} {% if tag_cloud.has_selected_tags %}
<p class="selected-tags"> <p class="selected-tags">
{% for tag in tag_cloud.selected_tags %} {% for tag in tag_cloud.selected_tags %}
<a href="?{% remove_tag_from_query tag.name %}" <a href="?{{ tag.query_string }}"
class="text-bold mr-2"> class="text-bold mr-2">
<span>-{{ tag.name }}</span> <span>-{{ tag.name }}</span>
</a> </a>
@@ -15,16 +15,16 @@
{% for group in tag_cloud.groups %} {% for group in tag_cloud.groups %}
<p class="group"> <p class="group">
{% for tag in group.tags %} {% for tag in group.tags %}
{# Highlight first char of first tag in group #} {# Highlight first char of first tag in group if grouping is enabled #}
{% if forloop.counter == 1 %} {% if group.highlight_first_char and forloop.counter == 1 %}
<a href="?{% add_tag_to_query tag.name %}" <a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item> class="mr-2" data-is-tag-item>
<span <span
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span> class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a> </a>
{% else %} {% else %}
{# Render remaining tags normally #} {# Render tags normally #}
<a href="?{% add_tag_to_query tag.name %}" <a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item> class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span> <span>{{ tag.name }}</span>
</a> </a>

View File

@@ -0,0 +1,26 @@
<section aria-labelledby="tags-heading">
<div class="section-header no-wrap">
<h2 id="tags-heading">Tags</h2>
{% if user.is_authenticated %}
<div ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="M4 6l16 0"/>
<path d="M4 12l16 0"/>
<path d="M4 18l16 0"/>
</svg>
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
</li>
</ul>
</div>
{% endif %}
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>

View File

@@ -0,0 +1,33 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Edit bundle - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="bundles-editor-page grid columns-md-1">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Edit bundle</h1>
</div>
{% include 'shared/messages.html' %}
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
{% csrf_token %}
{% include 'bundles/form.html' %}
</form>
</main>
<aside class="col-2" aria-labelledby="preview-heading">
<div class="section-header">
<h2 id="preview-heading">Preview</h2>
</div>
{% include 'bundles/preview.html' %}
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.name.errors %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
<label for="{{ form.search.id_for_label }}" class="form-label">Search terms</label>
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.search.errors %}
<div class="form-input-hint">
{{ form.search.errors }}
</div>
{% endif %}
<div class="form-input-hint">
All of these search terms must be present in a bookmark to match.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">
At least one of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">
All of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">
None of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-footer d-flex mt-4">
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
</div>
<script>
(function init() {
const bundleForm = document.getElementById('bundle-form');
const previewLink = document.getElementById('preview-link');
let pendingUpdate;
function scheduleUpdate() {
if (pendingUpdate) {
clearTimeout(pendingUpdate);
}
pendingUpdate = setTimeout(() => {
// Ignore if link has been removed (e.g. form submit or navigation)
if (!previewLink.isConnected) {
return;
}
const baseUrl = previewLink.href.split('?')[0];
const params = new URLSearchParams();
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
inputs.forEach(input => {
if (input.name && input.value.trim()) {
params.set(input.name, input.value.trim());
}
});
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
previewLink.click();
}, 500)
}
bundleForm.addEventListener('input', scheduleUpdate);
})();
</script>

View File

@@ -0,0 +1,134 @@
{% extends "bookmarks/layout.html" %}
{% block head %}
{% with page_title="Bundles - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<main class="bundles-page crud-page" aria-labelledby="main-heading">
<div class="crud-header">
<h1 id="main-heading">Bundles</h1>
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
</div>
{% include 'shared/messages.html' %}
{% if bundles %}
<form action="{% url 'linkding:bundles.action' %}" method="post">
{% csrf_token %}
<table class="table crud-table">
<thead>
<tr>
<th>Name</th>
<th class="actions">
<span class="text-assistive">Actions</span>
</th>
</tr>
</thead>
<tbody>
{% for bundle in bundles %}
<tr data-bundle-id="{{ bundle.id }}" draggable="true">
<td>
<div class="d-flex align-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" 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="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</svg>
<span>{{ bundle.name }}</span>
</div>
</td>
<td class="actions">
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
class="btn btn-link">Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="submit" name="move_bundle" value="" class="d-none">
<input type="hidden" name="move_position" value="">
</form>
{% else %}
<div class="empty">
<p class="empty-title h5">You have no bundles yet</p>
<p class="empty-subtitle">Create your first bundle to get started</p>
</div>
{% endif %}
</main>
<script>
(function init() {
const tableBody = document.querySelector(".crud-table tbody");
if (!tableBody) return;
let draggedElement = null;
const rows = tableBody.querySelectorAll('tr');
rows.forEach((item) => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('dragenter', handleDragEnter);
});
function handleDragStart(e) {
draggedElement = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
this.classList.add('drag-start');
setTimeout(() => {
this.classList.remove('drag-start');
this.classList.add('dragging');
}, 0);
}
function handleDragEnd() {
this.classList.remove('dragging');
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
const movePositionInput = document.querySelector('input[name="move_position"]');
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
const form = this.closest('form');
form.requestSubmit(moveBundleInput);
draggedElement = null;
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDragEnter() {
if (this !== draggedElement) {
const listItems = Array.from(tableBody.children);
const draggedIndex = listItems.indexOf(draggedElement);
const currentIndex = listItems.indexOf(this);
if (draggedIndex < currentIndex) {
this.insertAdjacentElement('afterend', draggedElement);
} else {
this.insertAdjacentElement('beforebegin', draggedElement);
}
}
}
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="New bundle - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="bundles-editor-page grid columns-md-1">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">New bundle</h1>
</div>
{% include 'shared/messages.html' %}
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
{% csrf_token %}
{% include 'bundles/form.html' %}
</form>
</main>
<aside class="col-2" aria-labelledby="preview-heading">
<div class="section-header">
<h2 id="preview-heading">Preview</h2>
</div>
{% include 'bundles/preview.html' %}
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
<turbo-frame id="preview">
{% if bookmark_list.is_empty %}
<div>
No bookmarks match the current bundle.
</div>
{% else %}
<div class="mb-4">
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
</div>
{% include 'bookmarks/bookmark_list.html' %}
{% endif %}
</turbo-frame>

View File

@@ -139,6 +139,15 @@
Instead, the tags are shown in an expandable drawer. Instead, the tags are shown in an expandable drawer.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
{{ form.hide_bundles }}
<i class="form-icon"></i> Hide bundles
</label>
<div class="form-input-hint">
Allows to hide the bundles in the side panel if you don't intend to use them.
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label> <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }} {{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
@@ -149,6 +158,18 @@
result will also include bookmarks where a search term matches otherwise. result will also include bookmarks where a search term matches otherwise.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.legacy_search.id_for_label }}" class="form-checkbox">
{{ form.legacy_search }}
<i class="form-icon"></i> Enable legacy search
</label>
<div class="form-input-hint">
Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not).
If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search.
Please report any issues you encounter with the new search on <a href="https://github.com/sissbruecker/linkding/issues" target="_blank">GitHub</a> so they can be addressed.
This option will be removed in a future version.
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label> <label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }} {{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
@@ -261,6 +282,17 @@ reddit.com/r/Music music reddit</pre>
This can be overridden when creating each new bookmark. This can be overridden when creating each new bookmark.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.default_mark_shared.id_for_label }}" class="form-checkbox">
{{ form.default_mark_shared }}
<i class="form-icon"></i> Create bookmarks as shared by default
</label>
<div class="form-input-hint">
Sets the default state for the "Share" option when creating a new bookmark.
Setting this option will make all new bookmarks default to shared.
This can be overridden when creating each new bookmark.
</div>
</div>
<div class="form-group"> <div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}> <details {% if form.custom_css.value %}open{% endif %}>
<summary> <summary>
@@ -374,17 +406,17 @@ reddit.com/r/Music music reddit</pre>
<td>{{ version_info }}</td> <td>{{ version_info }}</td>
</tr> </tr>
<tr> <tr>
<td rowspan="3" style="vertical-align: top">Links</td> <td style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/" <td>
target="_blank">GitHub</a></td> <div class="d-flex flex-column gap-2">
</tr> <a href="https://github.com/sissbruecker/linkding/"
<tr> target="_blank">GitHub</a>
<td><a href="https://linkding.link/" <a href="https://linkding.link/"
target="_blank">Documentation</a></td> target="_blank">Documentation</a>
</tr> <a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
<tr> target="_blank">Changelog</a>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md" </div>
target="_blank">Changelog</a></td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -395,21 +427,25 @@ reddit.com/r/Music music reddit</pre>
(function init() { (function init() {
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}"); const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}"); const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
const defaultMarkShared = document.getElementById("{{ form.default_mark_shared.id_for_label }}");
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}"); const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}"); const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
// Automatically disable public bookmark sharing if bookmark sharing is disabled // Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled
function updatePublicSharing() { function updateSharingOptions() {
if (enableSharing.checked) { if (enableSharing.checked) {
enablePublicSharing.disabled = false; enablePublicSharing.disabled = false;
defaultMarkShared.disabled = false;
} else { } else {
enablePublicSharing.disabled = true; enablePublicSharing.disabled = true;
enablePublicSharing.checked = false; enablePublicSharing.checked = false;
defaultMarkShared.disabled = true;
defaultMarkShared.checked = false;
} }
} }
updatePublicSharing(); updateSharingOptions();
enableSharing.addEventListener("change", updatePublicSharing); enableSharing.addEventListener("change", updateSharingOptions);
// Automatically hide the bookmark description max lines input if the description display is set to inline // Automatically hide the bookmark description max lines input if the description display is set to inline
function updateBookmarkDescriptionMaxLines() { function updateBookmarkDescriptionMaxLines() {

View File

@@ -25,15 +25,33 @@
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
application first. Here's how it works:</p> application first. Here's how it works:</p>
<ul> <ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li> <li>Choose your preferred method for detecting website titles and descriptions below (<a href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect" target="_blank">Help</a>)</li>
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li> <li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li> <li>Click the bookmarklet in your browser's toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li> <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li> <li>After saving the bookmark, the linkding window closes, and you are back on your website</li>
</ul> </ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false" <div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
class="btn btn-primary">📎 Add bookmark</a> <p id="detection-method-label">Choose your preferred bookmarklet:</p>
<label for="detection-method-server" class="form-radio">
<input id="detection-method-server" type="radio" name="bookmarklet-type" value="server" checked>
<i class="form-icon"></i>
Detect title and description on the server
</label>
<label for="detection-method-client" class="form-radio">
<input id="detection-method-client" type="radio" name="bookmarklet-type" value="client">
<i class="form-icon"></i>
Detect title and description in the browser
</label>
</div>
<div class="bookmarklet-container">
<a id="bookmarklet-server" href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a>
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}" data-turbo="false"
class="btn btn-primary" style="display: none;">📎 Add bookmark</a>
</div>
</section> </section>
<section aria-labelledby="rest-api-heading"> <section aria-labelledby="rest-api-heading">
@@ -90,4 +108,28 @@
</p> </p>
</section> </section>
</main> </main>
<script>
(function init() {
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
const serverBookmarklet = document.getElementById('bookmarklet-server');
const clientBookmarklet = document.getElementById('bookmarklet-client');
function toggleBookmarklet() {
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
if (selectedValue === 'server') {
serverBookmarklet.style.display = 'inline-block';
clientBookmarklet.style.display = 'none';
} else {
serverBookmarklet.style.display = 'none';
clientBookmarklet.style.display = 'inline-block';
}
}
toggleBookmarklet();
radioButtons.forEach(function(radio) {
radio.addEventListener('change', toggleBookmarklet);
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,6 @@
{% load i18n %}
{# Force rendering validation errors in English language to align with the rest of the app #}
{% language 'en-us' %}
{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
{% endlanguage %}

View File

@@ -0,0 +1,9 @@
{% if messages %}
<div class="message-list">
{% for message in messages %}
<div class="toast toast-{{ message.tags }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,23 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% block head %}
{% with page_title="Edit tag - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-editor-page">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Edit tag</h1>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% include 'tags/form.html' %}
</form>
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
{% if form.name.errors %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="divider"></div>
<div class="form-group d-flex justify-between">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
</div>

View File

@@ -0,0 +1,125 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% load pagination %}
{% block head %}
{% with page_title="Tags - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-page crud-page">
<main aria-labelledby="main-heading">
<div class="crud-header">
<h1 id="main-heading">Tags</h1>
<div class="d-flex gap-2 ml-auto">
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
</div>
</div>
{% include 'shared/messages.html' %}
{# Filters #}
<div class="crud-filters">
<form method="get" class="mb-2" ld-form-reset>
<div class="form-group">
<label class="form-label text-assistive" for="search">Search tags</label>
<div class="input-group">
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
class="form-input">
<button type="submit" class="btn input-group-btn">Search</button>
</div>
</div>
<div class="form-group">
<label class="form-label text-assistive" for="sort">Sort by</label>
<div class="input-group">
<span class="input-group-addon text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="M3 9l4 -4l4 4m-4 -4v14"/><path
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
</span>
<select id="sort" name="sort" class="form-select" ld-auto-submit>
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
<i class="form-icon"></i> Show only unused tags
</label>
</div>
</form>
{# Tags count #}
<p class="text-secondary text-small m-0">
{% if search or unused_only %}
Showing {{ page.paginator.count }} of {{ total_tags }} tags
{% else %}
{{ total_tags }} tags total
{% endif %}
</p>
</div>
{# Tags List #}
{% if page.object_list %}
<form method="post">
{% csrf_token %}
<table class="table crud-table">
<thead>
<tr>
<th>Name</th>
<th style="width: 25%">Bookmarks</th>
<th class="actions">
<span class="text-assistive">Actions</span>
</th>
</tr>
</thead>
<tbody>
{% for tag in page.object_list %}
<tr>
<td>
{{ tag.name }}
</td>
<td style="width: 25%">
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
{{ tag.bookmark_count }}
</a>
</td>
<td class="actions">
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
ld-confirm-button>
Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% pagination page %}
{% else %}
<div class="empty">
{% if search or unused_only %}
<p class="empty-title h5">No tags found</p>
<p class="empty-subtitle">Try adjusting your search or filters</p>
{% else %}
<p class="empty-title h5">You have no tags yet</p>
<p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
{% endif %}
</div>
{% endif %}
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Merge tags - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-editor-page">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Merge tags</h1>
</div>
<details class="mb-4">
<summary>
<span class="text-bold mb-1">How to merge tags</span>
</summary>
<ol>
<li>Enter the name of the tag you want to keep</li>
<li>Enter the names of tags to merge into the target tag</li>
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
<li>The merged tags are deleted</li>
</ol>
</details>
<form method="post">
{% csrf_token %}
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
<div class="form-input-hint">
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
</div>
{% if form.target_tag.errors %}
<div class="form-input-hint">
{{ form.target_tag.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
tags will be deleted after merging.
</div>
{% if form.merge_tags.errors %}
<div class="form-input-hint">
{{ form.merge_tags.errors }}
</div>
{% endif %}
</div>
<div class="divider"></div>
<div class="form-group d-flex justify-between">
<button type="submit" class="btn btn-primary">Merge Tags</button>
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
</div>
</form>
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "bookmarks/layout.html" %}
{% load shared %}
{% block head %}
{% with page_title="Add tag - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="tags-editor-page">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">New tag</h1>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% include 'tags/form.html' %}
</form>
</main>
</div>
{% endblock %}

View File

@@ -13,18 +13,21 @@ register = template.Library()
"bookmarks/pagination.html", name="pagination", takes_context=True "bookmarks/pagination.html", name="pagination", takes_context=True
) )
def pagination(context, page: Page): def pagination(context, page: Page):
request = context["request"]
base_url = request.path
# remove page number and details from query parameters # remove page number and details from query parameters
query_params = context["request"].GET.copy() query_params = request.GET.copy()
query_params.pop("page", None) query_params.pop("page", None)
query_params.pop("details", None) query_params.pop("details", None)
prev_link = ( prev_link = (
_generate_link(query_params, page.previous_page_number()) _generate_link(base_url, query_params, page.previous_page_number())
if page.has_previous() if page.has_previous()
else None else None
) )
next_link = ( next_link = (
_generate_link(query_params, page.next_page_number()) _generate_link(base_url, query_params, page.next_page_number())
if page.has_next() if page.has_next()
else None else None
) )
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
if page_number == -1: if page_number == -1:
page_links.append(None) page_links.append(None)
else: else:
link = _generate_link(query_params, page_number) link = _generate_link(base_url, query_params, page_number)
page_links.append( page_links.append(
{ {
"active": page_number == page.number, "active": page_number == page.number,
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
return reduce(append_page, visible_pages, []) return reduce(append_page, visible_pages, [])
def _generate_link(query_params: QueryDict, page_number: int) -> str: def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
query_params = query_params.copy()
query_params["page"] = page_number query_params["page"] = page_number
return query_params.urlencode() return f"{base_url}?{query_params.urlencode()}"

View File

@@ -23,53 +23,6 @@ def update_query_string(context, **kwargs):
return query.urlencode() return query.urlencode()
@register.simple_tag(takes_context=True)
def add_tag_to_query(context, tag_name: str):
params = context.request.GET.copy()
# Append to or create query string
query_string = params.get("q", "")
query_string = (query_string + " #" + tag_name).strip()
params.setlist("q", [query_string])
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@register.simple_tag(takes_context=True)
def remove_tag_from_query(context, tag_name: str):
params = context.request.GET.copy()
if params.__contains__("q"):
# Split query string into parts
query_string = params.__getitem__("q")
query_parts = query_string.split()
# Remove tag with hash
tag_name_with_hash = "#" + tag_name
query_parts = [
part
for part in query_parts
if str.lower(part) != str.lower(tag_name_with_hash)
]
# When using lax tag search, also remove tag without hash
profile = context.request.user_profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [
part for part in query_parts if str.lower(part) != str.lower(tag_name)
]
# Rebuild query string
query_string = " ".join(query_parts)
params.__setitem__("q", query_string)
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def replace_query_param(context, **kwargs): def replace_query_param(context, **kwargs):
query = context.request.GET.copy() query = context.request.GET.copy()
@@ -82,11 +35,6 @@ def replace_query_param(context, **kwargs):
return query.urlencode() return query.urlencode()
@register.filter(name="hash_tag")
def hash_tag(tag_name):
return "#" + tag_name
@register.filter(name="first_char") @register.filter(name="first_char")
def first_char(text): def first_char(text):
return text[0] return text[0]
@@ -145,3 +93,30 @@ def render_markdown(context, markdown_text):
linkified_html = bleach.linkify(sanitized_html) linkified_html = bleach.linkify(sanitized_html)
return mark_safe(linkified_html) return mark_safe(linkified_html)
def append_attr(widget, attr, value):
attrs = widget.attrs
if attrs.get(attr):
attrs[attr] += " " + value
else:
attrs[attr] = value
@register.filter("form_field")
def form_field(field, modifier_string):
modifiers = modifier_string.split(",")
has_errors = hasattr(field, "errors") and field.errors
if "validation" in modifiers and has_errors:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
if "help" in modifiers:
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
# Some assistive technologies announce a field as invalid when it has the
# required attribute, even if the user has not interacted with the field
# yet. Set aria-invalid false to prevent this behavior.
if field.field.required and not has_errors:
append_attr(field.field.widget, "aria-invalid", "false")
return field

View File

@@ -17,7 +17,7 @@ from rest_framework import status
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
class BookmarkFactoryMixin: class BookmarkFactoryMixin:
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str): def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title) return Bookmark.objects.get(title=title)
def setup_bundle(
self,
user: User = None,
name: str = None,
search: str = "",
any_tags: str = "",
all_tags: str = "",
excluded_tags: str = "",
order: int = 0,
):
if user is None:
user = self.get_or_create_test_user()
if not name:
name = get_random_string(length=32)
bundle = BookmarkBundle(
name=name,
owner=user,
date_created=timezone.now(),
search=search,
any_tags=any_tags,
all_tags=all_tags,
excluded_tags=excluded_tags,
order=order,
)
bundle.save()
return bundle
def setup_asset( def setup_asset(
self, self,
bookmark: Bookmark, bookmark: Bookmark,
@@ -209,8 +236,17 @@ class BookmarkFactoryMixin:
def read_asset_file(self, asset: BookmarkAsset): def read_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
with open(filepath, "rb") as f:
return f.read() if asset.gzip:
with gzip.open(filepath, "rb") as f:
return f.read()
else:
with open(filepath, "rb") as f:
return f.read()
def get_asset_filesize(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
return os.path.getsize(filepath) if os.path.exists(filepath) else 0
def has_asset_file(self, asset: BookmarkAsset): def has_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
@@ -239,7 +275,7 @@ class BookmarkFactoryMixin:
user.profile.save() user.profile.save()
return user return user
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]): def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
all_tags = [] all_tags = []
for bookmark in bookmarks: for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all()) all_tags = all_tags + list(bookmark.tags.all())

View File

@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertIsNone(asset.id) self.assertIsNone(asset.id)
def test_create_snapshot(self): def test_create_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com") initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(
url="https://example.com", modified=initial_modified
)
asset = assets.create_snapshot_asset(bookmark) asset = assets.create_snapshot_asset(bookmark)
asset.save() asset.save()
asset.date_created = timezone.datetime( asset.date_created = timezone.datetime(
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.file, expected_filename) self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip) self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
def test_create_snapshot_failure(self): def test_create_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com") bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark) asset = assets.create_snapshot_asset(bookmark)
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(saved_file.endswith("aaaa.html.gz")) self.assertTrue(saved_file.endswith("aaaa.html.gz"))
def test_upload_snapshot(self): def test_upload_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com") initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(
url="https://example.com", modified=initial_modified
)
asset = assets.upload_snapshot(bookmark, self.html_content.encode()) asset = assets.upload_snapshot(bookmark, self.html_content.encode())
# should create gzip file in asset folder # should create gzip file in asset folder
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.file, saved_file_name) self.assertEqual(asset.file, saved_file_name)
self.assertTrue(asset.gzip) self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
def test_upload_snapshot_failure(self): def test_upload_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com") bookmark = self.setup_bookmark(url="https://example.com")
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
@disable_logging @disable_logging
def test_upload_asset(self): def test_upload_asset(self):
bookmark = self.setup_bookmark() initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(modified=initial_modified)
file_content = b"test content" file_content = b"test content"
upload_file = SimpleUploadedFile( upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain" "test_file.txt", file_content, content_type="text/plain"
@@ -187,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
# verify file name # verify file name
self.assertTrue(saved_file_name.startswith("upload_")) self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.txt")) self.assertTrue(saved_file_name.endswith("_test_file.txt.gz"))
# file should contain the correct content # file should contain the correct content
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file: self.assertEqual(self.read_asset_file(asset), file_content)
self.assertEqual(file.read(), file_content)
# should create asset # should create asset
self.assertIsNotNone(asset.id) self.assertIsNotNone(asset.id)
@@ -201,9 +220,52 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(asset.display_name, upload_file.name) self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE) self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name) self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
self.assertTrue(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
@disable_logging
def test_upload_gzip_asset(self):
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(modified=initial_modified)
file_content = gzip.compress(b"<html>test content</html>")
upload_file = SimpleUploadedFile(
"test_file.html.gz", file_content, content_type="application/gzip"
)
asset = assets.upload_asset(bookmark, upload_file)
# should create file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(upload_file)
# verify file name
self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.html.gz"))
# file should contain the correct content
self.assertEqual(self.read_asset_file(asset), file_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "application/gzip")
self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, len(file_content)) self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip) self.assertFalse(asset.gzip)
# should update bookmark modified date
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)
@disable_logging @disable_logging
def test_upload_asset_truncates_asset_file_name(self): def test_upload_asset_truncates_asset_file_name(self):
# Create a bookmark with a very long URL # Create a bookmark with a very long URL
@@ -221,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(192, len(saved_file)) self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_")) self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt")) self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
@disable_logging @disable_logging
def test_upload_asset_failure(self): def test_upload_asset_failure(self):
@@ -409,3 +471,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
# Verify that latest_snapshot hasn't changed # Verify that latest_snapshot hasn't changed
self.assertEqual(bookmark.latest_snapshot, latest_asset) self.assertEqual(bookmark.latest_snapshot, latest_asset)
@disable_logging
def test_remove_asset(self):
initial_modified = timezone.datetime(
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
)
bookmark = self.setup_bookmark(modified=initial_modified)
file_content = b"test content for removal"
upload_file = SimpleUploadedFile(
"test_remove_file.txt", file_content, content_type="text/plain"
)
asset = assets.upload_asset(bookmark, upload_file)
asset_filepath = os.path.join(self.assets_dir, asset.file)
# Verify asset and file exist
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
self.assertTrue(os.path.exists(asset_filepath))
bookmark.date_modified = initial_modified
bookmark.save()
# Remove the asset
assets.remove_asset(asset)
# Verify asset is removed from DB
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
# Verify file is removed from disk
self.assertFalse(os.path.exists(asset_filepath))
# Verify bookmark modified date is updated
bookmark.refresh_from_db()
self.assertGreater(bookmark.date_modified, initial_modified)

View File

@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count()) self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count()) self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_index_action_bulk_select_across_respects_bundle(self):
self.setup_numbered_bookmarks(3, prefix="foo")
self.setup_numbered_bookmarks(3, prefix="bar")
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
bundle = self.setup_bundle(search="foo")
self.client.post(
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
{
"bulk_action": ["bulk_delete"],
"bulk_execute": [""],
"bulk_select_across": ["on"],
},
)
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self): def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data() self.setup_bulk_edit_scope_test_data()
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count()) self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count()) self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_archived_action_bulk_select_across_respects_bundle(self):
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
bundle = self.setup_bundle(search="foo")
self.client.post(
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
{
"bulk_action": ["bulk_delete"],
"bulk_execute": [""],
"bulk_select_across": ["on"],
},
)
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_shared_action_bulk_select_across_not_supported(self): def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data() self.setup_bulk_edit_scope_test_data()

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