Compare commits
265 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfbba20275 | ||
|
|
f67c4605fd | ||
|
|
1f0a2201ba | ||
|
|
d52caefe2c | ||
|
|
c998dd35b7 | ||
|
|
397eb6d316 | ||
|
|
fbb9e10421 | ||
|
|
b937f26b44 | ||
|
|
414c7abbe5 | ||
|
|
7333b283cf | ||
|
|
4f26c3483b | ||
|
|
184e4baa84 | ||
|
|
1b90db70c0 | ||
|
|
cbc8618805 | ||
|
|
afbf85b249 | ||
|
|
9ab91e018b | ||
|
|
f7229a06fc | ||
|
|
5f5ea73aec | ||
|
|
fdb5b4e82d | ||
|
|
50180c9684 | ||
|
|
65f3759444 | ||
|
|
7dfb8126c4 | ||
|
|
5da450ce96 | ||
|
|
3b26190df5 | ||
|
|
4d82fefa4e | ||
|
|
06048ee26f | ||
|
|
4f5009b30f | ||
|
|
ee169e82cd | ||
|
|
cce191440d | ||
|
|
ec0c7ee253 | ||
|
|
4291bda9d4 | ||
|
|
f7c371bce1 | ||
|
|
b6c4634403 | ||
|
|
b4a5b34815 | ||
|
|
ffc1a69085 | ||
|
|
38d450a916 | ||
|
|
df595f2219 | ||
|
|
b82d07c588 | ||
|
|
fc15363349 | ||
|
|
b97b0493e0 | ||
|
|
9013a8dfc2 | ||
|
|
4fed5de7b3 | ||
|
|
ee1cf6596b | ||
|
|
12dd1d8bc6 | ||
|
|
74ddf45632 | ||
|
|
83092ccb48 | ||
|
|
492de5618c | ||
|
|
c349ad7670 | ||
|
|
1c17e16655 | ||
|
|
9b70bc3b55 | ||
|
|
beba4f8b93 | ||
|
|
bb7af56dc1 | ||
|
|
e89fecbd10 | ||
|
|
70734ed273 | ||
|
|
dcb15f1942 | ||
|
|
3b6cdbdd84 | ||
|
|
344420ec4a | ||
|
|
eb99ece360 | ||
|
|
95529eccd4 | ||
|
|
a6b36750da | ||
|
|
8b98a335d4 | ||
|
|
6ac8ce6a7b | ||
|
|
a9f135552a | ||
|
|
f110eb35fe | ||
|
|
051bd39256 | ||
|
|
229d3b511f | ||
|
|
b9d6d91a91 | ||
|
|
a7a4dd5fff | ||
|
|
ecb34d2aea | ||
|
|
5495565fbd | ||
|
|
0c18b83a8e | ||
|
|
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 | ||
|
|
93e2832a89 | ||
|
|
f5708594a7 | ||
|
|
67f237c1de | ||
|
|
95f489ea48 | ||
|
|
ed57da3c99 | ||
|
|
c5c5949d20 | ||
|
|
f4e66c1ff1 | ||
|
|
fe7ddbe645 | ||
|
|
afa57aa10b | ||
|
|
b4108c9a56 | ||
|
|
6cf5fb396a | ||
|
|
3d8866c7bc | ||
|
|
8544137a31 | ||
|
|
baa3d5596d | ||
|
|
f79c24453c | ||
|
|
f3c1101746 | ||
|
|
ceceb56164 | ||
|
|
450980a8d4 | ||
|
|
2aab2813f4 | ||
|
|
0e488b7ce3 | ||
|
|
53e4aeb1c1 | ||
|
|
2b3cd2dec1 | ||
|
|
c22e30cbda | ||
|
|
ffaaf0521d | ||
|
|
db225d5267 | ||
|
|
74e65bc366 | ||
|
|
edba98f1fe |
@@ -2,7 +2,7 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"forwardPorts": [8000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
"postCreateCommand": "pip install uv && uv sync --group dev && npm install && mkdir -p data && uv run manage.py migrate",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
# Include files required for build or at runtime
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
@@ -11,12 +10,13 @@
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/pyproject.toml
|
||||
!/rollup.config.mjs
|
||||
!/supervisord.conf
|
||||
!/supervisord-tasks.conf
|
||||
!/supervisord-all.conf
|
||||
!/uv.lock
|
||||
!/uwsgi.ini
|
||||
!/version.txt
|
||||
|
||||
# Remove dev settings
|
||||
/siteroot/settings/dev.py
|
||||
/bookmarks/settings/dev.py
|
||||
|
||||
@@ -24,6 +24,8 @@ LD_AUTH_PROXY_USERNAME_HEADER=
|
||||
# The URL that linkding should redirect to after a logout, when using an auth proxy
|
||||
# See docs/Options.md for more details
|
||||
LD_AUTH_PROXY_LOGOUT_URL=
|
||||
# Disables the login form, useful to enforce OIDC authentication
|
||||
LD_DISABLE_LOGIN_FORM=False
|
||||
# List of trusted origins from which to accept POST requests
|
||||
# See docs/Options.md for more details
|
||||
LD_CSRF_TRUSTED_ORIGINS=
|
||||
|
||||
81
.github/workflows/build-test.yaml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: build-test
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build latest
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/sissbruecker/linkding:test
|
||||
target: linkding
|
||||
push: true
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||
|
||||
- name: Build latest-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/sissbruecker/linkding:test-alpine
|
||||
target: linkding
|
||||
push: true
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||
|
||||
- name: Build latest-plus
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/sissbruecker/linkding:test-plus
|
||||
target: linkding-plus
|
||||
push: true
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||
|
||||
- name: Build latest-plus-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
ghcr.io/sissbruecker/linkding:test-plus-alpine
|
||||
target: linkding-plus
|
||||
push: true
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||
97
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
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
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||
|
||||
- name: Build latest-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
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
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||
|
||||
- name: Build latest-plus
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
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
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache,mode=max
|
||||
|
||||
- name: Build latest-plus-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
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
|
||||
cache-from: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine
|
||||
cache-to: type=registry,ref=ghcr.io/sissbruecker/linkding:buildcache-alpine,mode=max
|
||||
26
.github/workflows/main.yaml
vendored
@@ -15,7 +15,9 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -25,10 +27,10 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
uv sync
|
||||
mkdir data
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
run: uv run pytest -n auto
|
||||
e2e_tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,7 +39,9 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -47,12 +51,18 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
uv sync
|
||||
uv run playwright install chromium
|
||||
mkdir data
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py collectstatic
|
||||
uv run manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
run: uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
|
||||
- name: Upload screenshots
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: test-results/screenshots
|
||||
|
||||
7
.gitignore
vendored
@@ -60,6 +60,7 @@ coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
test-results/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
@@ -192,7 +193,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
|
||||
|
||||
385
CHANGELOG.md
@@ -1,5 +1,390 @@
|
||||
# Changelog
|
||||
|
||||
## v1.45.0 (06/01/2026)
|
||||
|
||||
### What's Changed
|
||||
* API token management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1248
|
||||
* Add option to disable login form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1269
|
||||
* Turn scheme-less URLs into HTTPS instead of HTTP links by @Maaxxs in https://github.com/sissbruecker/linkding/pull/1225
|
||||
* Disable bulk execute button when no bookmarks selected by @emanuelebeffa in https://github.com/sissbruecker/linkding/pull/1241
|
||||
* Add option to run supervisor as main process by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1270
|
||||
* Allow setting date_added and date_modified for new bookmarks through REST API by @jmason in https://github.com/sissbruecker/linkding/pull/1063
|
||||
* Download PDF instead of creating HTML snapshot if URL points at PDF by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1271
|
||||
* Allow sandboxed scripts when viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1252
|
||||
* Allow viewing video assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1259
|
||||
* Remove absolute URIs from settings page by @packrat386 in https://github.com/sissbruecker/linkding/pull/1261
|
||||
* Move tag management forms into dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1253
|
||||
* Move bulk edit checkboxes into bookmark list container by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1257
|
||||
* Remove registration switch by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1268
|
||||
* Add linkdinger to community projects by @lmmendes in https://github.com/sissbruecker/linkding/pull/1266
|
||||
|
||||
### New Contributors
|
||||
* @packrat386 made their first contribution in https://github.com/sissbruecker/linkding/pull/1261
|
||||
* @lmmendes made their first contribution in https://github.com/sissbruecker/linkding/pull/1266
|
||||
* @Maaxxs made their first contribution in https://github.com/sissbruecker/linkding/pull/1225
|
||||
* @emanuelebeffa made their first contribution in https://github.com/sissbruecker/linkding/pull/1241
|
||||
* @jmason made their first contribution in https://github.com/sissbruecker/linkding/pull/1063
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.2...v1.45.0
|
||||
|
||||
---
|
||||
|
||||
## v1.44.2 (13/12/2025)
|
||||
|
||||
### What's Changed
|
||||
|
||||
> [!WARNING]
|
||||
> *This resolves a [security vulnerability](https://github.com/sissbruecker/linkding/security/advisories/GHSA-3pf9-5cjv-2w7q) in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
|
||||
|
||||
* Use sandbox CSP for viewing assets by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1245
|
||||
* Fix devcontainer by @m3eno in https://github.com/sissbruecker/linkding/pull/1208
|
||||
* Fix tag cloud highlighting first char when tags are not grouped by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1209
|
||||
* Bump supervisor to 4.3.0 to fix warning by @simonhammes in https://github.com/sissbruecker/linkding/pull/1216
|
||||
* Added Javascript client and library for Linkding REST API by @vbsampath in https://github.com/sissbruecker/linkding/pull/1195
|
||||
* Add Komrade project to community resources by @dev-inside in https://github.com/sissbruecker/linkding/pull/1236
|
||||
|
||||
### New Contributors
|
||||
* @m3eno made their first contribution in https://github.com/sissbruecker/linkding/pull/1208
|
||||
* @vbsampath made their first contribution in https://github.com/sissbruecker/linkding/pull/1195
|
||||
* @dev-inside made their first contribution in https://github.com/sissbruecker/linkding/pull/1236
|
||||
* @simonhammes made their first contribution in https://github.com/sissbruecker/linkding/pull/1216
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.1...v1.44.2
|
||||
|
||||
---
|
||||
|
||||
## v1.44.1 (11/10/2025)
|
||||
|
||||
### What's Changed
|
||||
* Fix normalized URL not being generated in bookmark import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1202
|
||||
* Fix missing tags causing errors in import with Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1203
|
||||
* Check for dupes by exact URL if normalized URL is missing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1204
|
||||
* Attempt to fix botched normalized URL migration from 1.43.0 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1205
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.0...v1.44.1
|
||||
|
||||
---
|
||||
|
||||
## v1.44.0 (05/10/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add new search engine that supports logical expressions (and, or, not) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1198
|
||||
* Fix pagination links to use relative URLs by @dunlor in https://github.com/sissbruecker/linkding/pull/1186
|
||||
* Fix queued tasks link when context path is used by @dunlor in https://github.com/sissbruecker/linkding/pull/1187
|
||||
* Fix bundle preview pagination resetting to first page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1194
|
||||
|
||||
### New Contributors
|
||||
* @dunlor made their first contribution in https://github.com/sissbruecker/linkding/pull/1186
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.43.0...v1.44.0
|
||||
|
||||
---
|
||||
|
||||
## v1.43.0 (28/09/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add basic tag management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1175
|
||||
* Normalize URLs when checking for duplicates by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1169
|
||||
* Add option to mark bookmarks as shared by default by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1170
|
||||
* Use modal dialog for confirming actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1168
|
||||
* Fix error when filtering bookmark assets in the admin UI by @proog in https://github.com/sissbruecker/linkding/pull/1162
|
||||
* Document API bundle filter by @proog in https://github.com/sissbruecker/linkding/pull/1161
|
||||
* Add alfred-linkding-bookmarks to community.md by @FireFingers21 in https://github.com/sissbruecker/linkding/pull/1160
|
||||
* Switch to uv by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1172
|
||||
* Replace Svelte components with Lit elements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1174
|
||||
* Bump versions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1173
|
||||
* Bump astro from 5.12.8 to 5.13.2 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1166
|
||||
* Bump vite from 6.3.5 to 6.3.6 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1184
|
||||
|
||||
### New Contributors
|
||||
* @FireFingers21 made their first contribution in https://github.com/sissbruecker/linkding/pull/1160
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.42.0...v1.43.0
|
||||
|
||||
---
|
||||
|
||||
## v1.42.0 (16/08/2025)
|
||||
|
||||
### What's Changed
|
||||
* Bulk create HTML snapshots by @Tql-ws1 in https://github.com/sissbruecker/linkding/pull/1132
|
||||
* Create bundle from current search query by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1154
|
||||
* Add alternative bookmarklet that uses browser metadata by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1159
|
||||
* Add date and time to HTML export filename by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1101
|
||||
* Automatically compress uploads with gzip by @hkclark in https://github.com/sissbruecker/linkding/pull/1087
|
||||
* Show bookmark bundles in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1110
|
||||
* Allow filtering feeds by bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1152
|
||||
* Submit bookmark form with Ctrl/Cmd + Enter by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1158
|
||||
* Improve bookmark form accessibility by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1116
|
||||
* Fix custom CSS not being used in reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1102
|
||||
* Use filename when downloading asset through UI by @proog in https://github.com/sissbruecker/linkding/pull/1146
|
||||
* Update order when deleting bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1114
|
||||
* Wrap long titles in bookmark details modal by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1150
|
||||
* Ignore tags with just whitespace by @pvl in https://github.com/sissbruecker/linkding/pull/1125
|
||||
* Ignore tags that exceed length limit during import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1153
|
||||
* Add CloudBreak on Managed Hosting by @benjaminoakes in https://github.com/sissbruecker/linkding/pull/1079
|
||||
* Add Pocket migration to to community page by @hkclark in https://github.com/sissbruecker/linkding/pull/1112
|
||||
* Add linkding-media-archiver to community.md by @proog in https://github.com/sissbruecker/linkding/pull/1144
|
||||
* Bump astro from 5.7.13 to 5.12.8 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1147
|
||||
|
||||
### New Contributors
|
||||
* @hkclark made their first contribution in https://github.com/sissbruecker/linkding/pull/1087
|
||||
* @benjaminoakes made their first contribution in https://github.com/sissbruecker/linkding/pull/1079
|
||||
* @proog made their first contribution in https://github.com/sissbruecker/linkding/pull/1146
|
||||
* @pvl made their first contribution in https://github.com/sissbruecker/linkding/pull/1125
|
||||
* @Tql-ws1 made their first contribution in https://github.com/sissbruecker/linkding/pull/1132
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.41.0...v1.42.0
|
||||
|
||||
---
|
||||
|
||||
## v1.41.0 (19/06/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
|
||||
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
|
||||
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
|
||||
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
|
||||
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
|
||||
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
|
||||
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
|
||||
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
|
||||
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
|
||||
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
|
||||
|
||||
### New Contributors
|
||||
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
|
||||
|
||||
---
|
||||
|
||||
## v1.40.0 (17/05/2025)
|
||||
|
||||
### What's Changed
|
||||
* 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
|
||||
* Fix several issues around browser back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/825
|
||||
* Speed up response times for certain actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/829
|
||||
* Implement IPv6 capability by @itz-Jana in https://github.com/sissbruecker/linkding/pull/826
|
||||
|
||||
### New Contributors
|
||||
* @itz-Jana made their first contribution in https://github.com/sissbruecker/linkding/pull/826
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.33.0...v1.34.0
|
||||
|
||||
---
|
||||
|
||||
## v1.33.0 (14/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Theme improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/822
|
||||
* Speed up navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/824
|
||||
* Rename "SingeFileError" to "SingleFileError" by @curiousleo in https://github.com/sissbruecker/linkding/pull/823
|
||||
* Bump svelte from 4.2.12 to 4.2.19 by @dependabot in https://github.com/sissbruecker/linkding/pull/806
|
||||
|
||||
### New Contributors
|
||||
* @curiousleo made their first contribution in https://github.com/sissbruecker/linkding/pull/823
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.32.0...v1.33.0
|
||||
|
||||
---
|
||||
|
||||
## v1.32.0 (10/09/2024)
|
||||
|
||||
### What's Changed
|
||||
* Allow configuring landing page for unauthenticated users by @sissbruecker in https://github.com/sissbruecker/linkding/pull/808
|
||||
* Allow configuring guest user profile by @sissbruecker in https://github.com/sissbruecker/linkding/pull/809
|
||||
* Return bookmark tags in RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/810
|
||||
* Additional filter parameters for RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/811
|
||||
* Allow pre-filling notes in new bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/812
|
||||
* Fix inconsistent tag order in bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/819
|
||||
* Fix auto-tagging when URL includes port by @sissbruecker in https://github.com/sissbruecker/linkding/pull/820
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.1...v1.32.0
|
||||
|
||||
---
|
||||
|
||||
## v1.31.1 (30/08/2024)
|
||||
|
||||
### What's Changed
|
||||
|
||||
33
Makefile
@@ -1,15 +1,38 @@
|
||||
.PHONY: serve
|
||||
|
||||
init:
|
||||
uv sync
|
||||
[ -d data ] || mkdir data data/assets data/favicons data/previews
|
||||
uv run manage.py migrate
|
||||
npm install
|
||||
|
||||
serve:
|
||||
python manage.py runserver
|
||||
uv run manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py process_tasks
|
||||
uv run manage.py run_huey
|
||||
|
||||
test:
|
||||
pytest -n auto
|
||||
uv run pytest -n auto
|
||||
|
||||
lint:
|
||||
uv run ruff check bookmarks
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
black siteroot
|
||||
uv run ruff format bookmarks
|
||||
uv run djlint bookmarks/templates --reformat --quiet --warn
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
||||
|
||||
prepare-e2e:
|
||||
uv run playwright install chromium
|
||||
rm -rf static
|
||||
npm run build
|
||||
uv run manage.py collectstatic --no-input
|
||||
|
||||
e2e:
|
||||
make prepare-e2e
|
||||
uv run pytest bookmarks/tests_e2e -n auto -o "python_files=e2e_test_*.py"
|
||||
|
||||
frontend:
|
||||
npm run dev
|
||||
|
||||
284
README.md
@@ -1,25 +1,11 @@
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://github.com/sissbruecker/linkding">
|
||||
<img src="docs/header.svg" height="50">
|
||||
<img src="assets/header.svg" height="50">
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
- [Introduction](#introduction)
|
||||
- [Installation](#installation)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Using Docker Compose](#using-docker-compose)
|
||||
- [User Setup](#user-setup)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Managed Hosting Options](#managed-hosting-options)
|
||||
- [Documentation](#documentation)
|
||||
- [Browser Extension](#browser-extension)
|
||||
- [Community](#community)
|
||||
- [Acknowledgements + Donations](#acknowledgements--donations)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
|
||||
linkding is a bookmark manager that you can host yourself.
|
||||
@@ -49,255 +35,57 @@ The name comes from:
|
||||
|
||||
**Screenshot:**
|
||||
|
||||

|
||||

|
||||
|
||||
## Installation
|
||||
## Getting Started
|
||||
|
||||
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
|
||||
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
|
||||
linkding uses an SQLite database by default.
|
||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||
|
||||
### Using Docker
|
||||
|
||||
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>latest</code></td>
|
||||
<td>Provides the basic functionality of linkding</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-plus</code></td>
|
||||
<td>
|
||||
Includes feature for archiving websites as HTML snapshots
|
||||
<ul>
|
||||
<li>Significantly larger image size as it includes a Chromium installation</li>
|
||||
<li>Requires more runtime memory to run Chromium</li>
|
||||
<li>Requires more disk space for storing HTML snapshots</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-alpine</code></td>
|
||||
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>latest-plus-alpine</code></td>
|
||||
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
|
||||
|
||||
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
|
||||
|
||||
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
|
||||
### User Setup
|
||||
|
||||
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
**Docker Compose**
|
||||
```shell
|
||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
|
||||
|
||||
<details>
|
||||
<summary>Apache</summary>
|
||||
|
||||
Apache2 does not change the headers by default, and should not
|
||||
need additional configuration.
|
||||
|
||||
An example virtual host that proxies to linkding might look like:
|
||||
```
|
||||
<VirtualHost *:9100>
|
||||
<Proxy *>
|
||||
Order deny,allow
|
||||
Allow from all
|
||||
</Proxy>
|
||||
|
||||
ProxyPass / http://linkding:9090/
|
||||
ProxyPassReverse / http://linkding:9090/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
|
||||
|
||||
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Caddy 2</summary>
|
||||
|
||||
Caddy does not change the headers by default, and should not need any further configuration.
|
||||
|
||||
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
|
||||
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
|
||||
```
|
||||
location /linkding {
|
||||
...
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
### Managed Hosting Options
|
||||
|
||||
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
|
||||
|
||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
||||
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
||||
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
|
||||
The following links help you to get started with linkding:
|
||||
- [Install linkding on your own server](https://linkding.link/installation) or [check managed hosting options](https://linkding.link/managed-hosting)
|
||||
- [Install the browser extension](https://linkding.link/browser-extension)
|
||||
- [Check out community projects](https://linkding.link/community), which include mobile apps, browser extensions, libraries and more
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
|
||||
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
||||
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
|
||||
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
|
||||
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
|
||||
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
|
||||
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||
The full documentation is now available at [linkding.link](https://linkding.link/).
|
||||
|
||||
## Browser Extension
|
||||
If you want to contribute to the documentation, you can find the source files in the `docs` folder.
|
||||
|
||||
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
|
||||
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
|
||||
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
If you want to contribute a community project, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md).
|
||||
|
||||
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
||||
## Contributing
|
||||
|
||||
## Community
|
||||
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements + Donations
|
||||
|
||||
### PikaPods
|
||||
|
||||
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
|
||||
|
||||
See the table below for a list of donations.
|
||||
|
||||
| Source | Description | Amount | Donated to |
|
||||
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
|
||||
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
|
||||
|
||||
### JetBrains
|
||||
|
||||
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||
Small improvements, bugfixes and documentation improvements are always welcome. If you want to contribute a larger feature, consider opening an issue first to discuss it. I may choose to ignore PRs for features that don't align with the project's goals or that I don't want to maintain.
|
||||
|
||||
## Development
|
||||
|
||||
The application is open source, so you are free to modify or contribute. 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.10
|
||||
- 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
|
||||
|
||||
@@ -305,32 +93,40 @@ The frontend is now available under http://localhost:8000
|
||||
|
||||
Run all tests with pytest:
|
||||
```
|
||||
pytest
|
||||
make test
|
||||
```
|
||||
|
||||
|
||||
### Linting
|
||||
|
||||
Run linting with ruff:
|
||||
```
|
||||
make lint
|
||||
```
|
||||
|
||||
### Formatting
|
||||
|
||||
Format Python code with black, and JavaScript code with prettier:
|
||||
Format Python code with ruff, Django templates with djlint, and JavaScript code with prettier:
|
||||
```
|
||||
make format
|
||||
```
|
||||
|
||||
### DevContainers
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
|
||||
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:
|
||||
|
||||
Create a user for the frontend:
|
||||
```
|
||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
uv run manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||
```
|
||||
npm run dev
|
||||
make frontend
|
||||
```
|
||||
Start the Django development server with:
|
||||
```
|
||||
python3 manage.py runserver
|
||||
make serve
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
41
assets/header.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 2126 591" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1265.31,-1395.82)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.823127,0,0,0.823127,-786.171,-888.198)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:35.43px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
BIN
assets/logo.png
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -1 +1,17 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 450 450" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1,0,0,1,-70.3466,-70.3466)">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1257.39,-1386.74)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.793058,0,0,0.793058,-739.034,-836.215)">
|
||||
<g transform="matrix(0.707351,0.706862,-0.706862,0.707351,1331.93,-512.804)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.710067,-0.704134,0.704134,-0.710067,1284.12,3366.41)">
|
||||
<path d="M1244.39,1293.95L1244.39,1493.59C1244.39,1493.59 1243.58,1561.48 1319.29,1562.47C1395.27,1563.46 1394.17,1493.59 1394.17,1493.59L1394.17,1293.95" style="fill:none;stroke:white;stroke-width:34.15px;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 688 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,3 +1,6 @@
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
@@ -6,12 +9,19 @@ from django.core.paginator import Paginator
|
||||
from django.db.models import Count, QuerySet
|
||||
from django.shortcuts import render
|
||||
from django.urls import path
|
||||
from django.utils.translation import ngettext, gettext
|
||||
from django.utils.translation import gettext, ngettext
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import TokenProxy
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import (
|
||||
ApiToken,
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
FeedToken,
|
||||
Tag,
|
||||
Toast,
|
||||
UserProfile,
|
||||
)
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -36,12 +46,14 @@ class TaskPaginator(Paginator):
|
||||
|
||||
# Copied from Huey's SqliteStorage with some modifications to allow pagination
|
||||
def enqueued_items(self, limit, offset):
|
||||
to_bytes = lambda b: bytes(b) if not isinstance(b, bytes) else b
|
||||
def to_bytes(b):
|
||||
return bytes(b) if not isinstance(b, bytes) else b
|
||||
|
||||
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
|
||||
params = (huey.storage.name, limit, offset)
|
||||
|
||||
serialized_tasks = [
|
||||
to_bytes(i) for i, in huey.storage.sql(sql, params, results=True)
|
||||
to_bytes(i) for (i,) in huey.storage.sql(sql, params, results=True)
|
||||
]
|
||||
return [huey.deserialize_task(task) for task in serialized_tasks]
|
||||
|
||||
@@ -75,6 +87,7 @@ class LinkdingAdminSite(AdminSite):
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
app_list = super().get_app_list(request, app_label)
|
||||
context_path = os.getenv("LD_CONTEXT_PATH", "")
|
||||
app_list += [
|
||||
{
|
||||
"name": "Huey",
|
||||
@@ -83,7 +96,7 @@ class LinkdingAdminSite(AdminSite):
|
||||
{
|
||||
"name": "Queued tasks",
|
||||
"object_name": "background_tasks",
|
||||
"admin_url": "/admin/tasks/",
|
||||
"admin_url": f"/{context_path}admin/tasks/",
|
||||
"view_only": True,
|
||||
}
|
||||
],
|
||||
@@ -206,7 +219,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_display_name",
|
||||
"display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
@@ -256,6 +269,21 @@ class AdminTag(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkBundle(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"owner",
|
||||
"order",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"date_created",
|
||||
)
|
||||
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
list_filter = ("owner__username",)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
@@ -270,7 +298,7 @@ class AdminCustomUser(UserAdmin):
|
||||
def get_inline_instances(self, request, obj=None):
|
||||
if not obj:
|
||||
return list()
|
||||
return super(AdminCustomUser, self).get_inline_instances(request, obj)
|
||||
return super().get_inline_instances(request, obj)
|
||||
|
||||
|
||||
class AdminToast(admin.ModelAdmin):
|
||||
@@ -285,11 +313,26 @@ class AdminFeedToken(admin.ModelAdmin):
|
||||
list_filter = ("user__username",)
|
||||
|
||||
|
||||
class ApiTokenAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ApiToken
|
||||
fields = ("name", "user")
|
||||
|
||||
|
||||
class AdminApiToken(admin.ModelAdmin):
|
||||
form = ApiTokenAdminForm
|
||||
list_display = ("name", "user", "created")
|
||||
search_fields = ["name", "user__username"]
|
||||
list_filter = ("user__username",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(ApiToken, AdminApiToken)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||
|
||||
38
bookmarks/api/auth.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
||||
|
||||
from bookmarks.models import ApiToken
|
||||
|
||||
|
||||
class LinkdingTokenAuthentication(TokenAuthentication):
|
||||
"""
|
||||
Extends DRF TokenAuthentication to add support for multiple keywords and
|
||||
multiple tokens per user.
|
||||
"""
|
||||
|
||||
model = ApiToken
|
||||
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) from None
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
@@ -1,25 +1,34 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from django.conf import settings
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import (
|
||||
BookmarkAssetSerializer,
|
||||
BookmarkBundleSerializer,
|
||||
BookmarkSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
)
|
||||
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,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
User,
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.services import assets, auto_tagging, bookmarks, bundles, website_loader
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +41,7 @@ class BookmarkViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
@@ -46,71 +56,63 @@ 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):
|
||||
return {"request": self.request, "user": self.request.user}
|
||||
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"]
|
||||
bookmark = Bookmark.query_existing(request.user, url).first()
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
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
|
||||
@@ -120,7 +122,7 @@ class BookmarkViewSet(
|
||||
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||
f"Failed to auto-tag bookmark. url={url}",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
@@ -133,6 +135,116 @@ 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,
|
||||
)
|
||||
|
||||
bookmark = Bookmark.query_existing(request.user, 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") # noqa: SIM115
|
||||
)
|
||||
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") from None
|
||||
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,
|
||||
@@ -140,6 +252,7 @@ class TagViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = TagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -152,11 +265,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")
|
||||
|
||||
@@ -3,9 +3,18 @@ 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
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
Tag,
|
||||
UserProfile,
|
||||
build_tag_string,
|
||||
)
|
||||
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):
|
||||
@@ -20,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.get("order", None)
|
||||
return bundles.create_bundle(bundle, self.context["user"])
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
@@ -29,8 +69,6 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
@@ -40,29 +78,31 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
read_only_fields = [
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"tag_names",
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
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 = EmtpyField()
|
||||
website_description = EmtpyField()
|
||||
# these are optional
|
||||
date_added = serializers.DateTimeField(required=False)
|
||||
date_modified = serializers.DateTimeField(required=False)
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
@@ -80,30 +120,75 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||
return preview_image_url
|
||||
|
||||
def get_web_archive_snapshot_url(self, obj: Bookmark):
|
||||
if obj.web_archive_snapshot_url:
|
||||
return obj.web_archive_snapshot_url
|
||||
|
||||
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"])
|
||||
return create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
tag_names = validated_data.pop("tag_names", [])
|
||||
tag_string = build_tag_string(tag_names)
|
||||
bookmark = Bookmark(**validated_data)
|
||||
|
||||
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 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):
|
||||
@@ -131,4 +216,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"search_preferences",
|
||||
"version",
|
||||
]
|
||||
|
||||
version = serializers.ReadOnlyField(default=app_version)
|
||||
|
||||
@@ -6,4 +6,5 @@ class BookmarksConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
# Register signal handlers
|
||||
import bookmarks.signals
|
||||
# noinspection PyUnusedImports
|
||||
import bookmarks.signals # noqa: F401
|
||||
|
||||
@@ -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,176 +0,0 @@
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_details(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
title = details_modal.locator("h2")
|
||||
expect(title).to_have_text(bookmark.title)
|
||||
|
||||
def test_close_details(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# close with close button
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
details_modal.locator("button.close").click()
|
||||
expect(details_modal).to_be_hidden()
|
||||
|
||||
# close with backdrop
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
overlay = details_modal.locator(".modal-overlay")
|
||||
overlay.click(position={"x": 0, "y": 0})
|
||||
expect(details_modal).to_be_hidden()
|
||||
|
||||
# close with escape
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
self.page.keyboard.press("Escape")
|
||||
expect(details_modal).to_be_hidden()
|
||||
|
||||
def test_toggle_archived(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# archive
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
details_modal.get_by_text("Archived", exact=False).click()
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
# unarchive
|
||||
url = reverse("bookmarks:archived")
|
||||
self.page.goto(self.live_server_url + url)
|
||||
self.resetReloads()
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
details_modal.get_by_text("Archived", exact=False).click()
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_toggle_unread(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# mark as unread
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
details_modal.get_by_text("Unread").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Unread")).to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
# mark as read
|
||||
details_modal.get_by_text("Unread").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_toggle_shared(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# share bookmark
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
details_modal.get_by_text("Shared").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Shared")).to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
# unshare bookmark
|
||||
details_modal.get_by_text("Shared").click()
|
||||
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_edit_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
# Navigate to edit page
|
||||
with self.page.expect_navigation():
|
||||
details_modal.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
self.page.get_by_text("Nevermind").click()
|
||||
|
||||
def test_delete(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
|
||||
# Delete bookmark, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
details_modal.get_by_text("Delete...").click()
|
||||
details_modal.get_by_text("Confirm").click()
|
||||
|
||||
# verify bookmark is deleted
|
||||
self.locate_bookmark(bookmark.title)
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_snapshot_remove_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||
self.open(url, p)
|
||||
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
asset_list = details_modal.locator(".assets")
|
||||
|
||||
# No snapshots initially
|
||||
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
|
||||
expect(snapshot).not_to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
|
||||
self.assertReloads(0)
|
||||
|
||||
# Has new snapshots
|
||||
expect(snapshot).to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
asset_list.get_by_text("Remove", exact=False).click()
|
||||
asset_list.get_by_text("Confirm", exact=False).click()
|
||||
|
||||
# Snapshot is removed
|
||||
expect(snapshot).not_to_be_visible()
|
||||
self.assertReloads(0)
|
||||
@@ -1,37 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_edit_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||
|
||||
# Navigate to edit page
|
||||
with self.page.expect_navigation():
|
||||
self.page.get_by_text("Edit").click()
|
||||
|
||||
# Cancel edit, verify return url
|
||||
with self.page.expect_navigation(
|
||||
url=self.live_server_url
|
||||
+ reverse("bookmarks:details", args=[bookmark.id])
|
||||
):
|
||||
self.page.get_by_text("Nevermind").click()
|
||||
|
||||
def test_delete_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||
|
||||
# Trigger delete, verify return url
|
||||
# Should probably return to last bookmark list page, but for now just returns to index
|
||||
with self.page.expect_navigation(
|
||||
url=self.live_server_url + reverse("bookmarks:index")
|
||||
):
|
||||
self.page.get_by_text("Delete...").click()
|
||||
self.page.get_by_text("Confirm").click()
|
||||
@@ -1,109 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
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")],
|
||||
website_title="Existing website title",
|
||||
website_description="Existing website description",
|
||||
unread=True,
|
||||
)
|
||||
tag_names = " ".join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
# 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(
|
||||
existing_bookmark.website_title,
|
||||
page.get_by_label("Title").get_attribute("placeholder"),
|
||||
)
|
||||
self.assertEqual(
|
||||
existing_bookmark.website_description,
|
||||
page.get_by_label("Description").get_attribute("placeholder"),
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_edit_should_not_check_for_existing_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(
|
||||
self.live_server_url + reverse("bookmarks:edit", args=[bookmark.id])
|
||||
)
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
|
||||
|
||||
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:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
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
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
url = self.live_server_url + reverse("bookmarks:new")
|
||||
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
page.goto(url)
|
||||
|
||||
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()
|
||||
@@ -1,25 +0,0 @@
|
||||
from unittest import skip
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
|
||||
@skip("Fails in CI, needs investigation")
|
||||
def test_toggle_notes_should_show_hide_notes(self):
|
||||
bookmark = self.setup_bookmark(notes="Test notes")
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
notes = self.locate_bookmark(bookmark.title).locator(".notes")
|
||||
expect(notes).to_be_hidden()
|
||||
|
||||
toggle_notes = page.locator("li button.toggle-notes")
|
||||
toggle_notes.click()
|
||||
expect(notes).to_be_visible()
|
||||
|
||||
toggle_notes.click()
|
||||
expect(notes).to_be_hidden()
|
||||
@@ -1,335 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
def setup_test_data(self):
|
||||
self.setup_numbered_bookmarks(50)
|
||||
self.setup_numbered_bookmarks(50, archived=True)
|
||||
self.setup_numbered_bookmarks(50, prefix="foo")
|
||||
self.setup_numbered_bookmarks(50, archived=True, prefix="foo")
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_active_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_active_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index") + "?q=foo", p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived") + "?q=foo", p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_select_all_toggles_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
page = self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
self.assertEqual(6, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).to_be_checked()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
def test_select_all_shows_select_across(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_visible()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
def test_select_across_is_unchecked_when_toggling_all(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
# Show select across, check it
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling select all
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_select_across_is_unchecked_when_toggling_bookmark(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
# Show select across, check it
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling a single bookmark
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_execute_resets_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
page = self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
# Execute bulk action
|
||||
self.select_bulk_action("Mark as unread")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
self.assertEqual(31, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
# Toggle select all and verify select across is reset
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_update_select_across_bookmark_count(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(
|
||||
self.locate_bulk_edit_bar().get_by_text("All pages (100 bookmarks)")
|
||||
).to_be_visible()
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(
|
||||
self.locate_bulk_edit_bar().get_by_text("All pages (70 bookmarks)")
|
||||
).to_be_visible()
|
||||
@@ -1,308 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
def setup_fixture(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
# create a number of bookmarks with different states / visibility to
|
||||
# verify correct data is loaded on update
|
||||
self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
self.setup_numbered_bookmarks(
|
||||
3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True),
|
||||
)
|
||||
|
||||
def assertVisibleBookmarks(self, titles: List[str]):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
expect(bookmark_tags).to_have_count(len(titles))
|
||||
|
||||
for title in titles:
|
||||
matching_tag = bookmark_tags.filter(has_text=title)
|
||||
expect(matching_tag).to_be_visible()
|
||||
|
||||
def assertVisibleTags(self, titles: List[str]):
|
||||
tag_tags = self.page.locator(".tag-cloud .unselected-tags a")
|
||||
expect(tag_tags).to_have_count(len(titles))
|
||||
|
||||
for title in titles:
|
||||
matching_tag = tag_tags.filter(has_text=title)
|
||||
expect(matching_tag).to_be_visible()
|
||||
|
||||
def test_partial_update_respects_query(self):
|
||||
self.setup_numbered_bookmarks(5, prefix="foo")
|
||||
self.setup_numbered_bookmarks(5, prefix="bar")
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + "?q=foo"
|
||||
self.open(url, p)
|
||||
|
||||
self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"])
|
||||
|
||||
self.locate_bookmark("foo 2").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(["foo 1", "foo 3", "foo 4", "foo 5"])
|
||||
|
||||
def test_partial_update_respects_sort(self):
|
||||
self.setup_numbered_bookmarks(5, prefix="foo")
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + "?sort=title_asc"
|
||||
page = self.open(url, p)
|
||||
|
||||
first_item = page.locator("li[ld-bookmark-item]").first
|
||||
expect(first_item).to_contain_text("foo 1")
|
||||
|
||||
first_item.get_by_text("Archive").click()
|
||||
|
||||
first_item = page.locator("li[ld-bookmark-item]").first
|
||||
expect(first_item).to_contain_text("foo 2")
|
||||
|
||||
def test_partial_update_respects_page(self):
|
||||
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
|
||||
self.setup_numbered_bookmarks(50, prefix="foo", suffix="-")
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index") + "?q=foo&page=2"
|
||||
self.open(url, p)
|
||||
|
||||
# with descending sort, page two has 'foo 1' to 'foo 20'
|
||||
expected_titles = [f"foo {i}-" for i in range(1, 21)]
|
||||
self.assertVisibleBookmarks(expected_titles)
|
||||
|
||||
self.locate_bookmark("foo 20-").get_by_text("Archive").click()
|
||||
|
||||
expected_titles = [f"foo {i}-" for i in range(1, 20)]
|
||||
self.assertVisibleBookmarks(expected_titles)
|
||||
|
||||
def test_multiple_partial_updates(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bookmark("Bookmark 1").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(
|
||||
["Bookmark 2", "Bookmark 3", "Bookmark 4", "Bookmark 5"]
|
||||
)
|
||||
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(["Bookmark 3", "Bookmark 4", "Bookmark 5"])
|
||||
|
||||
self.locate_bookmark("Bookmark 3").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(["Bookmark 4", "Bookmark 5"])
|
||||
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_archive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_mark_as_read(self):
|
||||
self.setup_fixture()
|
||||
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
|
||||
bookmark2.unread = True
|
||||
bookmark2.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_unshare(self):
|
||||
self.setup_fixture()
|
||||
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
|
||||
bookmark2.shared = True
|
||||
bookmark2.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_bulk_archive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Archive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_bulk_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_unarchive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Unarchive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_shared_bookmarks_partial_update_on_unarchive(self):
|
||||
self.setup_fixture()
|
||||
self.setup_numbered_bookmarks(
|
||||
3, shared=True, prefix="My Bookmark", with_tags=True
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:shared"), p)
|
||||
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click()
|
||||
|
||||
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
|
||||
self.assertVisibleBookmarks(
|
||||
[
|
||||
"My Bookmark 1",
|
||||
"My Bookmark 2",
|
||||
"My Bookmark 3",
|
||||
"Joe's Bookmark 1",
|
||||
"Joe's Bookmark 2",
|
||||
"Joe's Bookmark 3",
|
||||
]
|
||||
)
|
||||
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 2", "Shared Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_shared_bookmarks_partial_update_on_delete(self):
|
||||
self.setup_fixture()
|
||||
self.setup_numbered_bookmarks(
|
||||
3, shared=True, prefix="My Bookmark", with_tags=True
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:shared"), p)
|
||||
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(
|
||||
[
|
||||
"My Bookmark 1",
|
||||
"My Bookmark 3",
|
||||
"Joe's Bookmark 1",
|
||||
"Joe's Bookmark 2",
|
||||
"Joe's Bookmark 3",
|
||||
]
|
||||
)
|
||||
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 3"])
|
||||
self.assertReloads(0)
|
||||
@@ -1,30 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_focus_search(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:index"))
|
||||
|
||||
page.press("body", "s")
|
||||
|
||||
expect(page.get_by_placeholder("Search for words or #tags")).to_be_focused()
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_add_bookmark(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:index"))
|
||||
|
||||
page.press("body", "n")
|
||||
|
||||
expect(page).to_have_url(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
browser.close()
|
||||
@@ -1,88 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
|
||||
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
enable_sharing = page.get_by_label("Enable bookmark sharing")
|
||||
enable_sharing_label = page.get_by_text("Enable bookmark sharing")
|
||||
enable_public_sharing = page.get_by_label("Enable public bookmark sharing")
|
||||
enable_public_sharing_label = page.get_by_text(
|
||||
"Enable public bookmark sharing"
|
||||
)
|
||||
|
||||
# Public sharing is disabled by default
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
|
||||
# Enable sharing
|
||||
enable_sharing_label.click()
|
||||
expect(enable_sharing).to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_enabled()
|
||||
|
||||
# Enable public sharing
|
||||
enable_public_sharing_label.click()
|
||||
expect(enable_public_sharing).to_be_checked()
|
||||
expect(enable_public_sharing).to_be_enabled()
|
||||
|
||||
# Disable sharing
|
||||
enable_sharing_label.click()
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
|
||||
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.bookmark_description_display = (
|
||||
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
|
||||
)
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
max_lines = page.get_by_label("Bookmark description max lines")
|
||||
expect(max_lines).to_be_hidden()
|
||||
|
||||
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.bookmark_description_display = (
|
||||
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
|
||||
)
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
max_lines = page.get_by_label("Bookmark description max lines")
|
||||
expect(max_lines).to_be_visible()
|
||||
|
||||
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
max_lines = page.get_by_label("Bookmark description max lines")
|
||||
expect(max_lines).to_be_hidden()
|
||||
|
||||
display = page.get_by_label("Bookmark description", exact=True)
|
||||
display.select_option("separate")
|
||||
expect(max_lines).to_be_visible()
|
||||
|
||||
display.select_option("inline")
|
||||
expect(max_lines).to_be_hidden()
|
||||
@@ -1,76 +0,0 @@
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect, Locator
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_modal_close_modal(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
)
|
||||
modal_trigger.click()
|
||||
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
|
||||
# close with backdrop
|
||||
backdrop = modal.locator(".modal-overlay")
|
||||
backdrop.click(position={"x": 0, "y": 0})
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
def test_select_tag(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
)
|
||||
modal_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
modal = page.locator(".modal")
|
||||
unselected_tags = modal.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
# select tag
|
||||
unselected_tags.get_by_text("cooking").click()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
|
||||
# verify tag is selected, other tag is not visible anymore
|
||||
selected_tags = modal.locator(".selected-tags")
|
||||
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||
|
||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()
|
||||
@@ -1,81 +0,0 @@
|
||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.client.force_login(self.get_or_create_test_user())
|
||||
self.cookie = self.client.cookies["sessionid"]
|
||||
|
||||
def setup_browser(self, playwright) -> BrowserContext:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
context = browser.new_context()
|
||||
context.add_cookies(
|
||||
[
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": self.cookie.value,
|
||||
"domain": self.live_server_url.replace("http:", ""),
|
||||
"path": "/",
|
||||
}
|
||||
]
|
||||
)
|
||||
return context
|
||||
|
||||
def open(self, url: str, playwright: Playwright) -> Page:
|
||||
browser = self.setup_browser(playwright)
|
||||
self.page = browser.new_page()
|
||||
self.page.goto(self.live_server_url + url)
|
||||
self.page.on("load", self.on_load)
|
||||
self.num_loads = 0
|
||||
return self.page
|
||||
|
||||
def on_load(self):
|
||||
self.num_loads += 1
|
||||
|
||||
def assertReloads(self, count: int):
|
||||
self.assertEqual(self.num_loads, count)
|
||||
|
||||
def resetReloads(self):
|
||||
self.num_loads = 0
|
||||
|
||||
def locate_bookmark_list(self):
|
||||
return self.page.locator("ul[ld-bookmark-list]")
|
||||
|
||||
def locate_bookmark(self, title: str):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
def locate_details_modal(self):
|
||||
return self.page.locator(".modal.bookmark-details")
|
||||
|
||||
def open_details_modal(self, bookmark):
|
||||
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
|
||||
details_button.click()
|
||||
|
||||
details_modal = self.locate_details_modal()
|
||||
expect(details_modal).to_be_visible()
|
||||
|
||||
return details_modal
|
||||
|
||||
def locate_bulk_edit_bar(self):
|
||||
return self.page.locator(".bulk-edit-bar")
|
||||
|
||||
def locate_bulk_edit_select_all(self):
|
||||
return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
|
||||
|
||||
def locate_bulk_edit_select_across(self):
|
||||
return self.locate_bulk_edit_bar().locator("label.select-across")
|
||||
|
||||
def locate_bulk_edit_toggle(self):
|
||||
return self.page.get_by_title("Bulk edit")
|
||||
|
||||
def select_bulk_action(self, value: str):
|
||||
return (
|
||||
self.locate_bulk_edit_bar()
|
||||
.locator('select[name="bulk_action"]')
|
||||
.select_option(value)
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
from bookmarks.views import access
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,10 +31,16 @@ def sanitize(text: str):
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
bundle = None
|
||||
bundle_id = request.GET.get("bundle")
|
||||
if bundle_id:
|
||||
bundle = access.bundle_read(request, bundle_id)
|
||||
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
bundle=bundle,
|
||||
)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
@@ -43,10 +50,7 @@ class BaseBookmarksFeed(Feed):
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
limit = context.request.GET.get("limit", 100)
|
||||
if limit:
|
||||
data = context.query_set[: int(limit)]
|
||||
else:
|
||||
data = list(context.query_set)
|
||||
data = context.query_set[: int(limit)] if limit else list(context.query_set)
|
||||
prefetch_related_objects(data, "tags")
|
||||
return data
|
||||
|
||||
@@ -74,7 +78,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 +91,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 +104,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 +118,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")
|
||||
|
||||
365
bookmarks/forms.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
GlobalSettings,
|
||||
Tag,
|
||||
UserProfile,
|
||||
build_tag_string,
|
||||
parse_tag_string,
|
||||
sanitize_tag_name,
|
||||
)
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
from bookmarks.widgets import (
|
||||
FormCheckbox,
|
||||
FormErrorList,
|
||||
FormInput,
|
||||
FormNumberInput,
|
||||
FormSelect,
|
||||
FormTextarea,
|
||||
TagAutocomplete,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()], widget=FormInput)
|
||||
tag_string = forms.CharField(required=False, widget=TagAutocomplete)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False, widget=FormInput)
|
||||
description = forms.CharField(required=False, widget=FormTextarea)
|
||||
notes = forms.CharField(required=False, widget=FormTextarea)
|
||||
unread = forms.BooleanField(required=False, widget=FormCheckbox)
|
||||
shared = forms.BooleanField(required=False, widget=FormCheckbox)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
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=FormErrorList
|
||||
)
|
||||
|
||||
@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:
|
||||
is_duplicate = (
|
||||
Bookmark.query_existing(self.instance.owner, 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):
|
||||
name = forms.CharField(widget=FormInput)
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ["name"]
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs, error_class=FormErrorList)
|
||||
self.user = user
|
||||
|
||||
def clean_name(self):
|
||||
name = self.cleaned_data.get("name", "").strip()
|
||||
|
||||
name = sanitize_tag_name(name)
|
||||
|
||||
queryset = Tag.objects.filter(name__iexact=name, owner=self.user)
|
||||
if self.instance.pk:
|
||||
queryset = queryset.exclude(pk=self.instance.pk)
|
||||
|
||||
if queryset.exists():
|
||||
raise forms.ValidationError(f'Tag "{name}" already exists.')
|
||||
|
||||
return name
|
||||
|
||||
def save(self, commit=True):
|
||||
tag = super().save(commit=False)
|
||||
if not self.instance.pk:
|
||||
tag.owner = self.user
|
||||
tag.date_added = timezone.now()
|
||||
else:
|
||||
tag.date_modified = timezone.now()
|
||||
if commit:
|
||||
tag.save()
|
||||
return tag
|
||||
|
||||
|
||||
class TagMergeForm(forms.Form):
|
||||
target_tag = forms.CharField(widget=TagAutocomplete)
|
||||
merge_tags = forms.CharField(widget=TagAutocomplete)
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs, error_class=FormErrorList)
|
||||
self.user = user
|
||||
|
||||
def clean_target_tag(self):
|
||||
target_tag_name = self.cleaned_data.get("target_tag", "")
|
||||
|
||||
target_tag_names = parse_tag_string(target_tag_name, " ")
|
||||
if len(target_tag_names) != 1:
|
||||
raise forms.ValidationError(
|
||||
"Please enter only one tag name for the target tag."
|
||||
)
|
||||
|
||||
target_tag_name = target_tag_names[0]
|
||||
|
||||
try:
|
||||
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
|
||||
except Tag.DoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
f'Tag "{target_tag_name}" does not exist.'
|
||||
) from None
|
||||
|
||||
return target_tag
|
||||
|
||||
def clean_merge_tags(self):
|
||||
merge_tags_string = self.cleaned_data.get("merge_tags", "")
|
||||
|
||||
merge_tag_names = parse_tag_string(merge_tags_string, " ")
|
||||
if not merge_tag_names:
|
||||
raise forms.ValidationError("Please enter at least one tag to merge.")
|
||||
|
||||
merge_tags = []
|
||||
for tag_name in merge_tag_names:
|
||||
try:
|
||||
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
|
||||
merge_tags.append(tag)
|
||||
except Tag.DoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
f'Tag "{tag_name}" does not exist.'
|
||||
) from None
|
||||
|
||||
target_tag = self.cleaned_data.get("target_tag")
|
||||
if target_tag and target_tag in merge_tags:
|
||||
raise forms.ValidationError(
|
||||
"The target tag cannot be selected for merging."
|
||||
)
|
||||
|
||||
return merge_tags
|
||||
|
||||
|
||||
class BookmarkBundleForm(forms.ModelForm):
|
||||
name = forms.CharField(max_length=256, widget=FormInput)
|
||||
search = forms.CharField(max_length=256, required=False, widget=FormInput)
|
||||
any_tags = forms.CharField(required=False, widget=TagAutocomplete)
|
||||
all_tags = forms.CharField(required=False, widget=TagAutocomplete)
|
||||
excluded_tags = forms.CharField(required=False, widget=TagAutocomplete)
|
||||
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs, error_class=FormErrorList)
|
||||
|
||||
|
||||
class BookmarkSearchForm(forms.Form):
|
||||
SORT_CHOICES = [
|
||||
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
|
||||
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
|
||||
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
|
||||
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
|
||||
]
|
||||
FILTER_SHARED_CHOICES = [
|
||||
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
|
||||
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
|
||||
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
|
||||
]
|
||||
FILTER_UNREAD_CHOICES = [
|
||||
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
|
||||
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
|
||||
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
|
||||
]
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField(required=False, widget=FormSelect)
|
||||
bundle = forms.CharField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES, widget=FormSelect)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
modified_since = forms.CharField(required=False)
|
||||
added_since = forms.CharField(required=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
search: BookmarkSearch,
|
||||
editable_fields: list[str] = None,
|
||||
users: list[User] = None,
|
||||
):
|
||||
super().__init__()
|
||||
editable_fields = editable_fields or []
|
||||
self.editable_fields = editable_fields
|
||||
|
||||
# set choices for user field if users are provided
|
||||
if users:
|
||||
user_choices = [(user.username, user.username) for user in users]
|
||||
user_choices.insert(0, ("", "Everyone"))
|
||||
self.fields["user"].choices = user_choices
|
||||
|
||||
for param in search.params:
|
||||
# set initial values for modified params
|
||||
value = search.__dict__.get(param)
|
||||
if isinstance(value, models.Model):
|
||||
self.fields[param].initial = value.id
|
||||
else:
|
||||
self.fields[param].initial = value
|
||||
|
||||
# Mark non-editable modified fields as hidden. That way, templates
|
||||
# rendering a form can just loop over hidden_fields to ensure that
|
||||
# all necessary search options are kept when submitting the form.
|
||||
if search.is_modified(param) and param not in editable_fields:
|
||||
self.fields[param].widget = forms.HiddenInput()
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"theme",
|
||||
"bookmark_date_display",
|
||||
"bookmark_description_display",
|
||||
"bookmark_description_max_lines",
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"tag_grouping",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"enable_preview_images",
|
||||
"enable_automatic_html_snapshots",
|
||||
"display_url",
|
||||
"display_view_bookmark_action",
|
||||
"display_edit_bookmark_action",
|
||||
"display_archive_bookmark_action",
|
||||
"display_remove_bookmark_action",
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"default_mark_shared",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
"hide_bundles",
|
||||
"legacy_search",
|
||||
]
|
||||
widgets = {
|
||||
"theme": FormSelect,
|
||||
"bookmark_date_display": FormSelect,
|
||||
"bookmark_description_display": FormSelect,
|
||||
"bookmark_description_max_lines": FormNumberInput,
|
||||
"bookmark_link_target": FormSelect,
|
||||
"web_archive_integration": FormSelect,
|
||||
"tag_search": FormSelect,
|
||||
"tag_grouping": FormSelect,
|
||||
"auto_tagging_rules": FormTextarea,
|
||||
"custom_css": FormTextarea,
|
||||
"items_per_page": FormNumberInput,
|
||||
"display_url": FormCheckbox,
|
||||
"permanent_notes": FormCheckbox,
|
||||
"display_view_bookmark_action": FormCheckbox,
|
||||
"display_edit_bookmark_action": FormCheckbox,
|
||||
"display_archive_bookmark_action": FormCheckbox,
|
||||
"display_remove_bookmark_action": FormCheckbox,
|
||||
"sticky_pagination": FormCheckbox,
|
||||
"collapse_side_panel": FormCheckbox,
|
||||
"hide_bundles": FormCheckbox,
|
||||
"legacy_search": FormCheckbox,
|
||||
"enable_favicons": FormCheckbox,
|
||||
"enable_preview_images": FormCheckbox,
|
||||
"enable_sharing": FormCheckbox,
|
||||
"enable_public_sharing": FormCheckbox,
|
||||
"enable_automatic_html_snapshots": FormCheckbox,
|
||||
"default_mark_unread": FormCheckbox,
|
||||
"default_mark_shared": FormCheckbox,
|
||||
}
|
||||
|
||||
|
||||
class GlobalSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GlobalSettings
|
||||
fields = ["landing_page", "guest_profile_user", "enable_link_prefetch"]
|
||||
widgets = {
|
||||
"landing_page": FormSelect,
|
||||
"guest_profile_user": FormSelect,
|
||||
"enable_link_prefetch": FormCheckbox,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["guest_profile_user"].empty_label = "Standard profile"
|
||||
@@ -1,4 +1,4 @@
|
||||
export class ApiClient {
|
||||
export class Api {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
@@ -27,3 +27,6 @@ export class ApiClient {
|
||||
.then((data) => data.results);
|
||||
}
|
||||
}
|
||||
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
export const api = new Api(apiBaseUrl);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BookmarkItem extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
// Toggle notes
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
||||
}
|
||||
|
||||
// Add tooltip to title if it is truncated
|
||||
const titleAnchor = element.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
requestAnimationFrame(() => {
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.element.classList.toggle("show-notes");
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bookmark-item", BookmarkItem);
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BulkEdit extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.active = false;
|
||||
|
||||
this.onToggleActive = this.onToggleActive.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.init();
|
||||
// Reset when bookmarks are refreshed
|
||||
document.addEventListener("refresh-bookmark-list-done", () => this.init());
|
||||
}
|
||||
|
||||
init() {
|
||||
// Update elements
|
||||
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.element.querySelector(
|
||||
"select[name='bulk_action']",
|
||||
);
|
||||
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = this.element.querySelector("label.select-across");
|
||||
this.allCheckbox = this.element.querySelector(
|
||||
".bulk-edit-checkbox.all input",
|
||||
);
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
// Remove previous listeners if elements are the same
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
|
||||
// Add new listeners
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
onToggleActive() {
|
||||
this.active = !this.active;
|
||||
if (this.active) {
|
||||
this.element.classList.add("active", "activating");
|
||||
setTimeout(() => {
|
||||
this.element.classList.remove("activating");
|
||||
}, 500);
|
||||
} else {
|
||||
this.element.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
onToggleBookmark() {
|
||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.allCheckbox.checked = allChecked;
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onToggleAll() {
|
||||
const allChecked = this.allCheckbox.checked;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = allChecked;
|
||||
});
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onActionSelected() {
|
||||
const action = this.actionSelect.value;
|
||||
|
||||
if (action === "bulk_tag" || action === "bulk_untag") {
|
||||
this.tagAutoComplete.classList.remove("d-none");
|
||||
} else {
|
||||
this.tagAutoComplete.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
} else {
|
||||
this.selectAcross.classList.add("d-none");
|
||||
this.selectAcross.querySelector("input").checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.allCheckbox.checked = false;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.updateSelectAcross(false);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bulk-edit", BulkEdit);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
element.dataset.type = element.type;
|
||||
element.dataset.name = element.name;
|
||||
element.dataset.value = element.value;
|
||||
element.removeAttribute("type");
|
||||
element.removeAttribute("name");
|
||||
element.removeAttribute("value");
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Behavior.interacting = false;
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
Behavior.interacting = true;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
);
|
||||
iconElement.style.width = "16px";
|
||||
iconElement.style.height = "16px";
|
||||
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
||||
container.append(iconElement);
|
||||
}
|
||||
|
||||
const question = this.element.getAttribute("ld-confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const buttonClasses = Array.from(this.element.classList.values())
|
||||
.filter((cls) => cls.startsWith("btn"))
|
||||
.join(" ");
|
||||
|
||||
const cancelButton = document.createElement(this.element.nodeName);
|
||||
cancelButton.type = "button";
|
||||
cancelButton.innerText = question ? "No" : "Cancel";
|
||||
cancelButton.className = `${buttonClasses} mr-1`;
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.dataset.type;
|
||||
confirmButton.name = this.element.dataset.name;
|
||||
confirmButton.value = this.element.dataset.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;
|
||||
this.container.remove();
|
||||
this.element.classList.remove("d-none");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class DropdownBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
|
||||
const toggle = element.querySelector(".dropdown-toggle");
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
this.element.classList.add("active");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.classList.remove("active");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
||||
|
||||
class FetchBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const eventName = element.getAttribute("ld-on");
|
||||
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
||||
|
||||
this.onFetch = this.onFetch.bind(this);
|
||||
this.onInterval = this.onInterval.bind(this);
|
||||
|
||||
element.addEventListener(eventName, this.onFetch);
|
||||
if (interval) {
|
||||
this.intervalId = setInterval(this.onInterval, interval);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
async onFetch(maybeEvent) {
|
||||
if (maybeEvent) {
|
||||
maybeEvent.preventDefault();
|
||||
}
|
||||
const url = this.element.getAttribute("ld-fetch");
|
||||
const html = await fetch(url).then((response) => response.text());
|
||||
|
||||
const target = this.element.getAttribute("ld-target");
|
||||
const select = this.element.getAttribute("ld-select");
|
||||
swap(this.element, html, { target, select });
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
fireEvents(events);
|
||||
}
|
||||
|
||||
onInterval() {
|
||||
if (Behavior.interacting) {
|
||||
return;
|
||||
}
|
||||
this.onFetch();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-fetch", FetchBehavior);
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Behavior, fireEvents, registerBehavior } from "./index";
|
||||
|
||||
class FormBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("submit", this.onSubmit.bind(this));
|
||||
}
|
||||
|
||||
async onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = this.element.action;
|
||||
const formData = new FormData(this.element);
|
||||
if (event.submitter) {
|
||||
formData.append(event.submitter.name, event.submitter.value);
|
||||
}
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual", // ignore redirect
|
||||
});
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
if (fireEvents) {
|
||||
fireEvents(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("change", () => {
|
||||
const form = element.closest("form");
|
||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const fileInput = element.nextElementSibling;
|
||||
|
||||
element.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const form = fileInput.closest("form");
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
event.submitter = element;
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form", FormBehavior);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class GlobalShortcuts extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle shortcuts for navigating bookmarks with arrow keys
|
||||
const isArrowUp = event.key === "ArrowUp";
|
||||
const isArrowDown = event.key === "ArrowDown";
|
||||
if (isArrowUp || isArrowDown) {
|
||||
event.preventDefault();
|
||||
|
||||
// Detect current bookmark list item
|
||||
const path = event.composedPath();
|
||||
const currentItem = path.find(
|
||||
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
|
||||
);
|
||||
|
||||
// Find next item
|
||||
let nextItem;
|
||||
if (currentItem) {
|
||||
nextItem = isArrowUp
|
||||
? currentItem.previousElementSibling
|
||||
: currentItem.nextElementSibling;
|
||||
} else {
|
||||
// Select first item
|
||||
nextItem = document.querySelector("[ld-bookmark-item]");
|
||||
}
|
||||
// Focus first link
|
||||
if (nextItem) {
|
||||
nextItem.querySelector("a").focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for toggling all notes
|
||||
if (event.key === "e") {
|
||||
const list = document.querySelector(".bookmark-list");
|
||||
if (list) {
|
||||
list.classList.toggle("show-notes");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for focusing search input
|
||||
if (event.key === "s") {
|
||||
const searchInput = document.querySelector('input[type="search"]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for adding new bookmark
|
||||
if (event.key === "n") {
|
||||
window.location.assign("/bookmarks/new");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-global-shortcuts", GlobalShortcuts);
|
||||
@@ -1,149 +0,0 @@
|
||||
const behaviorRegistry = {};
|
||||
const debug = false;
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && !node.isConnected) {
|
||||
destroyBehaviors(node);
|
||||
}
|
||||
});
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.isConnected) {
|
||||
applyBehaviors(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("turbo:load", () => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
applyBehaviors(document, [name]);
|
||||
}
|
||||
|
||||
export function applyBehaviors(container, behaviorNames = null) {
|
||||
if (!behaviorNames) {
|
||||
behaviorNames = Object.keys(behaviorRegistry);
|
||||
}
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const behavior = behaviorRegistry[behaviorName];
|
||||
const elements = Array.from(
|
||||
container.querySelectorAll(`[${behaviorName}]`),
|
||||
);
|
||||
|
||||
// Include the container element if it has the behavior
|
||||
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
|
||||
elements.push(container);
|
||||
}
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.__behaviors = element.__behaviors || [];
|
||||
const hasBehavior = element.__behaviors.some(
|
||||
(b) => b instanceof behavior,
|
||||
);
|
||||
|
||||
if (hasBehavior) {
|
||||
return;
|
||||
}
|
||||
|
||||
const behaviorInstance = new behavior(element);
|
||||
element.__behaviors.push(behaviorInstance);
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyBehaviors(element) {
|
||||
const behaviorNames = Object.keys(behaviorRegistry);
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
|
||||
elements.push(element);
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (!element.__behaviors) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.__behaviors.forEach((behavior) => {
|
||||
behavior.destroy();
|
||||
if (debug) {
|
||||
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
|
||||
}
|
||||
});
|
||||
delete element.__behaviors;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html, options) {
|
||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
let targetElement = element;
|
||||
let strategy = "innerHTML";
|
||||
if (options.target) {
|
||||
const parts = options.target.split("|");
|
||||
targetElement =
|
||||
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
||||
strategy = parts[1] || "innerHTML";
|
||||
}
|
||||
|
||||
let contents = Array.from(dom.body.children);
|
||||
if (options.select) {
|
||||
contents = Array.from(dom.querySelectorAll(options.select));
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case "append":
|
||||
targetElement.append(...contents);
|
||||
break;
|
||||
case "outerHTML":
|
||||
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
||||
break;
|
||||
case "innerHTML":
|
||||
default:
|
||||
Array.from(targetElement.children).forEach((child) => {
|
||||
child.remove();
|
||||
});
|
||||
targetElement.append(...contents);
|
||||
}
|
||||
}
|
||||
|
||||
export function fireEvents(events) {
|
||||
if (!events) {
|
||||
return;
|
||||
}
|
||||
events.split(",").forEach((eventName) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll(`[ld-on='${eventName}']`),
|
||||
);
|
||||
targets.push(document);
|
||||
targets.forEach((target) => {
|
||||
target.dispatchEvent(new CustomEvent(eventName));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
const modalOverlay = element.querySelector(".modal-overlay");
|
||||
const closeButton = element.querySelector("button.close");
|
||||
modalOverlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener("animationend", (event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-modal", ModalBehavior);
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import { ApiClient } from "../api";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const wrapper = document.createElement("div");
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
const apiClient = new ApiClient(apiBaseUrl);
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: wrapper,
|
||||
props: {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
value: element.value,
|
||||
placeholder: element.getAttribute("placeholder") || "",
|
||||
apiClient: apiClient,
|
||||
variant: element.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
||||
element.replaceWith(wrapper.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-autocomplete", TagAutocomplete);
|
||||
@@ -1,261 +0,0 @@
|
||||
<script>
|
||||
import {SearchHistory} from "./SearchHistory";
|
||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
const searchHistory = new SearchHistory()
|
||||
|
||||
export let name;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let tags;
|
||||
export let mode = '';
|
||||
export let apiClient;
|
||||
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
|
||||
let tagSuggestions = []
|
||||
const currentWord = getCurrentWord(input)
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||
const searchTag = currentWord.substring(1, currentWord.length)
|
||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
.slice(0, 5)
|
||||
.map(tagName => ({
|
||||
type: 'tag',
|
||||
index: nextIndex(),
|
||||
label: `#${tagName}`,
|
||||
tagName: tagName
|
||||
}))
|
||||
}
|
||||
|
||||
// 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 apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.website_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>
|
||||
@@ -1,180 +0,0 @@
|
||||
<script>
|
||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let apiClient;
|
||||
export let variant = 'default';
|
||||
|
||||
let tags = [];
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
let suggestionList = null;
|
||||
|
||||
let suggestions = [];
|
||||
let selectedIndex = 0;
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
// For now we cache all tags on load as the template did before
|
||||
try {
|
||||
tags = await apiClient.getTags({limit: 5000, offset: 0});
|
||||
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
||||
} catch (e) {
|
||||
console.warn('TagAutocomplete: Error loading tag list');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
isFocus = false;
|
||||
close();
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
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>
|
||||
153
bookmarks/frontend/components/bookmark-page.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { HeadlessElement } from "../utils/element.js";
|
||||
|
||||
class BookmarkPage extends HeadlessElement {
|
||||
init() {
|
||||
this.update = this.update.bind(this);
|
||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||
this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this);
|
||||
this.onBulkActionChange = this.onBulkActionChange.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
|
||||
this.oldItems = [];
|
||||
this.update();
|
||||
document.addEventListener("bookmark-list-updated", this.update);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("bookmark-list-updated", this.update);
|
||||
}
|
||||
|
||||
update() {
|
||||
const items = this.querySelectorAll("ul.bookmark-list > li");
|
||||
this.updateTooltips(items);
|
||||
this.updateNotesToggles(items, this.oldItems);
|
||||
this.updateBulkEdit(items, this.oldItems);
|
||||
this.oldItems = items;
|
||||
}
|
||||
|
||||
updateTooltips(items) {
|
||||
// Add tooltip to title if it is truncated
|
||||
items.forEach((item) => {
|
||||
const titleAnchor = item.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
} else {
|
||||
delete titleAnchor.dataset.tooltip;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateNotesToggles(items, oldItems) {
|
||||
oldItems.forEach((oldItem) => {
|
||||
const oldToggle = oldItem.querySelector(".toggle-notes");
|
||||
if (oldToggle) {
|
||||
oldToggle.removeEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
});
|
||||
|
||||
items.forEach((item) => {
|
||||
const notesToggle = item.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.target.closest("li").classList.toggle("show-notes");
|
||||
}
|
||||
|
||||
updateBulkEdit() {
|
||||
if (this.hasAttribute("no-bulk-edit")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing listeners
|
||||
this.activeToggle?.removeEventListener("click", this.onToggleBulkEdit);
|
||||
this.actionSelect?.removeEventListener("change", this.onBulkActionChange);
|
||||
this.allCheckbox?.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes?.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Re-query elements
|
||||
this.activeToggle = this.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.querySelector("select[name='bulk_action']");
|
||||
this.allCheckbox = this.querySelector(".bulk-edit-checkbox.all input");
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
this.selectAcross = this.querySelector("label.select-across");
|
||||
this.executeButton = this.querySelector("button[name='bulk_execute']");
|
||||
|
||||
// Add listeners
|
||||
this.activeToggle.addEventListener("click", this.onToggleBulkEdit);
|
||||
this.actionSelect.addEventListener("change", this.onBulkActionChange);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Reset checkbox states
|
||||
this.allCheckbox.checked = false;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.updateSelectAcross(false);
|
||||
this.updateExecuteButton();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
onToggleBulkEdit() {
|
||||
this.classList.toggle("active");
|
||||
}
|
||||
|
||||
onBulkActionChange() {
|
||||
this.dataset.bulkAction = this.actionSelect.value;
|
||||
}
|
||||
|
||||
onToggleAll() {
|
||||
const allChecked = this.allCheckbox.checked;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = allChecked;
|
||||
});
|
||||
this.updateSelectAcross(allChecked);
|
||||
this.updateExecuteButton();
|
||||
}
|
||||
|
||||
onToggleBookmark() {
|
||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.allCheckbox.checked = allChecked;
|
||||
this.updateSelectAcross(allChecked);
|
||||
this.updateExecuteButton();
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
} else {
|
||||
this.selectAcross.classList.add("d-none");
|
||||
this.selectAcross.querySelector("input").checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateExecuteButton() {
|
||||
const anyChecked = this.bookmarkCheckboxes.some((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.executeButton.disabled = !anyChecked;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-bookmark-page", BookmarkPage);
|
||||
30
bookmarks/frontend/components/clear-button.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { HeadlessElement } from "../utils/element";
|
||||
|
||||
class ClearButton extends HeadlessElement {
|
||||
init() {
|
||||
this.field = document.getElementById(this.dataset.for);
|
||||
if (!this.field) {
|
||||
console.error(`Field with ID ${this.dataset.for} not found`);
|
||||
return;
|
||||
}
|
||||
this.update = this.update.bind(this);
|
||||
this.clear = this.clear.bind(this);
|
||||
|
||||
this.addEventListener("click", this.clear);
|
||||
this.field.addEventListener("input", this.update);
|
||||
this.field.addEventListener("value-changed", this.update);
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
this.style.display = this.field.value ? "inline" : "none";
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.field.value = "";
|
||||
this.field.focus();
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-clear-button", ClearButton);
|
||||
103
bookmarks/frontend/components/confirm-dropdown.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { FocusTrapController, isKeyboardActive } from "../utils/focus.js";
|
||||
import { PositionController } from "../utils/position-controller.js";
|
||||
|
||||
let confirmId = 0;
|
||||
|
||||
function nextConfirmId() {
|
||||
return `confirm-${confirmId++}`;
|
||||
}
|
||||
|
||||
function removeAll() {
|
||||
document
|
||||
.querySelectorAll("ld-confirm-dropdown")
|
||||
.forEach((dropdown) => dropdown.close());
|
||||
}
|
||||
|
||||
// Create a confirm dropdown whenever a button with the data-confirm attribute is clicked
|
||||
document.addEventListener("click", (event) => {
|
||||
// Check if the clicked element is a button with data-confirm
|
||||
const button = event.target.closest("button[data-confirm]");
|
||||
if (!button) return;
|
||||
|
||||
// Remove any existing confirm dropdowns
|
||||
removeAll();
|
||||
|
||||
// Show confirmation dropdown
|
||||
event.preventDefault();
|
||||
|
||||
const dropdown = document.createElement("ld-confirm-dropdown");
|
||||
dropdown.button = button;
|
||||
document.body.appendChild(dropdown);
|
||||
});
|
||||
|
||||
// Remove all confirm dropdowns when:
|
||||
// - Turbo caches the page
|
||||
// - The escape key is pressed
|
||||
document.addEventListener("turbo:before-cache", removeAll);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
removeAll();
|
||||
}
|
||||
});
|
||||
|
||||
class ConfirmDropdown extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.confirmId = nextConfirmId();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated(props) {
|
||||
super.firstUpdated(props);
|
||||
this.classList.add("dropdown", "confirm-dropdown", "active");
|
||||
|
||||
const menu = this.querySelector(".menu");
|
||||
this.positionController = new PositionController({
|
||||
anchor: this.button,
|
||||
overlay: menu,
|
||||
arrow: this.querySelector(".menu-arrow"),
|
||||
offset: 12,
|
||||
});
|
||||
this.positionController.enable();
|
||||
this.focusTrap = new FocusTrapController(menu);
|
||||
}
|
||||
|
||||
render() {
|
||||
const questionText = this.button.dataset.confirmQuestion || "Are you sure?";
|
||||
return html`
|
||||
<div
|
||||
class="menu with-arrow"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby=${this.confirmId}
|
||||
>
|
||||
<span id=${this.confirmId} style="font-weight: bold;">
|
||||
${questionText}
|
||||
</span>
|
||||
<button type="button" class="btn" @click=${this.close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-error" @click=${this.confirm}>
|
||||
Confirm
|
||||
</button>
|
||||
<div class="menu-arrow"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.button.closest("form").requestSubmit(this.button);
|
||||
this.close();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.positionController.disable();
|
||||
this.focusTrap.destroy();
|
||||
this.remove();
|
||||
this.button.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-confirm-dropdown", ConfirmDropdown);
|
||||
16
bookmarks/frontend/components/details-modal.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { setAfterPageLoadFocusTarget } from "../utils/focus.js";
|
||||
import { Modal } from "./modal.js";
|
||||
|
||||
class DetailsModal extends Modal {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.dataset.bookmarkId;
|
||||
setAfterPageLoadFocusTarget(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-details-modal", DetailsModal);
|
||||
257
bookmarks/frontend/components/dev-tool.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
|
||||
class DevTool extends LitElement {
|
||||
static properties = {
|
||||
profile: { type: Object, state: true },
|
||||
formAction: { type: String, attribute: "data-form-action" },
|
||||
csrfToken: { type: String, attribute: "data-csrf-token" },
|
||||
isOpen: { type: Boolean, state: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: var(--btn-primary-bg-color);
|
||||
color: var(--btn-primary-text-color);
|
||||
border: none;
|
||||
padding: var(--unit-2);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--btn-box-shadow);
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
background: var(--body-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--unit-2);
|
||||
margin-bottom: var(--unit-2);
|
||||
min-width: 220px;
|
||||
box-shadow: var(--box-shadow-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
:host([open]) .overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--unit-2) 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label:has(select) {
|
||||
margin-bottom: var(--unit-1);
|
||||
}
|
||||
|
||||
label:has(select) span {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: var(--unit-2) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
`;
|
||||
|
||||
static fields = [
|
||||
{
|
||||
type: "select",
|
||||
key: "theme",
|
||||
label: "Theme",
|
||||
options: [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "bookmark_date_display",
|
||||
label: "Date",
|
||||
options: [
|
||||
{ value: "relative", label: "Relative" },
|
||||
{ value: "absolute", label: "Absolute" },
|
||||
{ value: "hidden", label: "Hidden" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "bookmark_description_display",
|
||||
label: "Description",
|
||||
options: [
|
||||
{ value: "inline", label: "Inline" },
|
||||
{ value: "separate", label: "Separate" },
|
||||
],
|
||||
},
|
||||
{ type: "checkbox", key: "enable_favicons", label: "Favicons" },
|
||||
{ type: "checkbox", key: "enable_preview_images", label: "Preview images" },
|
||||
{ type: "checkbox", key: "display_url", label: "Display URL" },
|
||||
{ type: "checkbox", key: "permanent_notes", label: "Permanent notes" },
|
||||
{ type: "checkbox", key: "collapse_side_panel", label: "Collapse sidebar" },
|
||||
{ type: "checkbox", key: "sticky_pagination", label: "Sticky pagination" },
|
||||
{ type: "checkbox", key: "hide_bundles", label: "Hide bundles" },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.isOpen = false;
|
||||
this.profile = {};
|
||||
this._onOutsideClick = this._onOutsideClick.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const profileData = document.getElementById("json_profile");
|
||||
this.profile = JSON.parse(profileData.textContent || "{}");
|
||||
document.addEventListener("click", this._onOutsideClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("click", this._onOutsideClick);
|
||||
}
|
||||
|
||||
_onOutsideClick(e) {
|
||||
if (!this.contains(e.target) && this.isOpen) {
|
||||
this.isOpen = false;
|
||||
this.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
if (this.isOpen) {
|
||||
this.setAttribute("open", "");
|
||||
} else {
|
||||
this.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
_handleChange(key, value) {
|
||||
this.profile = { ...this.profile, [key]: value };
|
||||
if (key === "theme") {
|
||||
const themeLinks = document.head.querySelectorAll('link[href*="theme"]');
|
||||
themeLinks.forEach((link) => link.remove());
|
||||
}
|
||||
this._submitForm();
|
||||
}
|
||||
|
||||
_renderField(field) {
|
||||
switch (field.type) {
|
||||
case "checkbox":
|
||||
return html`
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this.profile[field.key] || false}
|
||||
@change=${(e) => this._handleChange(field.key, e.target.checked)}
|
||||
/>
|
||||
${field.label}
|
||||
</label>
|
||||
`;
|
||||
case "select":
|
||||
return html`
|
||||
<label>
|
||||
<span>${field.label}:</span>
|
||||
<select
|
||||
@change=${(e) => this._handleChange(field.key, e.target.value)}
|
||||
>
|
||||
${field.options.map(
|
||||
(opt) => html`
|
||||
<option
|
||||
value=${opt.value}
|
||||
?selected=${this.profile[field.key] === opt.value}
|
||||
>
|
||||
${opt.label}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
case "divider":
|
||||
return html`<hr />`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _submitForm() {
|
||||
const formData = new FormData();
|
||||
formData.append("csrfmiddlewaretoken", this.csrfToken);
|
||||
|
||||
// Profile fields
|
||||
for (const [key, value] of Object.entries(this.profile)) {
|
||||
if (typeof value === "boolean" && value) {
|
||||
formData.append(key, "on");
|
||||
} else if (typeof value !== "boolean") {
|
||||
formData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Submit button name that settings.update expects
|
||||
formData.append("update_profile", "1");
|
||||
|
||||
await fetch(this.formAction, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set("ts", Date.now().toString());
|
||||
window.history.replaceState({}, "", url);
|
||||
|
||||
Turbo.visit(url.toString());
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button class="button" @click=${() => this._toggle()}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065"
|
||||
/>
|
||||
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overlay">
|
||||
<h3>Dev Tools</h3>
|
||||
${DevTool.fields.map((field) => this._renderField(field))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-dev-tool", DevTool);
|
||||
72
bookmarks/frontend/components/dropdown.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { HeadlessElement } from "../utils/element.js";
|
||||
|
||||
class Dropdown extends HeadlessElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click when JS is enabled
|
||||
this.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.addEventListener("keydown", this.onEscape);
|
||||
this.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = this.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.classList.remove("active");
|
||||
this.toggle?.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-dropdown", Dropdown);
|
||||
110
bookmarks/frontend/components/filter-drawer.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { html, render } from "lit";
|
||||
import { Modal } from "./modal.js";
|
||||
import { HeadlessElement } from "../utils/element.js";
|
||||
import { isKeyboardActive } from "../utils/focus.js";
|
||||
|
||||
class FilterDrawerTrigger extends HeadlessElement {
|
||||
init() {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("ld-filter-drawer");
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-filter-drawer-trigger", FilterDrawerTrigger);
|
||||
|
||||
class FilterDrawer extends Modal {
|
||||
connectedCallback() {
|
||||
this.classList.add("modal", "drawer");
|
||||
|
||||
// Render modal structure
|
||||
render(
|
||||
html`
|
||||
<div class="modal-overlay" data-close-modal></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button
|
||||
class="btn btn-noborder close"
|
||||
aria-label="Close dialog"
|
||||
data-close-modal
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body"></div>
|
||||
</div>
|
||||
`,
|
||||
this,
|
||||
);
|
||||
// Teleport filter content
|
||||
this.teleport();
|
||||
// Force close on turbo cache to restore content
|
||||
this.doClose = this.doClose.bind(this);
|
||||
document.addEventListener("turbo:before-cache", this.doClose);
|
||||
// Force reflow to make transform transition work
|
||||
this.getBoundingClientRect();
|
||||
// Add active class to start slide-in animation
|
||||
requestAnimationFrame(() => this.classList.add("active"));
|
||||
// Call super.init() after rendering to ensure elements are available
|
||||
super.init();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.teleportBack();
|
||||
document.removeEventListener("turbo:before-cache", this.doClose);
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.querySelector(".modal-body");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
const content = this.querySelector(".modal-body");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("ld-filter-drawer-trigger") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-filter-drawer", FilterDrawer);
|
||||
71
bookmarks/frontend/components/form.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { HeadlessElement } from "../utils/element.js";
|
||||
|
||||
class Form extends HeadlessElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.addEventListener("keydown", this.onKeyDown);
|
||||
this.addEventListener("change", this.onChange);
|
||||
|
||||
if (this.hasAttribute("data-form-reset")) {
|
||||
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||
// Useful for filter forms where navigating back would otherwise still show
|
||||
// values from after the form submission, which means the filters would be out
|
||||
// of sync with the URL.
|
||||
this.initFormReset();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.hasAttribute("data-form-reset")) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
onChange(event) {
|
||||
if (event.target.hasAttribute("data-submit-on-change")) {
|
||||
this.querySelector("form")?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Check for Ctrl/Cmd + Enter combination
|
||||
if (
|
||||
this.hasAttribute("data-submit-on-ctrl-enter") &&
|
||||
event.key === "Enter" &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.querySelector("form")?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
initFormReset() {
|
||||
this.controls = this.querySelectorAll("input, select");
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.__initialValue = control.checked;
|
||||
} else {
|
||||
control.__initialValue = control.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.checked = control.__initialValue;
|
||||
} else {
|
||||
control.value = control.__initialValue;
|
||||
}
|
||||
delete control.__initialValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-form", Form);
|
||||
78
bookmarks/frontend/components/modal.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { FocusTrapController } from "../utils/focus.js";
|
||||
import { HeadlessElement } from "../utils/element.js";
|
||||
|
||||
export class Modal extends HeadlessElement {
|
||||
init() {
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
||||
btn.addEventListener("click", this.onClose);
|
||||
});
|
||||
this.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.setupScrollLock();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeScrollLock();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
setupScrollLock() {
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
removeScrollLock() {
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.classList.add("closing");
|
||||
this.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
doClose() {
|
||||
this.remove();
|
||||
this.dispatchEvent(new CustomEvent("modal:close"));
|
||||
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.dataset.closeUrl;
|
||||
const frame = this.dataset.turboFrame;
|
||||
const action = this.dataset.turboAction || "replace";
|
||||
if (closeUrl) {
|
||||
Turbo.visit(closeUrl, { action, frame: frame });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-modal", Modal);
|
||||
320
bookmarks/frontend/components/search-autocomplete.js
Normal file
@@ -0,0 +1,320 @@
|
||||
import { html } from "lit";
|
||||
import { api } from "../api.js";
|
||||
import { TurboLitElement } from "../utils/element.js";
|
||||
import {
|
||||
clampText,
|
||||
debounce,
|
||||
getCurrentWord,
|
||||
getCurrentWordBounds,
|
||||
} from "../utils/input.js";
|
||||
import { PositionController } from "../utils/position-controller.js";
|
||||
import { SearchHistory } from "../utils/search-history.js";
|
||||
import { cache } from "../utils/tag-cache.js";
|
||||
|
||||
export class SearchAutocomplete extends TurboLitElement {
|
||||
static properties = {
|
||||
inputName: { type: String, attribute: "input-name" },
|
||||
inputPlaceholder: { type: String, attribute: "input-placeholder" },
|
||||
inputValue: { type: String, attribute: "input-value" },
|
||||
mode: { type: String },
|
||||
user: { type: String },
|
||||
shared: { type: String },
|
||||
unread: { type: String },
|
||||
target: { type: String },
|
||||
isFocus: { state: true },
|
||||
isOpen: { state: true },
|
||||
suggestions: { state: true },
|
||||
selectedIndex: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.inputName = "";
|
||||
this.inputPlaceholder = "";
|
||||
this.inputValue = "";
|
||||
this.mode = "";
|
||||
this.target = "_blank";
|
||||
this.isFocus = false;
|
||||
this.isOpen = false;
|
||||
this.suggestions = {
|
||||
recentSearches: [],
|
||||
bookmarks: [],
|
||||
tags: [],
|
||||
total: [],
|
||||
};
|
||||
this.selectedIndex = undefined;
|
||||
this.input = null;
|
||||
this.menu = null;
|
||||
this.searchHistory = new SearchHistory();
|
||||
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.style.setProperty("--menu-max-height", "400px");
|
||||
this.input = this.querySelector("input");
|
||||
this.menu = this.querySelector(".menu");
|
||||
// Track current search query after loading the page
|
||||
this.searchHistory.pushCurrent();
|
||||
this.updateSuggestions();
|
||||
this.positionController = new PositionController({
|
||||
anchor: this.input,
|
||||
overlay: this.menu,
|
||||
autoWidth: true,
|
||||
placement: "bottom-start",
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
this.isFocus = true;
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
this.isFocus = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
this.inputValue = e.target.value;
|
||||
this.debouncedLoadSuggestions();
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
// Enter
|
||||
if (
|
||||
this.isOpen &&
|
||||
this.selectedIndex !== undefined &&
|
||||
(e.keyCode === 13 || e.keyCode === 9)
|
||||
) {
|
||||
const suggestion = this.suggestions.total[this.selectedIndex];
|
||||
if (suggestion) this.completeSuggestion(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Escape
|
||||
if (e.keyCode === 27) {
|
||||
this.close();
|
||||
e.preventDefault();
|
||||
}
|
||||
// Up arrow
|
||||
if (e.keyCode === 38) {
|
||||
this.updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Down arrow
|
||||
if (e.keyCode === 40) {
|
||||
if (!this.isOpen) {
|
||||
this.loadSuggestions();
|
||||
} else {
|
||||
this.updateSelection(1);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.positionController.enable();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.updateSuggestions();
|
||||
this.selectedIndex = undefined;
|
||||
this.positionController.disable();
|
||||
}
|
||||
|
||||
hasSuggestions() {
|
||||
return this.suggestions.total.length > 0;
|
||||
}
|
||||
|
||||
async loadSuggestions() {
|
||||
let suggestionIndex = 0;
|
||||
|
||||
function nextIndex() {
|
||||
return suggestionIndex++;
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
const tags = await cache.getTags();
|
||||
let tagSuggestions = [];
|
||||
const currentWord = getCurrentWord(this.input);
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === "#") {
|
||||
const searchTag = currentWord.substring(1, currentWord.length);
|
||||
tagSuggestions = (tags || [])
|
||||
.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((tag) => ({
|
||||
type: "tag",
|
||||
index: nextIndex(),
|
||||
label: `#${tag.name}`,
|
||||
tagName: tag.name,
|
||||
}));
|
||||
}
|
||||
|
||||
// Recent search suggestions
|
||||
const recentSearches = this.searchHistory
|
||||
.getRecentSearches(this.inputValue, 5)
|
||||
.map((value) => ({
|
||||
type: "search",
|
||||
index: nextIndex(),
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
|
||||
// Bookmark suggestions
|
||||
let bookmarks = [];
|
||||
|
||||
if (this.inputValue && this.inputValue.length >= 3) {
|
||||
const path = this.mode ? `/${this.mode}` : "";
|
||||
const suggestionSearch = {
|
||||
user: this.user,
|
||||
shared: this.shared,
|
||||
unread: this.unread,
|
||||
q: this.inputValue,
|
||||
};
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
path,
|
||||
});
|
||||
bookmarks = fetchedBookmarks.map((bookmark) => {
|
||||
const fullLabel = bookmark.title || bookmark.url;
|
||||
const label = clampText(fullLabel, 60);
|
||||
return {
|
||||
type: "bookmark",
|
||||
index: nextIndex(),
|
||||
label,
|
||||
bookmark,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);
|
||||
|
||||
if (this.hasSuggestions()) {
|
||||
this.open();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
||||
recentSearches = recentSearches || [];
|
||||
bookmarks = bookmarks || [];
|
||||
tagSuggestions = tagSuggestions || [];
|
||||
this.suggestions = {
|
||||
recentSearches,
|
||||
bookmarks,
|
||||
tags: tagSuggestions,
|
||||
total: [...tagSuggestions, ...recentSearches, ...bookmarks],
|
||||
};
|
||||
}
|
||||
|
||||
completeSuggestion(suggestion) {
|
||||
if (suggestion.type === "search") {
|
||||
this.inputValue = suggestion.value;
|
||||
this.close();
|
||||
}
|
||||
if (suggestion.type === "bookmark") {
|
||||
window.open(suggestion.bookmark.url, this.target);
|
||||
this.close();
|
||||
}
|
||||
if (suggestion.type === "tag") {
|
||||
const bounds = getCurrentWordBounds(this.input);
|
||||
const inputValue = this.input.value;
|
||||
this.input.value =
|
||||
inputValue.substring(0, bounds.start) +
|
||||
`#${suggestion.tagName} ` +
|
||||
inputValue.substring(bounds.end);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection(dir) {
|
||||
const length = this.suggestions.total.length;
|
||||
|
||||
if (length === 0) return;
|
||||
|
||||
if (this.selectedIndex === undefined) {
|
||||
this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let newIndex = this.selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
this.selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
renderSuggestions(suggestions, title) {
|
||||
if (suggestions.length === 0) return "";
|
||||
|
||||
return html`
|
||||
<li class="menu-item group-item">${title}</li>
|
||||
${suggestions.map(
|
||||
(suggestion) => html`
|
||||
<li
|
||||
class="menu-item ${this.selectedIndex === suggestion.index
|
||||
? "selected"
|
||||
: ""}"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@mousedown=${(e) => {
|
||||
e.preventDefault();
|
||||
this.completeSuggestion(suggestion);
|
||||
}}
|
||||
>
|
||||
${suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="form-autocomplete">
|
||||
<div
|
||||
class="form-autocomplete-input form-input ${this.isFocus
|
||||
? "is-focused"
|
||||
: ""}"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
class="form-input"
|
||||
name="${this.inputName}"
|
||||
placeholder="${this.inputPlaceholder}"
|
||||
autocomplete="off"
|
||||
.value="${this.inputValue}"
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="menu ${this.isOpen ? "open" : ""}">
|
||||
${this.renderSuggestions(this.suggestions.tags, "Tags")}
|
||||
${this.renderSuggestions(
|
||||
this.suggestions.recentSearches,
|
||||
"Recent Searches",
|
||||
)}
|
||||
${this.renderSuggestions(this.suggestions.bookmarks, "Bookmarks")}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-search-autocomplete", SearchAutocomplete);
|
||||
206
bookmarks/frontend/components/tag-autocomplete.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { TurboLitElement } from "../utils/element.js";
|
||||
import { getCurrentWord, getCurrentWordBounds } from "../utils/input.js";
|
||||
import { PositionController } from "../utils/position-controller.js";
|
||||
import { cache } from "../utils/tag-cache.js";
|
||||
|
||||
export class TagAutocomplete extends TurboLitElement {
|
||||
static properties = {
|
||||
inputId: { type: String, attribute: "input-id" },
|
||||
inputName: { type: String, attribute: "input-name" },
|
||||
inputValue: { type: String, attribute: "input-value" },
|
||||
inputClass: { type: String, attribute: "input-class" },
|
||||
inputPlaceholder: { type: String, attribute: "input-placeholder" },
|
||||
inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" },
|
||||
variant: { type: String },
|
||||
isFocus: { state: true },
|
||||
isOpen: { state: true },
|
||||
suggestions: { state: true },
|
||||
selectedIndex: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.inputId = "";
|
||||
this.inputName = "";
|
||||
this.inputValue = "";
|
||||
this.inputPlaceholder = "";
|
||||
this.inputAriaDescribedBy = "";
|
||||
this.variant = "default";
|
||||
this.isFocus = false;
|
||||
this.isOpen = false;
|
||||
this.suggestions = [];
|
||||
this.selectedIndex = 0;
|
||||
this.input = null;
|
||||
this.suggestionList = null;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.input = this.querySelector("input");
|
||||
this.suggestionList = this.querySelector(".menu");
|
||||
this.positionController = new PositionController({
|
||||
anchor: this.input,
|
||||
overlay: this.suggestionList,
|
||||
autoWidth: true,
|
||||
placement: "bottom-start",
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
this.isFocus = true;
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
this.isFocus = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
async handleInput(e) {
|
||||
this.input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(this.input);
|
||||
|
||||
this.suggestions = word
|
||||
? tags.filter(
|
||||
(tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
if (word && this.suggestions.length > 0) {
|
||||
this.open();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||
const suggestion = this.suggestions[this.selectedIndex];
|
||||
this.complete(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 27) {
|
||||
this.close();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 38) {
|
||||
this.updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 40) {
|
||||
this.updateSelection(1);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.selectedIndex = 0;
|
||||
this.positionController.enable();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.suggestions = [];
|
||||
this.selectedIndex = 0;
|
||||
this.positionController.disable();
|
||||
}
|
||||
|
||||
complete(suggestion) {
|
||||
const bounds = getCurrentWordBounds(this.input);
|
||||
const value = this.input.value;
|
||||
this.input.value =
|
||||
value.substring(0, bounds.start) +
|
||||
suggestion.name +
|
||||
" " +
|
||||
value.substring(bounds.end);
|
||||
this.dispatchEvent(new CustomEvent("input", { bubbles: true }));
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
updateSelection(dir) {
|
||||
const length = this.suggestions.length;
|
||||
let newIndex = this.selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
this.selectedIndex = newIndex;
|
||||
|
||||
// Scroll to selected list item
|
||||
setTimeout(() => {
|
||||
if (this.suggestionList) {
|
||||
const selectedListItem =
|
||||
this.suggestionList.querySelector("li.selected");
|
||||
if (selectedListItem) {
|
||||
selectedListItem.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
|
||||
<!-- autocomplete input container -->
|
||||
<div
|
||||
class="form-autocomplete-input form-input ${this.isFocus
|
||||
? "is-focused"
|
||||
: ""}"
|
||||
>
|
||||
<!-- autocomplete real input box -->
|
||||
<input
|
||||
id="${this.inputId || nothing}"
|
||||
name="${this.inputName || nothing}"
|
||||
.value="${this.inputValue || ""}"
|
||||
placeholder="${this.inputPlaceholder || " "}"
|
||||
class="form-input ${this.inputClass || ""}"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-describedby="${this.inputAriaDescribedBy || nothing}"
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- autocomplete suggestion list -->
|
||||
<ul
|
||||
class="menu ${this.isOpen && this.suggestions.length > 0
|
||||
? "open"
|
||||
: ""}"
|
||||
>
|
||||
<!-- menu list items -->
|
||||
${this.suggestions.map(
|
||||
(tag, i) => html`
|
||||
<li
|
||||
class="menu-item ${this.selectedIndex === i ? "selected" : ""}"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@mousedown=${(e) => {
|
||||
e.preventDefault();
|
||||
this.complete(tag);
|
||||
}}
|
||||
>
|
||||
${tag.name}
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-tag-autocomplete", TagAutocomplete);
|
||||
31
bookmarks/frontend/components/upload-button.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { HeadlessElement } from "../utils/element.js";
|
||||
|
||||
class UploadButton extends HeadlessElement {
|
||||
init() {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
|
||||
this.button = this.querySelector('button[type="submit"]');
|
||||
this.button.addEventListener("click", this.onClick);
|
||||
|
||||
this.fileInput = this.querySelector('input[type="file"]');
|
||||
this.fileInput.addEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
onChange() {
|
||||
// Check if the file input has a file selected
|
||||
if (!this.fileInput.files.length) {
|
||||
return;
|
||||
}
|
||||
this.closest("form").requestSubmit(this.button);
|
||||
// remove selected file so it doesn't get submitted again
|
||||
this.fileInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-upload-button", UploadButton);
|
||||
@@ -1,13 +1,14 @@
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/fetch";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { ApiClient } from "./api";
|
||||
import "./components/bookmark-page.js";
|
||||
import "./components/clear-button.js";
|
||||
import "./components/confirm-dropdown.js";
|
||||
import "./components/details-modal.js";
|
||||
import "./components/dev-tool.js";
|
||||
import "./components/dropdown.js";
|
||||
import "./components/filter-drawer.js";
|
||||
import "./components/form.js";
|
||||
import "./components/modal.js";
|
||||
import "./components/search-autocomplete.js";
|
||||
import "./components/tag-autocomplete.js";
|
||||
import "./components/upload-button.js";
|
||||
import "./shortcuts.js";
|
||||
|
||||
62
bookmarks/frontend/shortcuts.js
Normal file
@@ -0,0 +1,62 @@
|
||||
document.addEventListener("keydown", (event) => {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle shortcuts for navigating bookmarks with arrow keys
|
||||
const isArrowUp = event.key === "ArrowUp";
|
||||
const isArrowDown = event.key === "ArrowDown";
|
||||
if (isArrowUp || isArrowDown) {
|
||||
event.preventDefault();
|
||||
|
||||
// Detect current bookmark list item
|
||||
const items = [...document.querySelectorAll("ul.bookmark-list > li")];
|
||||
const path = event.composedPath();
|
||||
const currentItem = path.find((item) => items.includes(item));
|
||||
|
||||
// Find next item
|
||||
let nextItem;
|
||||
if (currentItem) {
|
||||
nextItem = isArrowUp
|
||||
? currentItem.previousElementSibling
|
||||
: currentItem.nextElementSibling;
|
||||
} else {
|
||||
// Select first item
|
||||
nextItem = items[0];
|
||||
}
|
||||
// Focus first link
|
||||
if (nextItem) {
|
||||
nextItem.querySelector("a").focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for toggling all notes
|
||||
if (event.key === "e") {
|
||||
const list = document.querySelector(".bookmark-list");
|
||||
if (list) {
|
||||
list.classList.toggle("show-notes");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for focusing search input
|
||||
if (event.key === "s") {
|
||||
const searchInput = document.querySelector('input[type="search"]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for adding new bookmark
|
||||
if (event.key === "n") {
|
||||
window.location.assign("/bookmarks/new");
|
||||
}
|
||||
});
|
||||
82
bookmarks/frontend/utils/element.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { LitElement } from "lit";
|
||||
|
||||
/**
|
||||
* Base class for custom elements that wrap existing server-rendered DOM.
|
||||
*
|
||||
* Handles timing issues where connectedCallback fires before child elements
|
||||
* are parsed during initial page load. With Turbo navigation, children are
|
||||
* always available, but on fresh page loads they may not be.
|
||||
*
|
||||
* Subclasses should override init() instead of connectedCallback().
|
||||
*/
|
||||
export class HeadlessElement extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (this.__initialized) {
|
||||
return;
|
||||
}
|
||||
this.__initialized = true;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("turbo:load", () => this.init(), {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Override in subclass
|
||||
}
|
||||
}
|
||||
|
||||
let isTopFrameVisit = false;
|
||||
|
||||
document.addEventListener("turbo:visit", (event) => {
|
||||
const url = event.detail.url;
|
||||
isTopFrameVisit =
|
||||
document.querySelector(`turbo-frame[src="${url}"][target="_top"]`) !== null;
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:render", () => {
|
||||
isTopFrameVisit = false;
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:before-morph-element", (event) => {
|
||||
const parent = event.target?.parentElement;
|
||||
if (parent instanceof TurboLitElement) {
|
||||
// Prevent Turbo from morphing Lit elements contents, which would remove
|
||||
// elements rendered on the client side.
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
export class TurboLitElement extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.__prepareForCache = this.__prepareForCache.bind(this);
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // Render to light DOM
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
document.addEventListener("turbo:before-cache", this.__prepareForCache);
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("turbo:before-cache", this.__prepareForCache);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
__prepareForCache() {
|
||||
// Remove rendered contents before caching, otherwise restoring the DOM from
|
||||
// cache will result in duplicated contents. Turbo also fires before-cache
|
||||
// when rendering a frame that does target the top frame, in which case we
|
||||
// want to keep the contents.
|
||||
if (!isTopFrameVisit) {
|
||||
this.innerHTML = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
130
bookmarks/frontend/utils/focus.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);
|
||||
}
|
||||
});
|
||||
71
bookmarks/frontend/utils/position-controller.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
arrow,
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
export class PositionController {
|
||||
constructor(options) {
|
||||
this.anchor = options.anchor;
|
||||
this.overlay = options.overlay;
|
||||
this.arrow = options.arrow;
|
||||
this.placement = options.placement || "bottom";
|
||||
this.offset = options.offset;
|
||||
this.autoWidth = options.autoWidth || false;
|
||||
this.autoUpdateCleanup = null;
|
||||
}
|
||||
|
||||
enable() {
|
||||
if (!this.autoUpdateCleanup) {
|
||||
this.autoUpdateCleanup = autoUpdate(this.anchor, this.overlay, () =>
|
||||
this.updatePosition(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
if (this.autoUpdateCleanup) {
|
||||
this.autoUpdateCleanup();
|
||||
this.autoUpdateCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
const middleware = [flip(), shift()];
|
||||
if (this.arrow) {
|
||||
middleware.push(arrow({ element: this.arrow }));
|
||||
}
|
||||
if (this.offset) {
|
||||
middleware.push(offset(this.offset));
|
||||
}
|
||||
computePosition(this.anchor, this.overlay, {
|
||||
placement: this.placement,
|
||||
strategy: "fixed",
|
||||
middleware,
|
||||
}).then(({ x, y, placement, middlewareData }) => {
|
||||
Object.assign(this.overlay.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
|
||||
this.overlay.classList.remove("top-aligned", "bottom-aligned");
|
||||
this.overlay.classList.add(`${placement}-aligned`);
|
||||
|
||||
if (this.arrow) {
|
||||
const { x, y } = middlewareData.arrow;
|
||||
Object.assign(this.arrow.style, {
|
||||
left: x != null ? `${x}px` : "",
|
||||
top: y != null ? `${y}px` : "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (this.autoWidth) {
|
||||
const width = this.anchor.offsetWidth;
|
||||
this.overlay.style.width = `${width}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
bookmarks/frontend/utils/tag-cache.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { api } from "../api.js";
|
||||
|
||||
class TagCache {
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
|
||||
// Reset cached tags after a form submission
|
||||
document.addEventListener("turbo:submit-end", () => {
|
||||
this.tagsPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
getTags() {
|
||||
if (!this.tagsPromise) {
|
||||
this.tagsPromise = this.api
|
||||
.getTags({
|
||||
limit: 5000,
|
||||
offset: 0,
|
||||
})
|
||||
.then((tags) =>
|
||||
tags.sort((left, right) =>
|
||||
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.catch((e) => {
|
||||
console.warn("Cache: Error loading tags", e);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return this.tagsPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = new TagCache(api);
|
||||
@@ -1,5 +1,5 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
@@ -14,7 +14,7 @@ class Command(BaseCommand):
|
||||
destination = options["destination"]
|
||||
|
||||
def progress(status, remaining, total):
|
||||
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
|
||||
self.stdout.write(f"Copied {total - remaining} of {total} pages...")
|
||||
|
||||
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
backup_db = sqlite3.connect(destination)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
@@ -65,7 +65,7 @@ class Command(BaseCommand):
|
||||
|
||||
def backup_database(self, backup_db_file):
|
||||
def progress(status, remaining, total):
|
||||
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
|
||||
self.stdout.write(f"Copied {total - remaining} of {total} pages...")
|
||||
|
||||
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
backup_db = sqlite3.connect(backup_db_file)
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -15,10 +14,10 @@ class Command(BaseCommand):
|
||||
secret_key_file = os.path.join("data", "secretkey.txt")
|
||||
|
||||
if os.path.exists(secret_key_file):
|
||||
logger.info(f"Secret key file already exists")
|
||||
logger.info("Secret key file already exists")
|
||||
return
|
||||
|
||||
secret_key = get_random_secret_key()
|
||||
with open(secret_key_file, "w") as f:
|
||||
f.write(secret_key)
|
||||
logger.info(f"Generated secret key file")
|
||||
logger.info("Generated secret key file")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import importlib
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||
|
||||
from bookmarks.models import UserProfile, GlobalSettings
|
||||
from bookmarks.models import GlobalSettings, UserProfile
|
||||
|
||||
|
||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||
@@ -22,7 +22,7 @@ class LinkdingMiddleware:
|
||||
# add global settings to request
|
||||
try:
|
||||
global_settings = GlobalSettings.get()
|
||||
except:
|
||||
except Exception:
|
||||
global_settings = default_global_settings
|
||||
request.global_settings = global_settings
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Generated by Django 2.2.2 on 2019-06-28 23:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Generated by Django 2.2.2 on 2019-06-29 23:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("bookmarks", "0001_initial"),
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0002_auto_20190629_2303"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0003_auto_20200913_0656"),
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Generated by Django 2.2.13 on 2021-01-03 12:12
|
||||
|
||||
import bookmarks.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
import bookmarks.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0004_auto_20200926_1028"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0005_auto_20210103_1212"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Generated by Django 2.2.18 on 2021-03-26 22:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0007_userprofile"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0008_userprofile_bookmark_date_display"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0010_userprofile_bookmark_link_target"),
|
||||
]
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Generated by Django 3.2.6 on 2022-01-08 19:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("bookmarks", "0011_userprofile_web_archive_integration"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Generated by Django 3.2.6 on 2022-01-08 19:27
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import migrations
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Generated by Django 3.2.13 on 2022-07-23 20:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("bookmarks", "0014_alter_bookmark_unread"),
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0015_feedtoken"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0016_bookmark_shared"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0017_userprofile_enable_sharing"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0018_bookmark_favicon_file"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0019_userprofile_enable_favicons"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0020_userprofile_tag_search"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0021_userprofile_display_url"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0022_bookmark_notes"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0023_userprofile_permanent_notes"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0024_userprofile_enable_public_sharing"),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0025_userprofile_search_preferences"),
|
||||
]
|
||||
|
||||