mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-01 23:43:14 +08:00
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128e1afbce | ||
|
|
d33719dc7c | ||
|
|
357c2d1399 | ||
|
|
9cda5a54d3 | ||
|
|
67d5b17450 | ||
|
|
3ec6c0a7f8 | ||
|
|
86c2bdd138 | ||
|
|
82e5b7d9d5 | ||
|
|
d873342105 | ||
|
|
d519cb74eb | ||
|
|
ff0e6f0ff6 | ||
|
|
77c45c63f3 | ||
|
|
e45e63bfb1 | ||
|
|
004319adae | ||
|
|
d8358f1b12 | ||
|
|
b90ae1b202 | ||
|
|
6c874afff2 | ||
|
|
723b843c13 | ||
|
|
96176ba50e | ||
|
|
f6fb46e8ad | ||
|
|
3804640574 | ||
|
|
8f61fbd04a | ||
|
|
22bc713ed8 | ||
|
|
04248a7fba | ||
|
|
0ff36a94fe | ||
|
|
f83eb25569 | ||
|
|
c746afcf76 | ||
|
|
aaa0f6e119 | ||
|
|
cd215a9237 | ||
|
|
1e56b0e6f3 | ||
|
|
5cc8c9c010 | ||
|
|
846808d870 | ||
|
|
6d9a694756 | ||
|
|
de38e56b3f | ||
|
|
c6fb695af2 | ||
|
|
93faf70b37 | ||
|
|
5330252db9 | ||
|
|
ef00d289f5 | ||
|
|
4e8318d0ae | ||
|
|
a8623d11ef | ||
|
|
8cd992ca30 | ||
|
|
68c104ba54 | ||
|
|
7a4236d179 | ||
|
|
e87304501f | ||
|
|
809e9e02f3 | ||
|
|
2bb33ff96d | ||
|
|
549554cc17 | ||
|
|
20e31397cc | ||
|
|
94ae5fb41c | ||
|
|
2a550e2315 | ||
|
|
a79e8bcd59 | ||
|
|
1710d44df7 | ||
|
|
9967b3e27b | ||
|
|
1672dc0152 | ||
|
|
8be72a5d1f | ||
|
|
bb796c9bdb | ||
|
|
578680c3c1 | ||
|
|
8debb5c5aa | ||
|
|
be752f8146 | ||
|
|
e487cf726a | ||
|
|
f2800efc1a | ||
|
|
9a00ae4b93 | ||
|
|
da9371e33c | ||
|
|
5b3f2f6563 | ||
|
|
04065f8079 | ||
|
|
d986ff0900 | ||
|
|
51a85bbaf1 | ||
|
|
39b911880d | ||
|
|
9db3fa1248 | ||
|
|
77689366a0 | ||
|
|
f2e6014ca4 | ||
|
|
da98929f07 | ||
|
|
1b0684bd6c | ||
|
|
8928c78530 | ||
|
|
61108234b4 | ||
|
|
7b098d4549 | ||
|
|
648e67bd91 | ||
|
|
6bba4f35c8 | ||
|
|
6d9d0e19f1 | ||
|
|
a23c357f2f | ||
|
|
f1acb4f7c9 | ||
|
|
48fc499aed | ||
|
|
2a55800e18 | ||
|
|
e45dffb9cb | ||
|
|
226eb69f8b | ||
|
|
b9bee24047 | ||
|
|
9dfc9b03b4 | ||
|
|
6ab6a031c7 | ||
|
|
1a1092d03a | ||
|
|
4260dfce79 | ||
|
|
2d3bd13a12 | ||
|
|
b037de14c9 | ||
|
|
bbf173c135 | ||
|
|
002fec37d0 | ||
|
|
996e2b6e19 | ||
|
|
6838e45e99 | ||
|
|
5b2a2c2b0d | ||
|
|
988468f3e5 | ||
|
|
3ac0503843 | ||
|
|
6d3755f46a | ||
|
|
25342e5fb6 | ||
|
|
be548a95a0 | ||
|
|
978fba4cf5 | ||
|
|
8a3572ba4b | ||
|
|
b21812c30a | ||
|
|
72fbf6a590 | ||
|
|
31ac796d6d | ||
|
|
2d81ea6f6e | ||
|
|
2e97b13bad | ||
|
|
30f85103cd | ||
|
|
cfe4ff113d | ||
|
|
757dc56277 | ||
|
|
dfbb367857 | ||
|
|
2276832465 | ||
|
|
9d61bdce52 | ||
|
|
1274a9ae0a | ||
|
|
5e7172d17e | ||
|
|
78608135d9 | ||
|
|
51acd1da3f | ||
|
|
016ff2da66 | ||
|
|
77d7e6e66a | ||
|
|
c5a300a435 | ||
|
|
0d4c47eb81 | ||
|
|
17442eeb9a | ||
|
|
2973812626 | ||
|
|
fc48b266a8 | ||
|
|
7b42241026 | ||
|
|
9c648dc67f | ||
|
|
1624128132 | ||
|
|
d1dd85538b | ||
|
|
c5aab3886e | ||
|
|
3f2739e5a6 | ||
|
|
f1ed89a0ba | ||
|
|
a59a7a777c | ||
|
|
9a5c535872 | ||
|
|
e6ebca1436 | ||
|
|
085d67e9f4 | ||
|
|
68825444fb | ||
|
|
b2ca16ec9c | ||
|
|
649f4154e5 | ||
|
|
d2e8a95e3c | ||
|
|
c3149409b0 | ||
|
|
4626fa1c67 | ||
|
|
6548e16baa | ||
|
|
c177de164a | ||
|
|
e9ecad38ac | ||
|
|
621aedd8eb | ||
|
|
4187141ac8 | ||
|
|
cf0cc32090 | ||
|
|
1f2cf21585 | ||
|
|
0dd05b9269 | ||
|
|
5cd6d773db | ||
|
|
d4c348cc5a | ||
|
|
791a5c73ca | ||
|
|
ebed0c050d | ||
|
|
f4dd2b53b5 | ||
|
|
b53fe09c39 | ||
|
|
ff88e726cc | ||
|
|
52400feacf | ||
|
|
c93709b549 | ||
|
|
ba904ed191 | ||
|
|
d1f81fee0e | ||
|
|
7b405c054d | ||
|
|
23ad52f75d | ||
|
|
c3a2305a5f | ||
|
|
d4006026db | ||
|
|
70bdf88791 |
@@ -3,7 +3,6 @@
|
||||
|
||||
# Include files required for build or at runtime
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
@@ -11,12 +10,12 @@
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/pyproject.toml
|
||||
!/rollup.config.mjs
|
||||
!/supervisord.conf
|
||||
!/uv.lock
|
||||
!/uwsgi.ini
|
||||
!/version.txt
|
||||
|
||||
# Remove dev settings
|
||||
/siteroot/settings/dev.py
|
||||
/bookmarks/settings/dev.py
|
||||
|
||||
73
.github/workflows/build-test.yaml
vendored
Normal file
73
.github/workflows/build-test.yaml
vendored
Normal 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
|
||||
89
.github/workflows/build.yaml
vendored
Normal file
89
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from file
|
||||
id: get_version
|
||||
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
|
||||
|
||||
- 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: |
|
||||
sissbruecker/linkding:latest
|
||||
sissbruecker/linkding:${{ env.VERSION }}
|
||||
ghcr.io/sissbruecker/linkding:latest
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
|
||||
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: |
|
||||
sissbruecker/linkding:latest-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||
ghcr.io/sissbruecker/linkding:latest-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-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: |
|
||||
sissbruecker/linkding:latest-plus
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||
ghcr.io/sissbruecker/linkding:latest-plus
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-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: |
|
||||
sissbruecker/linkding:latest-plus-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:latest-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
target: linkding-plus
|
||||
push: true
|
||||
20
.github/workflows/main.yaml
vendored
20
.github/workflows/main.yaml
vendored
@@ -15,7 +15,9 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -25,10 +27,10 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
uv sync
|
||||
mkdir data
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
run: uv run manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,7 +39,9 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -47,12 +51,12 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
uv sync
|
||||
uv run playwright install chromium
|
||||
mkdir data
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py collectstatic
|
||||
uv run manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
run: uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -192,7 +192,11 @@ typings/
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/uBOLite.chromium.mv3
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
||||
# Test setups
|
||||
/scripts/unsecure-test-setups/authelia-oidc/authelia/db.sqlite3
|
||||
/scripts/unsecure-test-setups/authelia-oidc/traefik/certs
|
||||
|
||||
202
CHANGELOG.md
202
CHANGELOG.md
@@ -1,5 +1,207 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
### What's Changed
|
||||
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
|
||||
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
|
||||
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
|
||||
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
|
||||
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
|
||||
|
||||
### New Contributors
|
||||
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
|
||||
|
||||
---
|
||||
|
||||
## v1.38.0 (09/02/2025)
|
||||
|
||||
### What's Changed
|
||||
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
|
||||
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
|
||||
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
|
||||
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
|
||||
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
|
||||
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
|
||||
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
|
||||
|
||||
### New Contributors
|
||||
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
|
||||
|
||||
---
|
||||
|
||||
## v1.37.0 (26/01/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
|
||||
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
|
||||
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
|
||||
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
|
||||
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
|
||||
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
|
||||
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
|
||||
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
|
||||
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
|
||||
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
|
||||
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
|
||||
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
|
||||
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
|
||||
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
|
||||
|
||||
### New Contributors
|
||||
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
|
||||
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
|
||||
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
|
||||
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
|
||||
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
|
||||
|
||||
---
|
||||
|
||||
## v1.36.0 (02/10/2024)
|
||||
|
||||
### What's Changed
|
||||
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
|
||||
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
|
||||
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
|
||||
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
|
||||
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
|
||||
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
|
||||
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
|
||||
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
|
||||
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
|
||||
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
|
||||
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
|
||||
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
|
||||
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
|
||||
|
||||
### New Contributors
|
||||
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
|
||||
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
|
||||
|
||||
---
|
||||
|
||||
## v1.35.0 (23/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
|
||||
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
|
||||
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
|
||||
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
|
||||
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
|
||||
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
|
||||
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
|
||||
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
|
||||
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
|
||||
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
|
||||
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
|
||||
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
|
||||
|
||||
### New Contributors
|
||||
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
|
||||
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
|
||||
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
|
||||
|
||||
---
|
||||
|
||||
## v1.34.0 (16/09/2024)
|
||||
|
||||
### What's Changed
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,16 +1,23 @@
|
||||
.PHONY: serve
|
||||
|
||||
init:
|
||||
uv sync
|
||||
uv run manage.py migrate
|
||||
npm install
|
||||
|
||||
serve:
|
||||
python manage.py runserver
|
||||
uv run manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py process_tasks
|
||||
uv run manage.py run_huey
|
||||
|
||||
test:
|
||||
pytest -n auto
|
||||
uv run pytest -n auto
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
black siteroot
|
||||
uv run black bookmarks
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
||||
|
||||
frontend:
|
||||
npm run dev
|
||||
45
README.md
45
README.md
@@ -58,46 +58,34 @@ Small improvements, bugfixes and documentation improvements are always welcome.
|
||||
|
||||
## Development
|
||||
|
||||
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, `siteroot` is the Django root 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
|
||||
- Python 3.12
|
||||
- Python 3.13
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Node.js
|
||||
|
||||
### Setup
|
||||
|
||||
Create a virtual environment for the application (https://docs.python.org/3/tutorial/venv.html):
|
||||
Initialize the development environment with:
|
||||
```
|
||||
python3 -m venv ~/environments/linkding
|
||||
```
|
||||
Activate the environment for your shell:
|
||||
```
|
||||
source ~/environments/linkding/bin/activate[.csh|.fish]
|
||||
```
|
||||
Within the active environment install the application dependencies from the application folder:
|
||||
```
|
||||
pip3 install -r requirements.txt -r requirements.dev.txt
|
||||
```
|
||||
Install frontend dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
Initialize database:
|
||||
```
|
||||
mkdir -p data
|
||||
python3 manage.py migrate
|
||||
make init
|
||||
```
|
||||
This sets up a virtual environment using uv, installs NPM dependencies and runs migrations to create the initial database.
|
||||
|
||||
Create a user for the frontend:
|
||||
```
|
||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
uv run manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||
|
||||
Run the frontend build for bundling frontend components with:
|
||||
```
|
||||
npm run dev
|
||||
make frontend
|
||||
```
|
||||
Start the Django development server with:
|
||||
|
||||
Then start the Django development server with:
|
||||
```
|
||||
python3 manage.py runserver
|
||||
make serve
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
@@ -117,6 +105,11 @@ make format
|
||||
|
||||
### DevContainers
|
||||
|
||||
> [!WARNING]
|
||||
> The dev container setup is currently broken after switching to uv.
|
||||
> Feel free to contribute a PR if you want to fix it.
|
||||
> The instructions below are outdated until then.
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
||||
|
||||
Once checked out, only the following commands are required to get started:
|
||||
|
||||
@@ -13,6 +13,29 @@
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||
<text x="770.835px" y="299.13px" style="font-family:'HelveticaNeue', 'Helvetica Neue';font-size:50px;fill:rgb(94,94,219);">l<tspan x="782.685px 794.535px 823.085px 849.785px 880.185px 892.035px 920.585px " y="299.13px 299.13px 299.13px 299.13px 299.13px 299.13px 299.13px ">inkding</tspan></text>
|
||||
<g transform="matrix(50,0,0,50,770.835,299.13)">
|
||||
<rect x="0.064" y="-0.716" width="0.088" height="0.716" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,782.693,299.13)">
|
||||
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,794.552,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,823.109,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.716L0.154,-0.716L0.154,-0.308L0.362,-0.519L0.476,-0.519L0.278,-0.326L0.496,-0L0.388,-0L0.216,-0.265L0.154,-0.206L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,848.859,299.13)">
|
||||
<path d="M0.402,-0L0.402,-0.065C0.369,-0.014 0.321,0.012 0.257,0.012C0.216,0.012 0.178,0 0.143,-0.022C0.109,-0.045 0.082,-0.077 0.063,-0.118C0.044,-0.159 0.034,-0.206 0.034,-0.259C0.034,-0.311 0.043,-0.357 0.06,-0.399C0.077,-0.442 0.103,-0.474 0.138,-0.497C0.172,-0.519 0.211,-0.53 0.253,-0.53C0.285,-0.53 0.313,-0.524 0.337,-0.51C0.361,-0.497 0.381,-0.48 0.396,-0.459L0.396,-0.716L0.484,-0.716L0.484,-0L0.402,-0ZM0.125,-0.259C0.125,-0.192 0.139,-0.143 0.167,-0.11C0.194,-0.077 0.228,-0.061 0.266,-0.061C0.304,-0.061 0.337,-0.076 0.363,-0.107C0.39,-0.139 0.404,-0.187 0.404,-0.251C0.404,-0.322 0.39,-0.375 0.363,-0.408C0.335,-0.441 0.302,-0.458 0.262,-0.458C0.223,-0.458 0.19,-0.442 0.164,-0.41C0.138,-0.378 0.125,-0.327 0.125,-0.259Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,877.417,299.13)">
|
||||
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,889.275,299.13)">
|
||||
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,917.833,299.13)">
|
||||
<path d="M0.05,0.043L0.135,0.056C0.139,0.082 0.149,0.101 0.165,0.113C0.187,0.13 0.217,0.138 0.254,0.138C0.295,0.138 0.326,0.13 0.349,0.113C0.371,0.097 0.386,0.074 0.394,0.045C0.398,0.027 0.4,-0.011 0.4,-0.068C0.361,-0.023 0.314,-0 0.256,-0C0.185,-0 0.13,-0.026 0.091,-0.077C0.052,-0.129 0.032,-0.19 0.032,-0.262C0.032,-0.312 0.041,-0.357 0.059,-0.399C0.077,-0.441 0.103,-0.473 0.137,-0.496C0.171,-0.519 0.211,-0.53 0.257,-0.53C0.318,-0.53 0.368,-0.506 0.408,-0.456L0.408,-0.519L0.489,-0.519L0.489,-0.07C0.489,0.01 0.481,0.068 0.464,0.101C0.448,0.135 0.422,0.162 0.386,0.181C0.351,0.201 0.307,0.21 0.255,0.21C0.193,0.21 0.143,0.196 0.105,0.168C0.067,0.141 0.049,0.099 0.05,0.043ZM0.123,-0.269C0.123,-0.201 0.136,-0.151 0.163,-0.12C0.19,-0.088 0.224,-0.073 0.265,-0.073C0.305,-0.073 0.339,-0.088 0.366,-0.119C0.394,-0.15 0.407,-0.199 0.407,-0.266C0.407,-0.329 0.393,-0.377 0.365,-0.409C0.337,-0.441 0.303,-0.458 0.263,-0.458C0.224,-0.458 0.191,-0.442 0.164,-0.41C0.136,-0.378 0.123,-0.331 0.123,-0.269Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -11,7 +11,15 @@ from huey.contrib.djhuey import HUEY as huey
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import TokenProxy
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
Tag,
|
||||
UserProfile,
|
||||
Toast,
|
||||
FeedToken,
|
||||
)
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -206,7 +214,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_display_name",
|
||||
"display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkBundle(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"owner",
|
||||
"order",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"date_created",
|
||||
)
|
||||
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
list_filter = ("owner__username",)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
|
||||
34
bookmarks/api/auth.py
Normal file
34
bookmarks/api/auth.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
||||
|
||||
|
||||
class LinkdingTokenAuthentication(TokenAuthentication):
|
||||
"""
|
||||
Extends DRF TokenAuthentication to add support for multiple keywords
|
||||
"""
|
||||
|
||||
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
|
||||
|
||||
def authenticate(self, request):
|
||||
auth = get_authorization_header(request).split()
|
||||
|
||||
if not auth or auth[0].lower() not in self.keywords:
|
||||
return None
|
||||
|
||||
if len(auth) == 1:
|
||||
msg = _("Invalid token header. No credentials provided.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
elif len(auth) > 2:
|
||||
msg = _("Invalid token header. Token string should not contain spaces.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
token = auth[1].decode()
|
||||
except UnicodeError:
|
||||
msg = _(
|
||||
"Invalid token header. Token string should not contain invalid characters."
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
@@ -1,25 +1,35 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import SimpleRouter, DefaultRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import (
|
||||
BookmarkSerializer,
|
||||
BookmarkAssetSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
BookmarkBundleSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
website_loader,
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
User,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
|
||||
from bookmarks.utils import normalize_url
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +42,7 @@ class BookmarkViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
@@ -46,67 +57,66 @@ class BookmarkViewSet(
|
||||
return super().get_permissions()
|
||||
|
||||
def get_queryset(self):
|
||||
# Provide filtered queryset for list actions
|
||||
user = self.request.user
|
||||
# For list action, use query set that applies search and tag projections
|
||||
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||
if self.action == "list":
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
elif self.action == "archived":
|
||||
return queries.query_archived_bookmarks(user, user.profile, search)
|
||||
elif self.action == "shared":
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmarks(
|
||||
user, self.request.user_profile, search, public_only
|
||||
)
|
||||
|
||||
# For single entity actions use default query set without projections
|
||||
# For single entity actions return user owned bookmarks
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
disable_scraping = "disable_scraping" in self.request.GET
|
||||
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
|
||||
return {
|
||||
"request": self.request,
|
||||
"user": self.request.user,
|
||||
"disable_scraping": disable_scraping,
|
||||
"disable_html_snapshot": disable_html_snapshot,
|
||||
}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
def archived(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def shared(self, request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not request.user.is_authenticated
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
def shared(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def archive(self, request, pk):
|
||||
def archive(self, request: HttpRequest, pk):
|
||||
bookmark = self.get_object()
|
||||
archive_bookmark(bookmark)
|
||||
bookmarks.archive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def unarchive(self, request, pk):
|
||||
def unarchive(self, request: HttpRequest, pk):
|
||||
bookmark = self.get_object()
|
||||
unarchive_bookmark(bookmark)
|
||||
bookmarks.unarchive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def check(self, request):
|
||||
def check(self, request: HttpRequest):
|
||||
url = request.GET.get("url")
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||
normalized_url = normalize_url(url)
|
||||
bookmark = Bookmark.objects.filter(
|
||||
owner=request.user, url_normalized=normalized_url
|
||||
).first()
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
@@ -129,6 +139,119 @@ class BookmarkViewSet(
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def singlefile(self, request: HttpRequest):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
url = request.POST.get("url")
|
||||
file = request.FILES.get("file")
|
||||
|
||||
if not url or not file:
|
||||
return Response(
|
||||
{"error": "Both 'url' and 'file' parameters are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
normalized_url = normalize_url(url)
|
||||
bookmark = Bookmark.objects.filter(
|
||||
owner=request.user, url_normalized=normalized_url
|
||||
).first()
|
||||
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(url=url)
|
||||
bookmark = bookmarks.create_bookmark(
|
||||
bookmark, "", request.user, disable_html_snapshot=True
|
||||
)
|
||||
bookmarks.enhance_with_website_metadata(bookmark)
|
||||
|
||||
assets.upload_snapshot(bookmark, file.read())
|
||||
|
||||
return Response(
|
||||
{"message": "Snapshot uploaded successfully."},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkAssetViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkAssetSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# limit access to assets to the owner of the bookmark for now
|
||||
bookmark = access.bookmark_write(self.request, self.kwargs["bookmark_id"])
|
||||
return BookmarkAsset.objects.filter(
|
||||
bookmark_id=bookmark.id, bookmark__owner=user
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="download")
|
||||
def download(self, request: HttpRequest, bookmark_id, pk):
|
||||
asset = self.get_object()
|
||||
try:
|
||||
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
content_type = asset.content_type
|
||||
file_stream = (
|
||||
gzip.GzipFile(file_path, mode="rb")
|
||||
if asset.gzip
|
||||
else open(file_path, "rb")
|
||||
)
|
||||
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
||||
response["Content-Disposition"] = (
|
||||
f'attachment; filename="{asset.download_name}"'
|
||||
)
|
||||
return response
|
||||
except FileNotFoundError:
|
||||
raise Http404("Asset file does not exist")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def upload(self, request: HttpRequest, bookmark_id):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
bookmark = access.bookmark_write(request, bookmark_id)
|
||||
|
||||
upload_file = request.FILES.get("file")
|
||||
if not upload_file:
|
||||
return Response(
|
||||
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
serializer = self.get_serializer(asset)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Failed to upload asset."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
assets.remove_asset(instance)
|
||||
|
||||
|
||||
class TagViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
@@ -136,6 +259,7 @@ class TagViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = TagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -148,11 +272,48 @@ class TagViewSet(
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
@action(methods=["get"], detail=False)
|
||||
def profile(self, request):
|
||||
def profile(self, request: HttpRequest):
|
||||
return Response(UserProfileSerializer(request.user.profile).data)
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||
router.register(r"tags", TagViewSet, basename="tag")
|
||||
router.register(r"user", UserViewSet, basename="user")
|
||||
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>/
|
||||
# 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
|
||||
default_router = DefaultRouter()
|
||||
|
||||
bookmark_router = SimpleRouter()
|
||||
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
|
||||
|
||||
tag_router = SimpleRouter()
|
||||
tag_router.register("", TagViewSet, basename="tag")
|
||||
|
||||
user_router = SimpleRouter()
|
||||
user_router.register("", UserViewSet, basename="user")
|
||||
|
||||
bundle_router = SimpleRouter()
|
||||
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
|
||||
|
||||
bookmark_asset_router = SimpleRouter()
|
||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.db.models import Max, prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services.bookmarks import (
|
||||
create_bookmark,
|
||||
update_bookmark,
|
||||
enhance_with_website_metadata,
|
||||
from bookmarks.models import (
|
||||
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.wayback import generate_fallback_webarchive_url
|
||||
from bookmarks.utils import app_version
|
||||
|
||||
|
||||
class TagListField(serializers.ListField):
|
||||
@@ -24,6 +29,37 @@ class BookmarkListSerializer(ListSerializer):
|
||||
return super().to_representation(data)
|
||||
|
||||
|
||||
class EmtpyField(serializers.ReadOnlyField):
|
||||
def to_representation(self, value):
|
||||
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 Meta:
|
||||
model = Bookmark
|
||||
@@ -49,6 +85,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"website_title",
|
||||
@@ -56,20 +93,15 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
list_serializer_class = BookmarkListSerializer
|
||||
|
||||
# Override optional char fields to provide default value
|
||||
title = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
is_archived = serializers.BooleanField(required=False, default=False)
|
||||
unread = serializers.BooleanField(required=False, default=False)
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
# Custom tag_names field to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField(required=False)
|
||||
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
|
||||
favicon_url = serializers.SerializerMethodField()
|
||||
preview_image_url = serializers.SerializerMethodField()
|
||||
web_archive_snapshot_url = serializers.SerializerMethodField()
|
||||
# Add dummy website title and description fields for backwards compatibility but keep them empty
|
||||
website_title = serializers.SerializerMethodField()
|
||||
website_description = serializers.SerializerMethodField()
|
||||
website_title = EmtpyField()
|
||||
website_description = EmtpyField()
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
@@ -87,43 +119,75 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def get_website_title(self, obj: Bookmark):
|
||||
return None
|
||||
def get_web_archive_snapshot_url(self, obj: Bookmark):
|
||||
if obj.web_archive_snapshot_url:
|
||||
return obj.web_archive_snapshot_url
|
||||
|
||||
def get_website_description(self, obj: Bookmark):
|
||||
return None
|
||||
return generate_fallback_webarchive_url(obj.url, obj.date_added)
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
bookmark.url = validated_data["url"]
|
||||
bookmark.title = validated_data["title"]
|
||||
bookmark.description = validated_data["description"]
|
||||
bookmark.notes = validated_data["notes"]
|
||||
bookmark.is_archived = validated_data["is_archived"]
|
||||
bookmark.unread = validated_data["unread"]
|
||||
bookmark.shared = validated_data["shared"]
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
tag_names = validated_data.pop("tag_names", [])
|
||||
tag_string = build_tag_string(tag_names)
|
||||
bookmark = Bookmark(**validated_data)
|
||||
|
||||
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
disable_scraping = self.context.get("disable_scraping", False)
|
||||
disable_html_snapshot = self.context.get("disable_html_snapshot", False)
|
||||
|
||||
saved_bookmark = bookmarks.create_bookmark(
|
||||
bookmark,
|
||||
tag_string,
|
||||
self.context["user"],
|
||||
disable_html_snapshot=disable_html_snapshot,
|
||||
)
|
||||
# Unless scraping is explicitly disabled, enhance bookmark with website
|
||||
# metadata to preserve backwards compatibility with clients that expect
|
||||
# title and description to be populated automatically when left empty
|
||||
if not self.context.get("disable_scraping", False):
|
||||
enhance_with_website_metadata(saved_bookmark)
|
||||
if not disable_scraping:
|
||||
bookmarks.enhance_with_website_metadata(saved_bookmark)
|
||||
return saved_bookmark
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
# Update fields if they were provided in the payload
|
||||
for key in ["url", "title", "description", "notes", "unread", "shared"]:
|
||||
if key in validated_data:
|
||||
setattr(instance, key, validated_data[key])
|
||||
tag_names = validated_data.pop("tag_names", instance.tag_names)
|
||||
tag_string = build_tag_string(tag_names)
|
||||
|
||||
# Use tag string from payload, or use bookmark's current tags as fallback
|
||||
tag_string = build_tag_string(instance.tag_names)
|
||||
if "tag_names" in validated_data:
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
for field_name, field in self.fields.items():
|
||||
if not field.read_only and field_name in validated_data:
|
||||
setattr(instance, field_name, validated_data[field_name])
|
||||
|
||||
return update_bookmark(instance, tag_string, self.context["user"])
|
||||
return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
|
||||
|
||||
def validate(self, attrs):
|
||||
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||
# updating the existing bookmark instead. When editing a bookmark,
|
||||
# there is no assumption that it would update a different bookmark if
|
||||
# the URL is a duplicate, so raise a validation error in that case.
|
||||
if self.instance and "url" in attrs:
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
if is_duplicate:
|
||||
raise serializers.ValidationError(
|
||||
{"url": "A bookmark with this URL already exists."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookmarkAssetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookmarkAsset
|
||||
fields = [
|
||||
"id",
|
||||
"bookmark",
|
||||
"date_created",
|
||||
"file_size",
|
||||
"asset_type",
|
||||
"content_type",
|
||||
"display_name",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
@@ -151,4 +215,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"search_preferences",
|
||||
"version",
|
||||
]
|
||||
|
||||
version = serializers.ReadOnlyField(default=app_version)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import BookmarkSearch, Toast
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import Toast
|
||||
|
||||
|
||||
def toasts(request):
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
mock_website_metadata = website_loader.WebsiteMetadata(
|
||||
url="https://example.com",
|
||||
title="Example Domain",
|
||||
description="This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.",
|
||||
preview_image=None,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.website_loader_patch = patch.object(
|
||||
website_loader, "load_website_metadata", return_value=mock_website_metadata
|
||||
)
|
||||
self.website_loader_patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.website_loader_patch.stop()
|
||||
|
||||
def test_enter_url_prefills_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
url.fill("https://example.com")
|
||||
expect(title).to_have_value("Example Domain")
|
||||
expect(description).to_have_value(
|
||||
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
|
||||
)
|
||||
|
||||
def test_enter_url_does_not_overwrite_modified_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
title.fill("Modified title")
|
||||
description.fill("Modified description")
|
||||
url.fill("https://example.com")
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(title).to_have_value("Modified title")
|
||||
expect(description).to_have_value("Modified description")
|
||||
|
||||
def test_with_initial_url_prefills_title_and_description(self):
|
||||
with sync_playwright() as p:
|
||||
page_url = reverse("bookmarks:new") + f"?url={quote('https://example.com')}"
|
||||
page = self.open(page_url, p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(url).to_have_value("https://example.com")
|
||||
expect(title).to_have_value("Example Domain")
|
||||
expect(description).to_have_value(
|
||||
"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission."
|
||||
)
|
||||
|
||||
def test_with_initial_url_title_description_does_not_overwrite_title_and_description(
|
||||
self,
|
||||
):
|
||||
with sync_playwright() as p:
|
||||
page_url = (
|
||||
reverse("bookmarks:new")
|
||||
+ f"?url={quote('https://example.com')}&title=Initial+title&description=Initial+description"
|
||||
)
|
||||
page = self.open(page_url, p)
|
||||
url = page.get_by_label("URL")
|
||||
title = page.get_by_label("Title")
|
||||
description = page.get_by_label("Description")
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
|
||||
expect(url).to_have_value("https://example.com")
|
||||
expect(title).to_have_value("Initial title")
|
||||
expect(description).to_have_value("Initial description")
|
||||
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(
|
||||
title="Existing title",
|
||||
description="Existing description",
|
||||
notes="Existing notes",
|
||||
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
|
||||
unread=True,
|
||||
)
|
||||
tag_names = " ".join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label("URL").fill(existing_bookmark.url)
|
||||
# Already bookmarked hint should be visible
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(
|
||||
existing_bookmark.title, page.get_by_label("Title").input_value()
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.description,
|
||||
page.get_by_label("Description").input_value(),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.notes, page.get_by_label("Notes").input_value()
|
||||
)
|
||||
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
|
||||
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
|
||||
|
||||
# Enter non-bookmarked URL
|
||||
page.get_by_label("URL").fill("https://example.com/unknown")
|
||||
# Already bookmarked hint should be hidden
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||
state="hidden", timeout=2000
|
||||
)
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
notes="Existing notes", description="Existing description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:new"), p)
|
||||
|
||||
details = page.locator("details.notes")
|
||||
expect(details).not_to_have_attribute("open", value="")
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
url = (
|
||||
reverse("bookmarks:new")
|
||||
+ "?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
)
|
||||
page = self.open(url, p)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
from bookmarks.views import access
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,10 +31,16 @@ def sanitize(text: str):
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
bundle = None
|
||||
bundle_id = request.GET.get("bundle")
|
||||
if bundle_id:
|
||||
bundle = access.bundle_read(request, bundle_id)
|
||||
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
bundle=bundle,
|
||||
)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
@@ -74,7 +81,7 @@ class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.all", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -87,7 +94,7 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
).filter(unread=True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.unread", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -100,7 +107,7 @@ class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||
return reverse("linkding:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
@@ -114,4 +121,4 @@ class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.public_shared")
|
||||
return reverse("linkding:feeds.public_shared")
|
||||
|
||||
199
bookmarks/forms.py
Normal file
199
bookmarks/forms.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from django import forms
|
||||
from django.forms.utils import ErrorList
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
parse_tag_string,
|
||||
sanitize_tag_name,
|
||||
)
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.utils import normalize_url
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
|
||||
class CustomErrorList(ErrorList):
|
||||
template_name = "shared/error_list.html"
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
def __init__(self, request: HttpRequest, instance: Bookmark = None):
|
||||
self.request = request
|
||||
|
||||
initial = None
|
||||
if instance is None and request.method == "GET":
|
||||
initial = {
|
||||
"url": request.GET.get("url"),
|
||||
"title": request.GET.get("title"),
|
||||
"description": request.GET.get("description"),
|
||||
"notes": request.GET.get("notes"),
|
||||
"tag_string": request.GET.get("tags"),
|
||||
"auto_close": "auto_close" in request.GET,
|
||||
"unread": request.user_profile.default_mark_unread,
|
||||
"shared": request.user_profile.default_mark_shared,
|
||||
}
|
||||
if instance is not None and request.method == "GET":
|
||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||
data = request.POST if request.method == "POST" else None
|
||||
super().__init__(
|
||||
data, instance=instance, initial=initial, error_class=CustomErrorList
|
||||
)
|
||||
|
||||
@property
|
||||
def is_auto_close(self):
|
||||
return self.data.get("auto_close", False) == "True" or self.initial.get(
|
||||
"auto_close", False
|
||||
)
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
|
||||
def save(self, commit=False):
|
||||
tag_string = convert_tag_string(self.data["tag_string"])
|
||||
bookmark = super().save(commit=False)
|
||||
if self.instance.pk:
|
||||
return update_bookmark(bookmark, tag_string, self.request.user)
|
||||
else:
|
||||
return create_bookmark(bookmark, tag_string, self.request.user)
|
||||
|
||||
def clean_url(self):
|
||||
# When creating a bookmark, the service logic prevents duplicate URLs by
|
||||
# updating the existing bookmark instead, which is also communicated in
|
||||
# the form's UI. When editing a bookmark, there is no assumption that
|
||||
# it would update a different bookmark if the URL is a duplicate, so
|
||||
# raise a validation error in that case.
|
||||
url = self.cleaned_data["url"]
|
||||
if self.instance.pk:
|
||||
normalized_url = normalize_url(url)
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(
|
||||
owner=self.instance.owner, url_normalized=normalized_url
|
||||
)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
if is_duplicate:
|
||||
raise forms.ValidationError("A bookmark with this URL already exists.")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
return tag_string.replace(" ", ",")
|
||||
|
||||
|
||||
class TagForm(forms.ModelForm):
|
||||
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
|
||||
@@ -1,79 +1,173 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
|
||||
|
||||
let confirmId = 0;
|
||||
|
||||
function nextConfirmId() {
|
||||
return `confirm-${confirmId++}`;
|
||||
}
|
||||
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
element.addEventListener("click", this.onClick);
|
||||
this.element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.reset();
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
}
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
Behavior.interacting = true;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
);
|
||||
iconElement.style.width = "16px";
|
||||
iconElement.style.height = "16px";
|
||||
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
||||
container.append(iconElement);
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
|
||||
const question = this.element.getAttribute("ld-confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const buttonClasses = Array.from(this.element.classList.values())
|
||||
.filter((cls) => cls.startsWith("btn"))
|
||||
.join(" ");
|
||||
|
||||
const cancelButton = document.createElement(this.element.nodeName);
|
||||
cancelButton.type = "button";
|
||||
cancelButton.innerText = question ? "No" : "Cancel";
|
||||
cancelButton.className = `${buttonClasses} mr-1`;
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.type;
|
||||
confirmButton.name = this.element.name;
|
||||
confirmButton.value = this.element.value;
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = buttonClasses;
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
container.append(cancelButton, confirmButton);
|
||||
this.container = container;
|
||||
|
||||
this.element.before(container);
|
||||
this.element.classList.add("d-none");
|
||||
}
|
||||
|
||||
reset() {
|
||||
setTimeout(() => {
|
||||
Behavior.interacting = false;
|
||||
if (this.container) {
|
||||
this.container.remove();
|
||||
this.container = null;
|
||||
}
|
||||
this.element.classList.remove("d-none");
|
||||
open() {
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "dropdown confirm-dropdown active";
|
||||
|
||||
const confirmId = nextConfirmId();
|
||||
const questionId = `${confirmId}-question`;
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "menu with-arrow";
|
||||
menu.role = "alertdialog";
|
||||
menu.setAttribute("aria-modal", "true");
|
||||
menu.setAttribute("aria-labelledby", questionId);
|
||||
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
|
||||
|
||||
const question = document.createElement("span");
|
||||
question.id = questionId;
|
||||
question.textContent =
|
||||
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
|
||||
question.style.fontWeight = "bold";
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.textContent = "Cancel";
|
||||
cancelButton.type = "button";
|
||||
cancelButton.className = "btn";
|
||||
cancelButton.tabIndex = 0;
|
||||
cancelButton.addEventListener("click", () => this.close());
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.textContent = "Confirm";
|
||||
confirmButton.type = "submit";
|
||||
confirmButton.name = this.element.name;
|
||||
confirmButton.value = this.element.value;
|
||||
confirmButton.className = "btn btn-error";
|
||||
confirmButton.addEventListener("click", () => this.confirm());
|
||||
|
||||
const arrow = document.createElement("div");
|
||||
arrow.className = "menu-arrow";
|
||||
|
||||
menu.append(question, cancelButton, confirmButton, arrow);
|
||||
dropdown.append(menu);
|
||||
document.body.append(dropdown);
|
||||
|
||||
this.positionController = new AnchorPositionController(this.element, menu);
|
||||
this.focusTrap = new FocusTrapController(menu);
|
||||
this.dropdown = dropdown;
|
||||
this.opened = true;
|
||||
}
|
||||
|
||||
onMenuKeyDown(event) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.element.closest("form").requestSubmit(this.element);
|
||||
this.close();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.opened) return;
|
||||
this.positionController.destroy();
|
||||
this.focusTrap.destroy();
|
||||
this.dropdown.remove();
|
||||
this.element.focus({ focusVisible: isKeyboardActive() });
|
||||
this.opened = false;
|
||||
}
|
||||
}
|
||||
|
||||
class AnchorPositionController {
|
||||
constructor(anchor, overlay) {
|
||||
this.anchor = anchor;
|
||||
this.overlay = overlay;
|
||||
|
||||
this.handleScroll = this.handleScroll.bind(this);
|
||||
window.addEventListener("scroll", this.handleScroll, { capture: true });
|
||||
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
if (this.debounce) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debounce = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.updatePosition();
|
||||
this.debounce = false;
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
const anchorRect = this.anchor.getBoundingClientRect();
|
||||
const overlayRect = this.overlay.getBoundingClientRect();
|
||||
const bufferX = 10;
|
||||
const bufferY = 30;
|
||||
|
||||
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
|
||||
const initialLeft = left;
|
||||
const overflowLeft = left < bufferX;
|
||||
const overflowRight =
|
||||
left + overlayRect.width > window.innerWidth - bufferX;
|
||||
|
||||
if (overflowLeft) {
|
||||
left = bufferX;
|
||||
} else if (overflowRight) {
|
||||
left = window.innerWidth - overlayRect.width - bufferX;
|
||||
}
|
||||
|
||||
const delta = initialLeft - left;
|
||||
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
|
||||
|
||||
let top = anchorRect.bottom;
|
||||
const overflowBottom =
|
||||
top + overlayRect.height > window.innerHeight - bufferY;
|
||||
|
||||
if (overflowBottom) {
|
||||
top = anchorRect.top - overlayRect.height;
|
||||
this.overlay.classList.remove("top-aligned");
|
||||
this.overlay.classList.add("bottom-aligned");
|
||||
} else {
|
||||
this.overlay.classList.remove("bottom-aligned");
|
||||
this.overlay.classList.add("top-aligned");
|
||||
}
|
||||
|
||||
this.overlay.style.left = `${left}px`;
|
||||
this.overlay.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("scroll", this.handleScroll, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
||||
|
||||
@@ -1,60 +1,22 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
|
||||
import { ModalBehavior } from "./modal";
|
||||
|
||||
class DetailsModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
class DetailsModalBehavior extends ModalBehavior {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.element.dataset.closeUrl;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
|
||||
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
|
||||
this.buttonLink = element.querySelector("a:has(button.close)");
|
||||
|
||||
this.overlayLink.addEventListener("click", this.onClose);
|
||||
this.buttonLink.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlayLink.removeEventListener("click", this.onClose);
|
||||
this.buttonLink.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
|
||||
const closeUrl = this.overlayLink.href;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.element.dataset.bookmarkId;
|
||||
setAfterPageLoadFocusTarget(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click then JS is enabled
|
||||
this.element.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.element.addEventListener("keydown", this.onEscape);
|
||||
this.element.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
this.element.removeEventListener("keydown", this.onEscape);
|
||||
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.element.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.element.classList.remove("active");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.element.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||
|
||||
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { ModalBehavior } from "./modal";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
|
||||
class FilterDrawerTriggerBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "drawer", "filter-drawer");
|
||||
modal.setAttribute("ld-filter-drawer", "");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDrawerBehavior extends ModalBehavior {
|
||||
init() {
|
||||
// Teleport content before creating focus trap, otherwise it will not detect
|
||||
// focusable content elements
|
||||
this.teleport();
|
||||
super.init();
|
||||
// Add active class to start slide-in animation
|
||||
this.element.classList.add("active");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
// Always close on destroy to restore drawer content to original location
|
||||
// before turbo caches DOM
|
||||
this.doClose();
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.element.querySelector(".content");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
const content = this.element.querySelector(".content");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
this.teleportBack();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
||||
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
||||
130
bookmarks/frontend/behaviors/focus-utils.js
Normal file
130
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,130 @@
|
||||
let keyboardActive = false;
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
() => {
|
||||
keyboardActive = true;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"mousedown",
|
||||
() => {
|
||||
keyboardActive = false;
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
export function isKeyboardActive() {
|
||||
return keyboardActive;
|
||||
}
|
||||
|
||||
export class FocusTrapController {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.focusableElements = this.element.querySelectorAll(
|
||||
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
this.firstFocusableElement = this.focusableElements[0];
|
||||
this.lastFocusableElement =
|
||||
this.focusableElements[this.focusableElements.length - 1];
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === this.firstFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.lastFocusableElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === this.lastFocusableElement) {
|
||||
event.preventDefault();
|
||||
this.firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let afterPageLoadFocusTarget = [];
|
||||
let firstPageLoad = true;
|
||||
|
||||
export function setAfterPageLoadFocusTarget(...targets) {
|
||||
afterPageLoadFocusTarget = targets;
|
||||
}
|
||||
|
||||
function programmaticFocus(element) {
|
||||
// Ensure element is focusable
|
||||
// Hide focus outline if element is not focusable by default - might
|
||||
// reconsider this later
|
||||
const isFocusable = element.tabIndex >= 0;
|
||||
if (!isFocusable) {
|
||||
// Apparently the default tabIndex is -1, even though an element is still
|
||||
// not focusable with that. Setting an explicit -1 also sets the attribute
|
||||
// and the element becomes focusable.
|
||||
element.tabIndex = -1;
|
||||
// `focusVisible` is not supported in all browsers, so hide the outline manually
|
||||
element.style["outline"] = "none";
|
||||
}
|
||||
element.focus({
|
||||
focusVisible: isKeyboardActive() && isFocusable,
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Register global listener for navigation and try to focus an element that
|
||||
// results in a meaningful announcement.
|
||||
document.addEventListener("turbo:load", () => {
|
||||
// Ignore initial page load to let the browser handle announcements
|
||||
if (firstPageLoad) {
|
||||
firstPageLoad = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if there is a modal dialog, which should handle its own focus
|
||||
const modal = document.querySelector("[aria-modal='true']");
|
||||
if (modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is an explicit focus target for the next page load
|
||||
for (const target of afterPageLoadFocusTarget) {
|
||||
const element = document.querySelector(target);
|
||||
if (element) {
|
||||
programmaticFocus(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
afterPageLoadFocusTarget = [];
|
||||
|
||||
// If there is some autofocus element, let the browser handle it
|
||||
const autofocus = document.querySelector("[autofocus]");
|
||||
if (autofocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is a toast as a result of some action, focus it
|
||||
const toast = document.querySelector(".toast");
|
||||
if (toast) {
|
||||
programmaticFocus(toast);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise go with main
|
||||
const main = document.querySelector("main");
|
||||
if (main) {
|
||||
programmaticFocus(main);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,27 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class FormSubmit extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Check for Ctrl/Cmd + Enter combination
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.element.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -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 {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -51,5 +103,7 @@ class UploadButton extends Behavior {
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form-submit", FormSubmit);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-form-reset", FormResetBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
|
||||
@@ -54,8 +54,6 @@ export class Behavior {
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
}
|
||||
|
||||
81
bookmarks/frontend/behaviors/modal.js
Normal file
81
bookmarks/frontend/behaviors/modal.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Behavior } from "./index";
|
||||
import { FocusTrapController } from "./focus-utils";
|
||||
|
||||
export class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.overlay = element.querySelector(".modal-overlay");
|
||||
this.closeButton = element.querySelector(".modal-header .close");
|
||||
|
||||
this.overlay.addEventListener("click", this.onClose);
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.overlay.removeEventListener("click", this.onClose);
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.removeScrollLock();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupScrollLock();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupScrollLock() {
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
removeScrollLock() {
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.removeScrollLock();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||
import "../components/SearchAutocomplete.js";
|
||||
|
||||
class SearchAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
@@ -10,26 +10,20 @@ class SearchAutocomplete extends Behavior {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new SearchAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
name: "q",
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
value: input.value,
|
||||
linkTarget: input.dataset.linkTarget,
|
||||
mode: input.dataset.mode,
|
||||
search: {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
},
|
||||
},
|
||||
});
|
||||
const autocomplete = document.createElement("ld-search-autocomplete");
|
||||
autocomplete.name = "q";
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
|
||||
autocomplete.mode = input.dataset.mode || "";
|
||||
autocomplete.search = {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
};
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import "../components/TagAutocomplete.js";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
@@ -10,21 +10,16 @@ class TagAutocomplete extends Behavior {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
const autocomplete = document.createElement("ld-tag-autocomplete");
|
||||
autocomplete.id = input.id;
|
||||
autocomplete.name = input.name;
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
|
||||
autocomplete.variant = input.getAttribute("variant") || "default";
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class TagModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>Tags</h2>
|
||||
<button class="close" aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tagCloud = document.querySelector(".tag-cloud");
|
||||
const tagCloudContainer = tagCloud.parentElement;
|
||||
|
||||
const content = modal.querySelector(".content");
|
||||
content.appendChild(tagCloud);
|
||||
|
||||
const overlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".close");
|
||||
overlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
this.modal = modal;
|
||||
this.tagCloud = tagCloud;
|
||||
this.tagCloudContainer = tagCloudContainer;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.remove();
|
||||
this.tagCloudContainer.appendChild(this.tagCloud);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-modal", TagModalBehavior);
|
||||
@@ -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>
|
||||
304
bookmarks/frontend/components/SearchAutocomplete.js
Normal file
304
bookmarks/frontend/components/SearchAutocomplete.js
Normal 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);
|
||||
194
bookmarks/frontend/components/TagAutocomplete.js
Normal file
194
bookmarks/frontend/components/TagAutocomplete.js
Normal 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.input.dispatchEvent(new CustomEvent("change", { 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);
|
||||
@@ -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>
|
||||
@@ -3,15 +3,13 @@ import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { api } from "./api";
|
||||
export { cache } from "./cache";
|
||||
|
||||
@@ -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) {
|
||||
if (!text || text.length <= 30) return text;
|
||||
|
||||
|
||||
18
bookmarks/migrations/0042_userprofile_custom_css_hash.py
Normal file
18
bookmarks/migrations/0042_userprofile_custom_css_hash.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-28 08:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0041_merge_metadata"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="custom_css_hash",
|
||||
field=models.CharField(blank=True, max_length=32),
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
18
bookmarks/migrations/0043_userprofile_collapse_side_panel.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-02 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0042_userprofile_custom_css_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="collapse_side_panel",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
46
bookmarks/migrations/0044_bookmark_latest_snapshot.py
Normal file
46
bookmarks/migrations/0044_bookmark_latest_snapshot.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 12:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
# Update the latest snapshot for each bookmark
|
||||
Bookmark = apps.get_model("bookmarks", "bookmark")
|
||||
BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset")
|
||||
|
||||
latest_snapshots = (
|
||||
BookmarkAsset.objects.filter(
|
||||
bookmark=OuterRef("pk"), asset_type="snapshot", status="complete"
|
||||
)
|
||||
.order_by("-date_created")
|
||||
.values("id")[:1]
|
||||
)
|
||||
Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots))
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0043_userprofile_collapse_side_panel"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="latest_snapshot",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="latest_snapshot",
|
||||
to="bookmarks.bookmarkasset",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal 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),
|
||||
),
|
||||
]
|
||||
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal 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,
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,12 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property
|
||||
from typing import List
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
@@ -13,7 +14,7 @@ from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import QueryDict
|
||||
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.utils import unique, normalize_url
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
date_added = models.DateTimeField()
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -39,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
# 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
|
||||
names = unique(names, 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):
|
||||
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)
|
||||
description = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
@@ -69,8 +71,15 @@ class Bookmark(models.Model):
|
||||
date_added = models.DateTimeField()
|
||||
date_modified = models.DateTimeField()
|
||||
date_accessed = models.DateTimeField(blank=True, null=True)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
tags = models.ManyToManyField(Tag)
|
||||
latest_snapshot = models.ForeignKey(
|
||||
"BookmarkAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="latest_snapshot",
|
||||
)
|
||||
|
||||
@property
|
||||
def resolved_title(self):
|
||||
@@ -88,10 +97,27 @@ class Bookmark(models.Model):
|
||||
names = [tag.name for tag in self.tags.all()]
|
||||
return sorted(names)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.url_normalized = normalize_url(self.url)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Bookmark)
|
||||
def bookmark_deleted(sender, instance, **kwargs):
|
||||
if instance.preview_image_file:
|
||||
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
|
||||
if os.path.isfile(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Failed to delete preview image: {filepath}", exc_info=error
|
||||
)
|
||||
|
||||
|
||||
class BookmarkAsset(models.Model):
|
||||
TYPE_SNAPSHOT = "snapshot"
|
||||
TYPE_UPLOAD = "upload"
|
||||
@@ -112,6 +138,14 @@ class BookmarkAsset(models.Model):
|
||||
status = models.CharField(max_length=64, blank=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):
|
||||
if self.file:
|
||||
try:
|
||||
@@ -137,36 +171,25 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
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 = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.initial.get("notes", None) or (
|
||||
self.instance and self.instance.notes
|
||||
)
|
||||
model = BookmarkBundle
|
||||
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
@@ -183,34 +206,54 @@ class BookmarkSearch:
|
||||
FILTER_UNREAD_YES = "yes"
|
||||
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"]
|
||||
defaults = {
|
||||
"q": "",
|
||||
"user": "",
|
||||
"bundle": None,
|
||||
"sort": SORT_ADDED_DESC,
|
||||
"shared": FILTER_SHARED_OFF,
|
||||
"unread": FILTER_UNREAD_OFF,
|
||||
"modified_since": None,
|
||||
"added_since": None,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
bundle: BookmarkBundle = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
modified_since: str = None,
|
||||
added_since: str = None,
|
||||
preferences: dict = None,
|
||||
request: any = None,
|
||||
):
|
||||
if not preferences:
|
||||
preferences = {}
|
||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||
self.request = request
|
||||
|
||||
self.q = q or self.defaults["q"]
|
||||
self.user = user or self.defaults["user"]
|
||||
self.bundle = bundle or self.defaults["bundle"]
|
||||
self.sort = sort or self.defaults["sort"]
|
||||
self.shared = shared or self.defaults["shared"]
|
||||
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):
|
||||
value = self.__dict__[param]
|
||||
@@ -238,7 +281,14 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
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
|
||||
def preferences_dict(self):
|
||||
@@ -247,14 +297,21 @@ class BookmarkSearch:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||
initial_values = {}
|
||||
for param in BookmarkSearch.params:
|
||||
value = query_dict.get(param)
|
||||
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):
|
||||
@@ -277,9 +334,12 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
bundle = forms.CharField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
modified_since = forms.CharField(required=False)
|
||||
added_since = forms.CharField(required=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -299,7 +359,11 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
for param in search.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
|
||||
# rendering a form can just loop over hidden_fields to ensure that
|
||||
@@ -355,9 +419,7 @@ class UserProfile(models.Model):
|
||||
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||
]
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
|
||||
theme = models.CharField(
|
||||
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
|
||||
)
|
||||
@@ -412,14 +474,27 @@ class UserProfile(models.Model):
|
||||
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
permanent_notes = models.BooleanField(default=False, null=False)
|
||||
custom_css = models.TextField(blank=True, null=False)
|
||||
custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
|
||||
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, 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(
|
||||
null=False, default=30, validators=[MinValueValidator(10)]
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
hide_bundles = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
self.custom_css_hash = hashlib.md5(
|
||||
self.custom_css.encode("utf-8")
|
||||
).hexdigest()
|
||||
else:
|
||||
self.custom_css_hash = ""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -446,20 +521,23 @@ class UserProfileForm(forms.ModelForm):
|
||||
"display_remove_bookmark_action",
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"default_mark_shared",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
"hide_bundles",
|
||||
]
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
||||
|
||||
@@ -468,7 +546,7 @@ class Toast(models.Model):
|
||||
key = models.CharField(max_length=50)
|
||||
message = models.TextField()
|
||||
acknowledged = models.BooleanField(default=False)
|
||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class FeedToken(models.Model):
|
||||
@@ -478,7 +556,7 @@ class FeedToken(models.Model):
|
||||
|
||||
key = models.CharField(max_length=40, primary_key=True)
|
||||
user = models.OneToOneField(
|
||||
get_user_model(),
|
||||
User,
|
||||
related_name="feed_token",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
@@ -512,7 +590,7 @@ class GlobalSettings(models.Model):
|
||||
default=LANDING_PAGE_LOGIN,
|
||||
)
|
||||
guest_profile_user = models.ForeignKey(
|
||||
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
enable_link_prefetch = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
@@ -2,16 +2,26 @@ from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
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.expressions import RawSQL
|
||||
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.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
user: User,
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||
|
||||
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
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
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
|
||||
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
|
||||
|
||||
# Split query into search terms and tags
|
||||
query = parse_query_string(search.q)
|
||||
|
||||
@@ -85,6 +154,10 @@ def _base_bookmarks_query(
|
||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||
query_set = query_set.filter(shared=False)
|
||||
|
||||
# Filter by bundle
|
||||
if search.bundle:
|
||||
query_set = _filter_bundle(query_set, search.bundle)
|
||||
|
||||
# Sort
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
|
||||
178
bookmarks/services/assets.py
Normal file
178
bookmarks/services/assets.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset
|
||||
from bookmarks.services import singlefile
|
||||
|
||||
MAX_ASSET_FILENAME_LENGTH = 192
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
date_created = timezone.now()
|
||||
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
date_created=date_created,
|
||||
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
|
||||
display_name=f"HTML snapshot from {timestamp}",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def create_snapshot(asset: BookmarkAsset):
|
||||
try:
|
||||
# Create snapshot into temporary file
|
||||
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
|
||||
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
|
||||
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
|
||||
|
||||
# Store as gzip in asset folder
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with (
|
||||
open(temp_filepath, "rb") as temp_file,
|
||||
gzip.open(filepath, "wb") as gz_file,
|
||||
):
|
||||
shutil.copyfileobj(temp_file, gz_file)
|
||||
|
||||
# Remove temporary file
|
||||
os.remove(temp_filepath)
|
||||
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
except Exception as error:
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
raise error
|
||||
|
||||
|
||||
def upload_snapshot(bookmark: Bookmark, html: bytes):
|
||||
asset = create_snapshot_asset(bookmark)
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
|
||||
with gzip.open(filepath, "wb") as gz_file:
|
||||
gz_file.write(html)
|
||||
|
||||
# Only save the asset if the file was written successfully
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
||||
try:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
date_created=timezone.now(),
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
gzip=False,
|
||||
)
|
||||
name, extension = os.path.splitext(upload_file.name)
|
||||
|
||||
# automatically gzip the file if it is not already gzipped
|
||||
if upload_file.content_type != "application/gzip":
|
||||
filename = _generate_asset_filename(
|
||||
asset, name, extension.lstrip(".") + ".gz"
|
||||
)
|
||||
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.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
return asset
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
def remove_asset(asset: BookmarkAsset):
|
||||
# If this asset is the latest_snapshot for a bookmark, try to find the next most recent snapshot
|
||||
bookmark = asset.bookmark
|
||||
if bookmark and bookmark.latest_snapshot == asset:
|
||||
latest = (
|
||||
BookmarkAsset.objects.filter(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
.exclude(pk=asset.pk)
|
||||
.order_by("-date_created")
|
||||
.first()
|
||||
)
|
||||
|
||||
bookmark.latest_snapshot = latest
|
||||
|
||||
asset.delete()
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def _generate_asset_filename(
|
||||
asset: BookmarkAsset, filename: str, extension: str
|
||||
) -> str:
|
||||
def sanitize_char(char):
|
||||
if char.isalnum() or char in ("-", "_", "."):
|
||||
return char
|
||||
else:
|
||||
return "_"
|
||||
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
sanitized_filename = "".join(sanitize_char(char) for char in filename)
|
||||
|
||||
# Calculate the length of fixed parts of the final filename
|
||||
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
|
||||
# Calculate the maximum length for the dynamic part of the filename
|
||||
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
|
||||
# Truncate the filename if necessary
|
||||
sanitized_filename = sanitized_filename[:max_filename_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"
|
||||
@@ -7,11 +7,22 @@ def get_tags(script: str, url: str):
|
||||
parsed_url = urlparse(url.lower())
|
||||
result = set()
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
if "#" in line:
|
||||
i = line.index("#")
|
||||
line = line[:i]
|
||||
if not parsed_url.hostname:
|
||||
return result
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines or lines that start with a comment
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Remove trailing comment - only if # is preceded by whitespace
|
||||
comment_match = re.search(r"\s+#", line)
|
||||
if comment_match:
|
||||
line = line[: comment_match.start()]
|
||||
|
||||
# Ignore lines that don't contain a URL and a tag
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
@@ -33,6 +44,11 @@ def get_tags(script: str, url: str):
|
||||
):
|
||||
continue
|
||||
|
||||
if parsed_pattern.fragment and not _fragment_matches(
|
||||
parsed_pattern.fragment, parsed_url.fragment
|
||||
):
|
||||
continue
|
||||
|
||||
for tag in parts[1:]:
|
||||
result.add(tag)
|
||||
|
||||
@@ -62,3 +78,7 @@ def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:
|
||||
return actual_fragment.startswith(expected_fragment)
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||
from bookmarks.models import Bookmark, User, parse_tag_string
|
||||
from bookmarks.utils import normalize_url
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
def create_bookmark(
|
||||
bookmark: Bookmark,
|
||||
tag_string: str,
|
||||
current_user: User,
|
||||
disable_html_snapshot: bool = False,
|
||||
):
|
||||
# If URL is already bookmarked, then update it
|
||||
normalized_url = normalize_url(bookmark.url)
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||
owner=current_user, url=bookmark.url
|
||||
owner=current_user, url_normalized=normalized_url
|
||||
).first()
|
||||
|
||||
if existing_bookmark is not None:
|
||||
@@ -42,7 +45,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
if (
|
||||
current_user.profile.enable_automatic_html_snapshots
|
||||
and not disable_html_snapshot
|
||||
):
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
return bookmark
|
||||
@@ -65,7 +71,6 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||
bookmark.save()
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -194,44 +199,24 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
)
|
||||
|
||||
|
||||
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
gzip=False,
|
||||
def refresh_bookmarks_metadata(bookmark_ids: [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
|
||||
)
|
||||
asset.save()
|
||||
|
||||
try:
|
||||
filename = _generate_upload_asset_filename(asset, upload_file.name)
|
||||
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.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
for bookmark in owned_bookmarks:
|
||||
tasks.refresh_metadata(bookmark)
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
asset.save()
|
||||
|
||||
return asset
|
||||
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):
|
||||
|
||||
37
bookmarks/services/bundles.py
Normal file
37
bookmarks/services/bundles.py
Normal 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"])
|
||||
@@ -35,14 +35,15 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]"
|
||||
tag_names = bookmark.tag_names
|
||||
if bookmark.is_archived:
|
||||
tag_names.append("linkding:archived")
|
||||
tag_names.append("linkding:bookmarks.archived")
|
||||
tags = ",".join(tag_names)
|
||||
toread = "1" if bookmark.unread else "0"
|
||||
private = "0" if bookmark.shared else "1"
|
||||
added = int(bookmark.date_added.timestamp())
|
||||
modified = int(bookmark.date_modified.timestamp())
|
||||
|
||||
doc.append(
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" LAST_MODIFIED="{modified}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
)
|
||||
|
||||
if desc:
|
||||
|
||||
@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
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)
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name, owner=user)
|
||||
@@ -231,7 +238,10 @@ def _copy_bookmark_data(
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
else:
|
||||
bookmark.date_added = timezone.now()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
if netscape_bookmark.date_modified:
|
||||
bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)
|
||||
else:
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
bookmark.unread = netscape_bookmark.to_read
|
||||
if netscape_bookmark.title:
|
||||
bookmark.title = netscape_bookmark.title
|
||||
|
||||
@@ -22,9 +22,10 @@ def create_snapshot(url: str, filepath: str):
|
||||
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
|
||||
subprocess.run(command, check=True, shell=True)
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
with (
|
||||
open(temp_filepath, "rb") as raw_file,
|
||||
gzip.open(filepath, "wb") as gz_file,
|
||||
):
|
||||
shutil.copyfileobj(raw_file, gz_file)
|
||||
|
||||
os.remove(temp_filepath)
|
||||
|
||||
@@ -12,6 +12,7 @@ class NetscapeBookmark:
|
||||
description: str
|
||||
notes: str
|
||||
date_added: str
|
||||
date_modified: str
|
||||
tag_names: List[str]
|
||||
to_read: bool
|
||||
private: bool
|
||||
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark = None
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.last_modified = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
@@ -60,9 +62,9 @@ class BookmarkParser(HTMLParser):
|
||||
def handle_start_a(self, attrs: Dict[str, str]):
|
||||
vars(self).update(attrs)
|
||||
tag_names = parse_tag_string(self.tags)
|
||||
archived = "linkding:archived" in self.tags
|
||||
archived = "linkding:bookmarks.archived" in self.tags
|
||||
try:
|
||||
tag_names.remove("linkding:archived")
|
||||
tag_names.remove("linkding:bookmarks.archived")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
|
||||
description="",
|
||||
notes="",
|
||||
date_added=self.add_date,
|
||||
date_modified=self.last_modified,
|
||||
tag_names=tag_names,
|
||||
to_read=self.toread == "1",
|
||||
# Mark as private by default, also when attribute is not specified
|
||||
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark = None
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.last_modified = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_snapshot(url: str, filepath: str):
|
||||
singlefile_path = settings.LD_SINGLEFILE_PATH
|
||||
|
||||
# parse options to list of arguments
|
||||
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
|
||||
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
|
||||
temp_filepath = filepath + ".tmp"
|
||||
# concat lists
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
|
||||
try:
|
||||
# Use start_new_session=True to create a new process group
|
||||
process = subprocess.Popen(args, start_new_session=True)
|
||||
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
|
||||
|
||||
# check if the file was created
|
||||
if not os.path.exists(temp_filepath):
|
||||
if not os.path.exists(filepath):
|
||||
raise SingleFileError("Failed to create snapshot")
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
shutil.copyfileobj(raw_file, gz_file)
|
||||
|
||||
os.remove(temp_filepath)
|
||||
except subprocess.TimeoutExpired:
|
||||
# First try to terminate properly
|
||||
try:
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import waybackpy
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone, formats
|
||||
from django.utils import timezone
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from huey.exceptions import TaskLockedException
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
from bookmarks.services import assets, favicon_loader, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT, load_website_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,7 +157,7 @@ def schedule_bookmarks_without_favicons(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
@@ -175,7 +173,7 @@ def schedule_refresh_favicons(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_refresh_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(owner=user)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
@@ -214,7 +212,7 @@ def schedule_bookmarks_without_previews(user: User):
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(
|
||||
Q(preview_image_file__exact=""),
|
||||
owner=user,
|
||||
@@ -228,6 +226,31 @@ def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
logging.exception(exc)
|
||||
|
||||
|
||||
def refresh_metadata(bookmark: Bookmark):
|
||||
if not settings.LD_DISABLE_BACKGROUND_TASKS:
|
||||
_refresh_metadata_task(bookmark.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _refresh_metadata_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f"Refresh metadata for bookmark. url={bookmark.url}")
|
||||
|
||||
metadata = load_website_metadata(bookmark.url)
|
||||
if metadata.title:
|
||||
bookmark.title = metadata.title
|
||||
if metadata.description:
|
||||
bookmark.description = metadata.description
|
||||
bookmark.date_modified = timezone.now()
|
||||
|
||||
bookmark.save()
|
||||
logger.info(f"Successfully refreshed metadata for bookmark. url={bookmark.url}")
|
||||
|
||||
|
||||
def is_html_snapshot_feature_active() -> bool:
|
||||
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
@@ -236,7 +259,7 @@ def create_html_snapshot(bookmark: Bookmark):
|
||||
if not is_html_snapshot_feature_active():
|
||||
return
|
||||
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
|
||||
|
||||
@@ -246,47 +269,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
|
||||
|
||||
assets_to_create = []
|
||||
for bookmark in bookmark_list:
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
assets_to_create.append(asset)
|
||||
|
||||
BookmarkAsset.objects.bulk_create(assets_to_create)
|
||||
|
||||
|
||||
MAX_SNAPSHOT_FILENAME_LENGTH = 192
|
||||
|
||||
|
||||
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
content_type="text/html",
|
||||
display_name=f"HTML snapshot from {timestamp}",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
|
||||
def sanitize_char(char):
|
||||
if char.isalnum() or char in ("-", "_", "."):
|
||||
return char
|
||||
else:
|
||||
return "_"
|
||||
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
|
||||
|
||||
# Calculate the length of the non-URL parts of the filename
|
||||
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
|
||||
# Calculate the maximum length for the URL part
|
||||
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
|
||||
# Truncate the URL if necessary
|
||||
sanitized_url = sanitized_url[:max_url_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
|
||||
|
||||
|
||||
# singe-file does not support running multiple instances in parallel, so we can
|
||||
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic
|
||||
# task that grabs a number of pending assets and creates snapshots for them in
|
||||
@@ -313,13 +301,8 @@ def _create_html_snapshot_task(asset_id: int):
|
||||
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
|
||||
|
||||
try:
|
||||
filename = _generate_snapshot_filename(asset)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
singlefile.create_snapshot(asset.bookmark.url, filepath)
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.gzip = True
|
||||
asset.save()
|
||||
assets.create_snapshot(asset)
|
||||
|
||||
logger.info(
|
||||
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
|
||||
)
|
||||
@@ -328,8 +311,6 @@ def _create_html_snapshot_task(asset_id: int):
|
||||
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
|
||||
exc_info=error,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
|
||||
|
||||
def create_missing_html_snapshots(user: User) -> int:
|
||||
|
||||
@@ -27,10 +27,20 @@ class WebsiteMetadata:
|
||||
}
|
||||
|
||||
|
||||
def load_website_metadata(url: str, ignore_cache: bool = False):
|
||||
if ignore_cache:
|
||||
return _load_website_metadata(url)
|
||||
return _load_website_metadata_cached(url)
|
||||
|
||||
|
||||
# Caching metadata avoids scraping again when saving bookmarks, in case the
|
||||
# metadata was already scraped to show preview values in the bookmark form
|
||||
@lru_cache(maxsize=10)
|
||||
def load_website_metadata(url: str):
|
||||
def _load_website_metadata_cached(url: str):
|
||||
return _load_website_metadata(url)
|
||||
|
||||
|
||||
def _load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
|
||||
@@ -58,7 +58,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "siteroot.urls"
|
||||
ROOT_URLCONF = "bookmarks.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
@@ -80,7 +80,7 @@ TEMPLATES = [
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
WSGI_APPLICATION = "siteroot.wsgi.application"
|
||||
WSGI_APPLICATION = "bookmarks.wsgi.application"
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
@@ -128,18 +126,10 @@ STATIC_URL = "/" + LD_CONTEXT_PATH + "static/"
|
||||
# Collect static files in static folder
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
# Resolve theme files from style source folder
|
||||
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
||||
# Resolve downloaded files in dev environment
|
||||
os.path.join(BASE_DIR, "data", "favicons"),
|
||||
os.path.join(BASE_DIR, "data", "previews"),
|
||||
]
|
||||
|
||||
# REST framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
"bookmarks.api.auth.LinkdingTokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||
@@ -154,6 +144,7 @@ ALLOW_REGISTRATION = False
|
||||
LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -161,6 +152,7 @@ LD_DISABLE_URL_VALIDATION = os.getenv("LD_DISABLE_URL_VALIDATION", False) in (
|
||||
LD_DISABLE_BACKGROUND_TASKS = os.getenv("LD_DISABLE_BACKGROUND_TASKS", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -187,7 +179,7 @@ HUEY = {
|
||||
|
||||
|
||||
# Enable OICD support if configured
|
||||
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1")
|
||||
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "true", "1")
|
||||
|
||||
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
|
||||
|
||||
@@ -202,11 +194,18 @@ if LD_ENABLE_OIDC:
|
||||
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
|
||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
|
||||
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
|
||||
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "true", "1")
|
||||
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "true", "1")
|
||||
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
|
||||
|
||||
# Enable authentication proxy support if configured
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
|
||||
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
|
||||
"LD_AUTH_PROXY_USERNAME_HEADER", "REMOTE_USER"
|
||||
)
|
||||
@@ -273,6 +272,7 @@ LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")
|
||||
LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
|
||||
@@ -294,6 +294,13 @@ LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
|
||||
LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
|
||||
True,
|
||||
"True",
|
||||
"true",
|
||||
"1",
|
||||
)
|
||||
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
|
||||
@@ -304,7 +311,7 @@ LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
|
||||
'--browser-arg="--headless=new"',
|
||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||
'--browser-arg="--no-sandbox"',
|
||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
||||
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -20,6 +20,14 @@ INTERNAL_IPS = [
|
||||
# Allow access through ngrok
|
||||
CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"]
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
# Resolve theme files from style source folder
|
||||
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
||||
# Resolve downloaded files in dev environment
|
||||
os.path.join(BASE_DIR, "data", "favicons"),
|
||||
os.path.join(BASE_DIR, "data", "previews"),
|
||||
]
|
||||
|
||||
# Enable debug logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
2
bookmarks/static/robots.txt
Normal file
2
bookmarks/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,5 +1,13 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
.title {
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -28,7 +36,7 @@
|
||||
}
|
||||
|
||||
& .preview-image {
|
||||
margin: var(--unit-4 0);
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
@@ -36,57 +44,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
& dl {
|
||||
margin-bottom: 0;
|
||||
& .sections section {
|
||||
margin-top: var(--unit-4);
|
||||
}
|
||||
|
||||
& .sections h3 {
|
||||
margin-bottom: var(--unit-2);
|
||||
font-size: var(--font-size);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
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 {
|
||||
& .filesize {
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.bookmarks-form-page {
|
||||
section {
|
||||
main {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -15,14 +15,23 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& .form-group .clear-button {
|
||||
display: none;
|
||||
& .form-group .suffix-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
height: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .form-group .clear-button,
|
||||
& .form-group #refresh-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .form-group input.modified,
|
||||
& .form-group textarea.modified {
|
||||
background: var(--primary-color-shade);
|
||||
}
|
||||
|
||||
& .form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: var(--warning-color);
|
||||
|
||||
@@ -10,8 +10,38 @@
|
||||
}
|
||||
|
||||
/* Bookmark page grid */
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
.bookmarks-page {
|
||||
&.grid {
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
section.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse-side-panel {
|
||||
main {
|
||||
grid-column: span var(--grid-columns);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
@@ -316,12 +346,6 @@ li[ld-bookmark-item] {
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
/* Remove left padding from first pagination link */
|
||||
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -335,7 +359,8 @@ li[ld-bookmark-item] {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
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(
|
||||
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
||||
@@ -349,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 {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
@@ -429,7 +474,7 @@ ul.bookmark-list {
|
||||
|
||||
/* Hide section border when bulk edit bar is opened, otherwise borders overlap in dark mode due to using contrast colors */
|
||||
|
||||
&.active section:first-of-type .content-area-header {
|
||||
&.active .main .section-header {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
29
bookmarks/styles/bundles.css
Normal file
29
bookmarks/styles/bundles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,47 @@
|
||||
/* Shared components */
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
/* Section header component */
|
||||
.section-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-5);
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
section.content-area .content-area-header {
|
||||
.section-header:not(.no-wrap) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
color: var(--error-color) !important;
|
||||
.confirm-dropdown.active {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
& .menu {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,3 +55,60 @@ span.confirmation {
|
||||
.turbo-progress-bar {
|
||||
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
65
bookmarks/styles/crud.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,29 +11,19 @@ body {
|
||||
header {
|
||||
margin-bottom: var(--unit-9);
|
||||
|
||||
.logo {
|
||||
a.app-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 var(--unit-3);
|
||||
.app-name {
|
||||
margin-left: var(--unit-3);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--unit-10);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-3);
|
||||
}
|
||||
}
|
||||
|
||||
6
bookmarks/styles/tags.css
Normal file
6
bookmarks/styles/tags.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.tags-editor-page {
|
||||
main {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -141,3 +141,10 @@
|
||||
--bookmark-actions-weight: 400;
|
||||
--bulk-actions-bg-color: var(--contrast-5);
|
||||
}
|
||||
|
||||
/* Try to force dark color scheme for all native elements (e.g. upload button
|
||||
in file inputs, native select dropdown). For the select dropdown some browsers
|
||||
ignore this and use whatever users have configured in their system settings. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "crud.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@import "markdown.css";
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
||||
@import "bundles.css";
|
||||
@import "tags.css";
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
position: relative;
|
||||
|
||||
& .form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
@@ -22,10 +23,11 @@
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
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 {
|
||||
display: none;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
max-height: var(--menu-max-height, 200px);
|
||||
overflow: auto;
|
||||
|
||||
& .menu-item.selected > a,
|
||||
& .menu-item > a:hover {
|
||||
@@ -54,4 +75,8 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
& .menu.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ html {
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
|
||||
@@ -119,6 +119,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Button no border */
|
||||
&.btn-noborder {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
--dropdown-focus-display: block;
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
@@ -20,9 +22,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu,
|
||||
.dropdown-toggle:focus + .menu,
|
||||
.menu:hover {
|
||||
&:focus-within .menu {
|
||||
/* Use custom CSS property to allow disabling opening on focus when using JS */
|
||||
display: var(--dropdown-focus-display);
|
||||
}
|
||||
|
||||
&.active .menu {
|
||||
/* Always show menu when class is added through JS */
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,16 @@ textarea.form-input {
|
||||
no-repeat right 0.35rem center / 0.4rem 0.5rem;
|
||||
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
|
||||
}
|
||||
|
||||
/* Options */
|
||||
& option {
|
||||
/* On Windows with Chrome / Edge, options seems to use the same
|
||||
background color as the select. However for the dark theme the
|
||||
background is a semi-transparent white, resulting in an opaque white
|
||||
background for the dropdown. Use the modal background color to force
|
||||
a dark background instead. */
|
||||
background: var(--modal-container-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Form element: Checkbox and Radio */
|
||||
@@ -214,12 +224,13 @@ textarea.form-input {
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
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 {
|
||||
outline: var(--focus-outline);
|
||||
@@ -233,9 +244,9 @@ textarea.form-input {
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
pointer-events: none;
|
||||
border: var(--border-width) solid var(--checkbox-border-color);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition:
|
||||
@@ -419,13 +430,21 @@ textarea.form-input {
|
||||
/* Form element: Input groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
|
||||
> * {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.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-radius: var(--border-radius);
|
||||
line-height: var(--line-height);
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
padding: 0 var(--control-padding-x);
|
||||
white-space: nowrap;
|
||||
|
||||
&.addon-sm {
|
||||
|
||||
@@ -87,4 +87,43 @@
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
cursor: default;
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
@@ -62,13 +62,14 @@
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-6);
|
||||
padding-bottom: 0;
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
@@ -78,27 +79,60 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& button.close {
|
||||
background: none;
|
||||
border: none;
|
||||
& .close {
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 0 var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-body:not(:has(+ .modal-footer)) {
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
text-align: right;
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal.drawer {
|
||||
display: block;
|
||||
|
||||
& .modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border: none;
|
||||
border-left: solid 1px var(--modal-container-border-color);
|
||||
border-radius: 0;
|
||||
transform: translateX(100%);
|
||||
animation: fade-in 0.25s ease 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& .modal-container {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active.closing {
|
||||
& .modal-container {
|
||||
animation: fade-out 0.25s ease 1;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-lock {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child a {
|
||||
/* Remove left padding from first pagination link */
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
|
||||
@@ -5,22 +5,19 @@
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
/* Scrollable tables */
|
||||
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
td,
|
||||
th {
|
||||
border-bottom: var(--border-width) solid var(--secondary-border-color);
|
||||
padding: var(--unit-2) var(--unit-2);
|
||||
}
|
||||
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
th {
|
||||
font-weight: 500;
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,44 @@
|
||||
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 {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -283,6 +321,10 @@
|
||||
}
|
||||
|
||||
/* Flex */
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -294,3 +336,7 @@
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
@@ -49,20 +49,22 @@
|
||||
--body-color-contrast: var(--gray-100);
|
||||
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
|
||||
monospace;
|
||||
--base-font-family:
|
||||
-apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
--mono-font-family:
|
||||
"SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
|
||||
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
|
||||
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
|
||||
var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
|
||||
var(--fallback-font-family);
|
||||
--cjk-zh-hans-font-family:
|
||||
var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family:
|
||||
var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
|
||||
"Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family:
|
||||
var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
|
||||
"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);
|
||||
|
||||
/* Unit sizes */
|
||||
@@ -87,6 +89,7 @@
|
||||
--font-size: 0.7rem;
|
||||
--font-size-sm: 0.65rem;
|
||||
--font-size-lg: 0.8rem;
|
||||
--font-size-xl: 1rem;
|
||||
--line-height: 1rem;
|
||||
|
||||
/* Sizes */
|
||||
@@ -144,6 +147,6 @@
|
||||
/* Shadows */
|
||||
--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-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
<div class="section-header mb-0">
|
||||
<h1 id="main-heading">Archived bookmarks</h1>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,23 +28,21 @@
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,157 +5,166 @@
|
||||
{% if bookmark_list.is_empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
<section aria-label="Bookmark list">
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
role="list" tabindex="-1"
|
||||
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" 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 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% 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 %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% 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-icon="ld-icon-unshare" 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 %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images %}
|
||||
{% if bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% else %}
|
||||
<div class="preview-image placeholder">
|
||||
<div class="img"/>
|
||||
<div class="actions">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.snapshot_url %}
|
||||
<a href="{{ bookmark_item.snapshot_url }}"
|
||||
title="{{ bookmark_item.snapshot_title }}"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
{% if not bookmark_list.is_preview %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not bookmark_list.is_preview %}
|
||||
{# 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 %}
|
||||
{% 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 %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images %}
|
||||
{% if bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% else %}
|
||||
<div class="preview-image placeholder">
|
||||
<div class="img"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
|
||||
{% pagination bookmark_list.bookmarks_page %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
var bookmarkUrl = window.location;
|
||||
var applicationUrl = '{{ application_url }}';
|
||||
const bookmarkUrl = window.location;
|
||||
|
||||
let applicationUrl = '{{ application_url }}';
|
||||
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||
applicationUrl += '&auto_close';
|
||||
|
||||
|
||||
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal file
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal 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);
|
||||
})();
|
||||
@@ -22,6 +22,10 @@
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
{% if bookmark_list.snapshot_feature_enabled %}
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||
|
||||
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
36
bookmarks/templates/bookmarks/bundle_section.html
Normal 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 %}
|
||||
@@ -1,12 +1,12 @@
|
||||
<div>
|
||||
{% if details.assets %}
|
||||
<div class="assets">
|
||||
<div class="item-list assets">
|
||||
{% for asset in details.assets %}
|
||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
||||
<div class="asset-icon {{ asset.icon_classes }}">
|
||||
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||
{% include 'bookmarks/details/asset_icon.html' %}
|
||||
</div>
|
||||
<div class="asset-text {{ asset.text_classes }}">
|
||||
<div class="list-item-text {{ asset.text_classes }}">
|
||||
<span class="truncate">
|
||||
{{ asset.display_name }}
|
||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||
@@ -16,9 +16,9 @@
|
||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<div class="list-item-actions">
|
||||
{% if asset.file %}
|
||||
<a class="btn btn-link" href="{% url 'bookmarks: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 %}
|
||||
{% if details.is_editable %}
|
||||
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
|
||||
@@ -33,12 +33,16 @@
|
||||
|
||||
{% if details.is_editable %}
|
||||
<div class="assets-actions">
|
||||
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
{% if details.snapshots_enabled %}
|
||||
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if details.uploads_enabled %}
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
{% endif %}
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span>{{ details.bookmark.url }}</span>
|
||||
</a>
|
||||
{% if details.latest_snapshot %}
|
||||
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}"
|
||||
<a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -40,14 +40,14 @@
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||
<div class="sections grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<div class="status col-2">
|
||||
<dt>Status</dt>
|
||||
<dd class="d-flex" style="gap: .8rem">
|
||||
<section class="status col-2">
|
||||
<h3>Status</h3>
|
||||
<div class="d-flex" style="gap: .8rem">
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||
@@ -71,44 +71,42 @@
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.show_files %}
|
||||
<div class="files col-2">
|
||||
<dt>Files</dt>
|
||||
<dd>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</dd>
|
||||
<section class="files col-2">
|
||||
<h3>Files</h3>
|
||||
<div>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if details.bookmark.tag_names %}
|
||||
<div class="tags col-1">
|
||||
<dt>Tags</dt>
|
||||
<dd>
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div>
|
||||
{% for tag_name in details.bookmark.tag_names %}
|
||||
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div class="date-added col-1">
|
||||
<dt>Date added</dt>
|
||||
<dd>
|
||||
<section class="date-added col-1">
|
||||
<h3>Date added</h3>
|
||||
<div>
|
||||
<span>{{ details.bookmark.date_added }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<div class="description col-2">
|
||||
<dt>Description</dt>
|
||||
<dd>{{ details.bookmark.resolved_description }}</dd>
|
||||
</div>
|
||||
</section>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<section class="description col-2">
|
||||
<h3>Description</h3>
|
||||
<div>{{ details.bookmark.resolved_description }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.notes %}
|
||||
<div class="notes col-2">
|
||||
<dt>Notes</dt>
|
||||
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
||||
</div>
|
||||
<section class="notes col-2">
|
||||
<h3>Notes</h3>
|
||||
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
<div class="modal active bookmark-details"
|
||||
ld-details-modal>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
</a>
|
||||
<div class="modal-container">
|
||||
<div class="modal active bookmark-details" ld-details-modal
|
||||
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
|
||||
<button class="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
||||
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content">
|
||||
@@ -28,7 +24,7 @@
|
||||
<div class="actions">
|
||||
<div class="left-actions">
|
||||
<a class="btn btn-wide"
|
||||
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">
|
||||
@@ -36,7 +32,7 @@
|
||||
<input type="hidden" name="disable_turbo" value="true">
|
||||
<button ld-confirm-button class="btn btn-error btn-wide"
|
||||
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
||||
Delete...
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit bookmark - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load widget_tweaks %}
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
|
||||
<div class="bookmarks-form">
|
||||
{% csrf_token %}
|
||||
@@ -7,7 +8,7 @@
|
||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||
<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>
|
||||
</div>
|
||||
{% if form.url.errors %}
|
||||
@@ -22,8 +23,8 @@
|
||||
</div>
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<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" }}
|
||||
<div class="form-input-hint">
|
||||
{{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<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 (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
</div>
|
||||
@@ -33,9 +34,13 @@
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-between align-baseline">
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="btn btn-link clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
<div class="flex">
|
||||
<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"
|
||||
type="button">Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
{{ form.title.errors }}
|
||||
@@ -43,7 +48,8 @@
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-between align-baseline">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
|
||||
<button ld-clear-button data-for="{{ form.description.id_for_label }}" class="btn btn-link clear-button"
|
||||
<button ld-clear-button data-for="{{ form.description.id_for_label }}"
|
||||
class="btn btn-link suffix-button clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
</div>
|
||||
@@ -56,31 +62,31 @@
|
||||
<span class="form-label d-inline-block">Notes</span>
|
||||
</summary>
|
||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div class="form-input-hint">
|
||||
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
</details>
|
||||
{{ form.notes.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||
{{ form.unread }}
|
||||
<div class="form-checkbox">
|
||||
{{ form.unread|form_field:"help" }}
|
||||
<i class="form-icon"></i>
|
||||
<span>Mark as unread</span>
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
<label for="{{ form.unread.id_for_label }}">Mark as unread</label>
|
||||
</div>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
||||
{{ form.shared }}
|
||||
<div class="form-checkbox">
|
||||
{{ form.shared|form_field:"help" }}
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
<label for="{{ form.shared.id_for_label }}">Share</label>
|
||||
</div>
|
||||
<div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
|
||||
{% if request.user_profile.enable_public_sharing %}
|
||||
Share this bookmark with other registered users and anonymous users.
|
||||
{% else %}
|
||||
@@ -91,12 +97,12 @@
|
||||
{% endif %}
|
||||
<div class="divider"></div>
|
||||
<div class="form-group d-flex justify-between">
|
||||
{% if auto_close %}
|
||||
{% if form.is_auto_close %}
|
||||
<input type="submit" value="Save and close" class="btn btn-primary btn-wide">
|
||||
{% else %}
|
||||
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||
<a href="{{ return_url }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
@@ -111,8 +117,9 @@
|
||||
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
|
||||
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
const editedBookmarkId = {{ bookmark_id }};
|
||||
const editedBookmarkId = {{ form.instance.id|default:0 }};
|
||||
let isTitleModified = !!titleInput.value;
|
||||
let isDescriptionModified = !!descriptionInput.value;
|
||||
|
||||
@@ -144,7 +151,7 @@
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@@ -154,6 +161,7 @@
|
||||
// Display hint if URL is already bookmarked
|
||||
const existingBookmark = data.bookmark;
|
||||
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
|
||||
refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';
|
||||
|
||||
// Prefill form with existing bookmark data
|
||||
if (existingBookmark) {
|
||||
@@ -193,6 +201,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
function refreshMetadata() {
|
||||
if (!urlInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;
|
||||
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata;
|
||||
const existingBookmark = data.bookmark;
|
||||
toggleLoadingIcon(urlInput, false);
|
||||
|
||||
if (metadata.title && metadata.title !== existingBookmark?.title) {
|
||||
titleInput.value = metadata.title;
|
||||
titleInput.classList.add("modified");
|
||||
}
|
||||
|
||||
if (metadata.description && metadata.description !== existingBookmark?.description) {
|
||||
descriptionInput.value = metadata.description;
|
||||
descriptionInput.classList.add("modified");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshButton.addEventListener('click', refreshMetadata);
|
||||
|
||||
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
||||
if (!editedBookmarkId) {
|
||||
checkUrl();
|
||||
@@ -203,6 +242,8 @@
|
||||
descriptionInput.addEventListener('input', () => {
|
||||
isDescriptionModified = true;
|
||||
});
|
||||
} else {
|
||||
refreshButton.style['display'] = 'inline-block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
|
||||
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
|
||||
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||
<link rel="manifest" href="{% url 'linkding:manifest' %}">
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="Linkding" href="{% url 'linkding:opensearch' %}"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
<meta name="description" content="Self-hosted bookmark service">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="author" content="Sascha Ißbrücker">
|
||||
<title>linkding</title>
|
||||
<title>{{ page_title|default:'Linkding' }}</title>
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
@@ -30,11 +31,14 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% endif %}
|
||||
{% if request.user_profile.custom_css %}
|
||||
<style>{{ request.user_profile.custom_css }}</style>
|
||||
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
|
||||
{% endif %}
|
||||
<meta name="turbo-cache-control" content="no-preview">
|
||||
{% if not request.global_settings.enable_link_prefetch %}
|
||||
<meta name="turbo-prefetch" content="false">
|
||||
{% endif %}
|
||||
{% if rss_feed_url %}
|
||||
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
|
||||
{% endif %}
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
</head>
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
{% load shared %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block title %}Bookmarks - Linkding{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
<div class="section-header mb-0">
|
||||
<h1 id="main-heading">Bookmarks</h1>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,23 +30,21 @@
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
{# Bookmark details #}
|
||||
<turbo-frame id="details-modal" target="_top">
|
||||
{% if details %}
|
||||
{% include 'bookmarks/details/modal.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
{% endblock %}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
{# Use data attributes as storage for access in static scripts #}
|
||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||
{% include 'bookmarks/head.html' %}
|
||||
<html lang="en" data-api-base-url="{% url 'linkding:api-root' %}">
|
||||
{% block head %}{% include 'bookmarks/head.html' %}{% endblock %}
|
||||
<body ld-global-shortcuts>
|
||||
|
||||
<div class="d-none">
|
||||
@@ -18,18 +18,6 @@
|
||||
<path d="M21 6l0 13"></path>
|
||||
</symbol>
|
||||
</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">
|
||||
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -41,18 +29,6 @@
|
||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||
</symbol>
|
||||
</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">
|
||||
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -67,8 +43,8 @@
|
||||
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts">
|
||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
<div class="message-list">
|
||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
<div class="toast d-flex">
|
||||
@@ -80,22 +56,28 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-between">
|
||||
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>LINKDING</h1>
|
||||
<a href="{% url 'linkding:root' %}" class="app-link d-flex align-center">
|
||||
<img class="app-logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<span class="app-name">LINKDING</span>
|
||||
</a>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% else %}
|
||||
{# Otherwise show login link #}
|
||||
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||
{% endif %}
|
||||
<nav>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% else %}
|
||||
{# Otherwise show login link #}
|
||||
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="modals">
|
||||
{% block overlays %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,77 +2,103 @@
|
||||
{% htmlmin %}
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div class="dropdown">
|
||||
<a href="{% url 'linkding:bookmarks.new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div ld-dropdown class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Bookmarks
|
||||
</button>
|
||||
<ul class="menu">
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Active</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
<div ld-dropdown class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Settings
|
||||
</button>
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:settings.general' %}" class="menu-link">General</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||
<a href="{% url 'linkding:bookmarks.new' %}" aria-label="Add bookmark" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Bookmarks</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:archived' %}" class="menu-link">Archived</a>
|
||||
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived bookmarks</a>
|
||||
</li>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="menu-link">Shared</a>
|
||||
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared bookmarks</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
<div class="divider"></div>
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
|
||||
<a href="{% url 'linkding:settings.general' %}" class="menu-link">Settings</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<div class="divider"></div>
|
||||
<li class="menu-item">
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link menu-link">Logout</button>
|
||||
</form>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="New bookmark - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-form-page">
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ul class="pagination">
|
||||
{% if prev_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
@@ -14,7 +14,7 @@
|
||||
{% for page_link in page_links %}
|
||||
{% if page_link %}
|
||||
<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>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{% if next_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
||||
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user